feat(TrainingStats): enhance training statistics view with collapsible panels and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s

- Refactored the TrainingStatsView to implement collapsible sections for better organization of training statistics.
- Added new localization keys for training statistics panels in both German and English.
- Updated the mobile app's TrainingStatsScreen to utilize the new collapsible panel structure, improving user experience.
- Enhanced the MembersManager to merge training statistics into member data, providing a comprehensive view of member participation.
- Introduced new API methods for quick updates and transfers of member data, streamlining member management processes.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 16:15:19 +02:00
parent 7981371136
commit 6ef1d79a5f
17 changed files with 2852 additions and 373 deletions

View File

@@ -212,7 +212,6 @@ Web-Route: `/calendar` · Referenz: `CalendarView.vue` (Aggregation mehrerer Dat
### Phase 12 Backlog / offen
- [ ] **iOS:** Kalender-UI + Tab (derzeit nur Android `composeApp` / `MainTab.Calendar`)
- [ ] **i18n:** Kalender-Keys in `MobileStrings.kt` für alle unterstützten Sprachen ergänzen (nicht nur Fallback im Code)
- [ ] **Kalender vs. Web:** Offizielle Teilnahmen mobil per Browser vs. Web in-app bewusst lassen oder später angleichen
- [ ] **Release:** Bei `isMinifyEnabled = true` ProGuard/R8 für Ktor, `kotlinx.serialization`, ggf. Koin (`composeApp/proguard-rules.pro`)

View File

@@ -123,13 +123,15 @@ class AppDependencies(context: Context) {
AccidentApi(client),
MemberGroupPhotosApi(client),
)
private val trainingStatsApi = TrainingStatsApi(client)
val membersManager = MembersManager(
MembersApi(client),
TrainingGroupsApi(client),
MemberActivitiesApi(client),
TrainingTimesApi(client),
trainingStatsApi,
)
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
val trainingStatsManager = TrainingStatsManager(trainingStatsApi)
val scheduleManager = ScheduleManager(
ClubTeamsApi(client),
matchesApi,

View File

@@ -0,0 +1,156 @@
package de.tt_tagebuch.app.pdf
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberContactDto
import java.io.File
import java.io.FileOutputStream
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val PAGE_W = 595
private const val PAGE_H = 842
private const val MARGIN = 40f
private const val MAX_TEXT_W = (PAGE_W - MARGIN * 2).toInt()
private fun newTitlePaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 14f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun newBodyPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 10f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
textLocale = Locale.GERMANY
}
private fun newBoldPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 10f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
textLocale = Locale.GERMANY
}
private fun Canvas.drawStatic(text: String, paint: TextPaint, x: Float, y: Float): Float {
if (text.isEmpty()) return y
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, MAX_TEXT_W)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0f, 1.05f)
.setIncludePad(false)
.build()
save()
translate(x, y)
layout.draw(this)
restore()
return y + layout.height + 4f
}
private fun formatBirthDe(birthDate: String?): String {
if (birthDate.isNullOrBlank()) return ""
val day = birthDate.trim().take(10)
val d = runCatching { LocalDate.parse(day) }.getOrNull() ?: return ""
return d.format(DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.GERMANY))
}
private data class PhoneLine(val value: String, val label: String?)
private fun memberPhoneLines(member: Member): List<PhoneLine> {
val phones = member.contacts
.filter { it.type == "phone" && it.value.isNotBlank() }
.sortedWith(compareBy<MemberContactDto> { !it.isPrimary }.thenBy { it.id ?: 0 })
val out = phones.map { c ->
val v = c.value.trim()
val suffix = if (c.isParent) {
val p = c.parentName?.trim().orEmpty()
if (p.isNotEmpty()) " ($p)" else " (Eltern)"
} else {
""
}
PhoneLine(v + suffix, null)
}.toMutableList()
val legacy = member.phone?.trim().orEmpty()
if (out.isEmpty() && legacy.isNotEmpty()) {
out.add(PhoneLine(legacy, null))
}
return out
}
/**
* Telefonliste wie Web-[PDFGenerator.addPhoneList]: Name, Geburtsdatum, Telefonnummern.
*/
fun writeMembersPhoneListPdf(
outFile: File,
titleLine: String,
members: List<Member>,
) {
val sorted = members.sortedWith(
compareBy<Member> { it.lastName.lowercase(Locale.GERMANY) }.thenBy { it.firstName.lowercase(Locale.GERMANY) },
)
val doc = PdfDocument()
var pageSeq = 0
fun openPage(): PdfDocument.Page {
pageSeq++
return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create())
}
var page = openPage()
var canvas = page.canvas
var y = MARGIN
val titlePaint = newTitlePaint()
val bodyPaint = newBodyPaint()
val boldPaint = newBoldPaint()
fun newPageIfNeeded(extra: Float) {
if (y + extra > PAGE_H - MARGIN) {
doc.finishPage(page)
page = openPage()
canvas = page.canvas
y = MARGIN
}
}
y = canvas.drawStatic(titleLine, titlePaint, MARGIN, y)
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
y = canvas.drawStatic("Name, Vorname", boldPaint, MARGIN, y)
y = canvas.drawStatic("Geburtsdatum / Telefon", bodyPaint, MARGIN, y)
y = canvas.drawStatic("".repeat(42), bodyPaint, MARGIN, y)
for (member in sorted) {
val phones = memberPhoneLines(member)
val nameLine = "${member.lastName}, ${member.firstName}".trim()
val birth = formatBirthDe(member.birthDate)
val blockHeight = 18f + phones.size.coerceAtLeast(1) * 14f
newPageIfNeeded(blockHeight)
y = canvas.drawStatic(nameLine, boldPaint, MARGIN, y)
val sub = buildString {
if (birth.isNotEmpty()) append("Geboren: $birth")
if (phones.isEmpty()) {
if (isNotEmpty()) append("")
append("")
}
}
y = canvas.drawStatic(sub, bodyPaint, MARGIN, y)
if (phones.isEmpty()) {
y += 4f
} else {
for (p in phones) {
newPageIfNeeded(16f)
y = canvas.drawStatic("Tel.: ${p.value}", bodyPaint, MARGIN + 12f, y)
}
}
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
}
doc.finishPage(page)
FileOutputStream(outFile).use { doc.writeTo(it) }
doc.close()
}

View File

@@ -0,0 +1,540 @@
package de.tt_tagebuch.app.ui
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Checkbox
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
private val CropFrameMaxHeight = 420.dp
private const val CropOutputSize = 600
private const val MinSelectionPx = 24f
private const val SmallCropThresholdPx = 80f
private fun decodeBitmapForCrop(resolver: android.content.ContentResolver, uri: Uri, maxSide: Int = 4096): Bitmap? {
return resolver.openInputStream(uri)?.use { input ->
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(input, null, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@use null
var sample = 1
var w = bounds.outWidth
var h = bounds.outHeight
while (w > maxSide || h > maxSide) {
sample *= 2
w = bounds.outWidth / sample
h = bounds.outHeight / sample
}
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
resolver.openInputStream(uri)?.use { stream ->
BitmapFactory.decodeStream(stream, null, opts)
}
}
}
private fun bitmapToJpeg(bitmap: Bitmap, quality: Int = 90): ByteArray {
val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out)
return out.toByteArray()
}
private fun cropSquareForUpload(
source: Bitmap,
selX: Float,
selY: Float,
selSize: Float,
dispW: Float,
dispH: Float,
): Bitmap? {
if (selSize < MinSelectionPx || dispW <= 0f || dispH <= 0f) return null
val bw = source.width.toFloat()
val bh = source.height.toFloat()
val scaleX = bw / dispW
val scaleY = bh / dispH
val sx = (selX * scaleX).roundToInt().coerceIn(0, source.width - 1)
val sy = (selY * scaleY).roundToInt().coerceIn(0, source.height - 1)
val sideRaw = (selSize * min(scaleX, scaleY)).roundToInt().coerceAtLeast(1)
val side = min(sideRaw, min(source.width - sx, source.height - sy))
if (side < 8) return null
val cropped = Bitmap.createBitmap(source, sx, sy, side, side)
return if (cropped.width == CropOutputSize && cropped.height == CropOutputSize) {
cropped
} else {
val scaled = Bitmap.createScaledBitmap(cropped, CropOutputSize, CropOutputSize, true)
if (scaled != cropped) cropped.recycle()
scaled
}
}
@Composable
fun MemberGroupPortraitCropRoute(
clubId: Int,
members: List<Member>,
dependencies: AppDependencies,
canWriteMembers: Boolean,
onBack: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val context = LocalContext.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
var sourceUri by remember { mutableStateOf<Uri?>(null) }
var fullBitmap by remember { mutableStateOf<Bitmap?>(null) }
var decodeError by remember { mutableStateOf<String?>(null) }
var containerSize by remember { mutableStateOf(IntSize.Zero) }
var selectionStart by remember { mutableStateOf<Offset?>(null) }
var selectionRect by remember { mutableStateOf<Rect?>(null) }
var isDragging by remember { mutableStateOf(false) }
var previewBytes by remember { mutableStateOf<ByteArray?>(null) }
var previewBitmap by remember { mutableStateOf<Bitmap?>(null) }
var memberQuery by remember { mutableStateOf("") }
var selectedMemberId by remember { mutableStateOf<Int?>(null) }
var makePrimary by remember { mutableStateOf(true) }
var memberPickerOpen by remember { mutableStateOf(false) }
var saving by remember { mutableStateOf(false) }
var statusMessage by remember { mutableStateOf<String?>(null) }
var errorMessage by remember { mutableStateOf<String?>(null) }
BackHandler(onBack = onBack)
LaunchedEffect(sourceUri) {
val uri = sourceUri ?: return@LaunchedEffect
decodeError = null
fullBitmap?.recycle()
fullBitmap = null
selectionRect = null
selectionStart = null
previewBytes = null
previewBitmap?.recycle()
previewBitmap = null
val bmp = withContext(Dispatchers.IO) {
decodeBitmapForCrop(context.contentResolver, uri)
}
if (bmp == null) {
decodeError = s("members.groupCropDecodeError", "Bild konnte nicht geladen werden.")
} else {
fullBitmap = bmp
}
}
val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) sourceUri = uri
}
fun resetSelection() {
selectionStart = null
selectionRect = null
isDragging = false
previewBytes = null
previewBitmap?.recycle()
previewBitmap = null
}
fun recomputePreviewFromSelection(rect: Rect?) {
val bmp = fullBitmap ?: return
if (rect == null || rect.width < MinSelectionPx || rect.height < MinSelectionPx) {
previewBytes = null
previewBitmap?.recycle()
previewBitmap = null
return
}
val frame = computeImageDisplaySize(bmp.width.toFloat(), bmp.height.toFloat(), containerSize)
if (frame.dispW <= 0f || frame.dispH <= 0f) return
val cropped = cropSquareForUpload(bmp, rect.left, rect.top, rect.width, frame.dispW, frame.dispH)
if (cropped == null) {
previewBytes = null
previewBitmap?.recycle()
previewBitmap = null
return
}
previewBitmap?.recycle()
previewBitmap = cropped
previewBytes = bitmapToJpeg(cropped)
}
fun updateSelectionFromDrag(start: Offset, current: Offset, maxW: Float, maxH: Float) {
val dx = current.x - start.x
val dy = current.y - start.y
var size = min(abs(dx), abs(dy))
var x = if (dx < 0) start.x - size else start.x
var y = if (dy < 0) start.y - size else start.y
x = max(0f, min(x, maxW - size))
y = max(0f, min(y, maxH - size))
size = min(size, min(maxW - x, maxH - y))
selectionRect = Rect(x, y, x + size, y + size)
}
val filteredMembers = remember(members, memberQuery) {
val q = memberQuery.trim().lowercase()
members
.filter { m ->
if (q.isEmpty()) return@filter true
"${m.firstName} ${m.lastName}".lowercase().contains(q) ||
"${m.lastName} ${m.firstName}".lowercase().contains(q)
}
.sortedWith(compareBy<Member> { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() })
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
s("members.groupPortraitCropTitle", "Portrait aus Gruppenfoto"),
style = MaterialTheme.typography.h6,
)
TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) }
}
Text(
s(
"members.groupPortraitCropHint",
"Wähle ein Bild, ziehe mit dem Finger einen quadratischen Rahmen um eine Person, ordne das Mitglied zu und speichere.",
),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
modifier = Modifier.padding(bottom = 12.dp),
)
if (!canWriteMembers) {
Text(s("members.groupCropNoWrite", "Keine Berechtigung zum Hochladen von Mitgliedsfotos."))
return@Column
}
OutlinedButton(
onClick = {
pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
) { Text(s("members.groupCropPickImage", "Bild aus Galerie wählen")) }
decodeError?.let {
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp))
}
val bmp = fullBitmap
if (bmp != null) {
val frame = computeImageDisplaySize(
bmp.width.toFloat(),
bmp.height.toFloat(),
containerSize,
)
val dispWdp = with(density) { frame.dispW.toDp() }
val dispHdp = with(density) { frame.dispH.toDp() }
Box(
modifier = Modifier
.fillMaxWidth()
.height(CropFrameMaxHeight)
.padding(top = 12.dp)
.onSizeChanged { containerSize = it },
contentAlignment = Alignment.Center,
) {
if (frame.dispW > 0f && frame.dispH > 0f) {
Box(
modifier = Modifier
.size(dispWdp, dispHdp)
.pointerInput(frame.dispW, frame.dispH, bmp) {
detectDragGestures(
onDragStart = { start ->
isDragging = true
selectionStart = start
errorMessage = null
statusMessage = null
previewBytes = null
previewBitmap?.recycle()
previewBitmap = null
selectionRect = Rect(start.x, start.y, start.x, start.y)
},
onDrag = { change, _ ->
val start = selectionStart ?: return@detectDragGestures
updateSelectionFromDrag(start, change.position, frame.dispW, frame.dispH)
},
onDragEnd = {
isDragging = false
selectionStart = null
val r = selectionRect
if (r == null || r.width < MinSelectionPx) {
resetSelection()
} else {
recomputePreviewFromSelection(r)
}
},
onDragCancel = {
isDragging = false
selectionStart = null
val r = selectionRect
if (r == null || r.width < MinSelectionPx) {
resetSelection()
} else {
recomputePreviewFromSelection(r)
}
},
)
},
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
Canvas(Modifier.fillMaxSize()) {
val r = selectionRect
if (r != null && r.width > 0f && r.height > 0f) {
val path = Path().apply {
addRect(Rect(Offset.Zero, size))
addRect(r)
fillType = PathFillType.EvenOdd
}
drawPath(path, Color.Black.copy(alpha = 0.48f))
drawRect(
color = Color.White.copy(alpha = 0.85f),
topLeft = Offset(r.left, r.top),
size = Size(r.width, r.height),
style = Stroke(width = 2.dp.toPx()),
)
}
}
}
}
}
selectionRect?.let { r ->
if (r.width < SmallCropThresholdPx && r.width >= MinSelectionPx && !isDragging) {
Text(
s("members.groupCropSmallWarning", "Kleiner Ausschnitt — Qualität kann leiden."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.secondary,
modifier = Modifier.padding(top = 6.dp),
)
}
}
OutlinedButton(
onClick = { resetSelection() },
enabled = selectionRect != null && !isDragging,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
) { Text(s("members.groupCropResetSelection", "Auswahl zurücksetzen")) }
Text(s("members.groupCropPreview", "Vorschau"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 16.dp))
val prev = previewBitmap
if (prev != null) {
Image(
bitmap = prev.asImageBitmap(),
contentDescription = s("members.groupCropPreview", "Vorschau"),
modifier = Modifier
.size(160.dp)
.padding(top = 6.dp),
)
} else {
Text(
s("members.groupCropNoPreview", "Noch keine Auswahl."),
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(top = 6.dp),
)
}
OutlinedButton(
onClick = { memberPickerOpen = true },
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.heightIn(min = 48.dp),
) {
Text(
selectedMemberId?.let { id ->
members.firstOrNull { it.id == id }?.let { "${it.lastName}, ${it.firstName}" }
} ?: s("members.groupCropPickMember", "Mitglied wählen"),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 8.dp),
) {
Checkbox(checked = makePrimary, onCheckedChange = { makePrimary = it })
Text(s("members.groupCropMakePrimary", "Als Hauptfoto verwenden"))
}
if (saving) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
Button(
onClick = {
val bytes = previewBytes
val mid = selectedMemberId
if (bytes == null || mid == null) return@Button
saving = true
errorMessage = null
statusMessage = null
scope.launch {
runCatching {
dependencies.membersManager.uploadMemberPortrait(clubId, mid, bytes, makePrimary = makePrimary)
dependencies.membersManager.loadMembers(clubId)
}.fold(
onSuccess = {
val m = members.firstOrNull { it.id == mid }
statusMessage = m?.let {
s("members.groupCropSavedFor", "Foto gespeichert für ${it.firstName} ${it.lastName}.")
} ?: s("members.groupCropSaved", "Foto gespeichert.")
resetSelection()
},
onFailure = {
errorMessage = it.message ?: s("members.groupCropSaveError", "Speichern fehlgeschlagen.")
},
)
saving = false
}
},
enabled = !saving && previewBytes != null && selectedMemberId != null,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.heightIn(min = 48.dp),
) {
Text(s("members.groupCropSave", "Als Mitgliedsfoto speichern"))
}
statusMessage?.let {
Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(top = 8.dp))
}
errorMessage?.let {
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp))
}
}
Spacer(modifier = Modifier.height(24.dp))
}
if (memberPickerOpen) {
AlertDialog(
onDismissRequest = { memberPickerOpen = false },
title = { Text(s("members.groupCropPickMember", "Mitglied wählen")) },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = memberQuery,
onValueChange = { memberQuery = it },
label = { Text(s("mobile.search", "Suche")) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn(modifier = Modifier.height(320.dp).padding(top = 8.dp)) {
items(filteredMembers, key = { it.id }) { m ->
TextButton(
onClick = {
selectedMemberId = m.id
memberPickerOpen = false
},
modifier = Modifier.fillMaxWidth(),
) {
Text("${m.lastName}, ${m.firstName}", modifier = Modifier.fillMaxWidth())
}
}
}
}
},
confirmButton = {
TextButton(onClick = { memberPickerOpen = false }) {
Text(s("common.close", "Schließen"))
}
},
)
}
}
private data class ImageDisplayFrame(val dispW: Float, val dispH: Float)
private fun computeImageDisplaySize(bitmapW: Float, bitmapH: Float, container: IntSize): ImageDisplayFrame {
if (container.width <= 0 || container.height <= 0 || bitmapW <= 0f || bitmapH <= 0f) {
return ImageDisplayFrame(0f, 0f)
}
val cw = container.width.toFloat()
val ch = container.height.toFloat()
val scale = min(cw / bitmapW, ch / bitmapH)
return ImageDisplayFrame(bitmapW * scale, bitmapH * scale)
}

View File

@@ -0,0 +1,386 @@
package de.tt_tagebuch.app.ui
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
import de.tt_tagebuch.shared.api.toAbsoluteUrl
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.buildJsonObject
private val MembersExtraHorizontalPadding = 20.dp
private val MembersExtraTouchMin = 48.dp
@Composable
fun MemberGroupPhotoManageRoute(
clubId: Int,
dependencies: AppDependencies,
canReadMembers: Boolean,
canWriteMembers: Boolean,
onBack: () -> Unit,
onOpenPortraitCrop: () -> Unit = {},
) {
val languageCode = LocalLanguageCode.current
fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
BackHandler(onBack = onBack)
val androidContext = LocalContext.current
var groupPhotos by remember { mutableStateOf<List<MemberGroupPhotoDto>>(emptyList()) }
var groupPhotoBusy by remember { mutableStateOf(false) }
var newGroupPhotoTitle by rememberSaveable { mutableStateOf("") }
var newGroupPhotoDescription by rememberSaveable { mutableStateOf("") }
var loadError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(clubId) {
groupPhotoBusy = true
loadError = null
groupPhotos = runCatching { dependencies.diaryManager.listMemberGroupPhotos(clubId) }.getOrElse {
loadError = it.message
emptyList()
}
groupPhotoBusy = false
}
val pickGroupPhoto = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri == null || !canWriteMembers) return@rememberLauncherForActivityResult
val title = newGroupPhotoTitle.trim().ifBlank { "Gruppenfoto" }
dependencies.applicationScope.launch {
groupPhotoBusy = true
runCatching {
val bytes = androidContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@runCatching
dependencies.diaryManager.uploadMemberGroupPhoto(
clubId,
bytes,
title = title,
description = newGroupPhotoDescription.trim(),
)
newGroupPhotoTitle = ""
newGroupPhotoDescription = ""
groupPhotos = dependencies.diaryManager.listMemberGroupPhotos(clubId)
}
groupPhotoBusy = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = MembersExtraHorizontalPadding, vertical = 16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(s("members.groupPhotoTitle", "Gruppenfoto"), style = MaterialTheme.typography.h6)
TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) }
}
Text(
s(
"members.groupPhotoMobileHint",
"Gruppenfotos werden im Verein gespeichert (wie im Tagebuch). Einzelportraits legst du pro Mitglied unter „Profilbild“ fest.",
),
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
modifier = Modifier.padding(bottom = 12.dp),
)
if (canWriteMembers) {
OutlinedButton(
onClick = onOpenPortraitCrop,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
.heightIn(min = MembersExtraTouchMin),
) {
Text(s("members.openPortraitCrop", "Portrait aus Bild zuschneiden"))
}
}
loadError?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) }
if (!canReadMembers) {
Text(s("members.noReadPermission", "Keine Berechtigung."))
return@Column
}
if (canWriteMembers) {
OutlinedTextField(
value = newGroupPhotoTitle,
onValueChange = { newGroupPhotoTitle = it },
label = { Text(s("diary.groupPhotoTitle", "Titel")) },
modifier = Modifier.fillMaxWidth(),
enabled = !groupPhotoBusy,
singleLine = true,
)
OutlinedButton(
onClick = {
pickGroupPhoto.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
},
enabled = !groupPhotoBusy,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.heightIn(min = MembersExtraTouchMin),
) { Text(s("diary.uploadGroupPhoto", "Foto wählen")) }
OutlinedTextField(
value = newGroupPhotoDescription,
onValueChange = { newGroupPhotoDescription = it },
label = { Text(s("diary.groupPhotoDescription", "Beschreibung (optional)")) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
enabled = !groupPhotoBusy,
singleLine = false,
maxLines = 3,
)
}
if (groupPhotoBusy) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp).align(Alignment.CenterHorizontally))
}
if (groupPhotos.isEmpty() && !groupPhotoBusy) {
Text(s("diary.noGroupPhotos", "Keine Gruppenfotos."), modifier = Modifier.padding(top = 12.dp))
} else {
val auth = dependencies.diaryAuthHeaders()
groupPhotos.forEach { ph ->
val url = ph.imageUrl?.let { dependencies.apiConfig.toAbsoluteUrl(it) }
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
elevation = 1.dp,
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(ph.title.orEmpty(), fontWeight = FontWeight.SemiBold)
if (!ph.description.isNullOrBlank()) {
Text(ph.description.orEmpty(), style = MaterialTheme.typography.caption)
}
if (url != null) {
AuthenticatedAsyncImage(
imageUrl = url,
authHeaders = auth,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(top = 6.dp),
contentDescription = ph.title,
)
}
if (canWriteMembers) {
TextButton(
enabled = !groupPhotoBusy,
onClick = {
dependencies.applicationScope.launch {
groupPhotoBusy = true
runCatching {
dependencies.diaryManager.deleteMemberGroupPhoto(clubId, ph.id)
groupPhotos = dependencies.diaryManager.listMemberGroupPhotos(clubId)
}
groupPhotoBusy = false
}
},
) { Text(s("common.delete", "Löschen")) }
}
}
}
}
}
}
}
@Composable
fun MemberTransferRunRoute(
clubId: Int,
dependencies: AppDependencies,
canWriteMembers: Boolean,
onBack: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun s(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
BackHandler(onBack = onBack)
var loading by remember { mutableStateOf(true) }
var envelope by remember { mutableStateOf<MemberTransferConfigEnvelope?>(null) }
var transferBusy by remember { mutableStateOf(false) }
var loginUser by rememberSaveable { mutableStateOf("") }
var loginPass by rememberSaveable { mutableStateOf("") }
var statusText by remember { mutableStateOf<String?>(null) }
LaunchedEffect(clubId) {
loading = true
envelope = runCatching { dependencies.memberTransferConfigApi.get(clubId) }.getOrNull()
loading = false
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = MembersExtraHorizontalPadding, vertical = 16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(s("members.transferMembers", "Mitgliedstransfer"), style = MaterialTheme.typography.h6)
TextButton(onClick = onBack) { Text(s("mobile.back", "Zurück")) }
}
if (!canWriteMembers) {
Text(s("members.transferNoWrite", "Keine Schreibberechtigung für Mitglieder."))
return@Column
}
if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally))
return@Column
}
val cfg = envelope?.config
val hasConfig = cfg != null &&
!cfg.transferEndpoint.isNullOrBlank() &&
!cfg.transferTemplate.isNullOrBlank() &&
!cfg.server.isNullOrBlank()
if (!hasConfig) {
Text(
s(
"members.transferConfigMissing",
"Es ist keine Transfer-Konfiguration hinterlegt. Lege sie unter „Mehr“ → Vereinsstammdaten → Mitgliedstransfer an.",
),
modifier = Modifier.padding(top = 8.dp),
)
return@Column
}
val c = cfg!!
Text(
s("members.transferActiveOnlyHint", "Es werden nur aktive Mitglieder übertragen (serverseitig)."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
modifier = Modifier.padding(bottom = 8.dp),
)
Text("${s("memberTransferDialog.server", "Server")}: ${c.server}", style = MaterialTheme.typography.body2)
Text("${s("memberTransferDialog.endpoint", "Endpoint")}: ${c.transferEndpoint}", style = MaterialTheme.typography.body2)
Text("${s("memberTransferDialog.method", "Methode")}: ${c.transferMethod ?: "POST"}", style = MaterialTheme.typography.body2)
Text(
"${s("memberTransferDialog.mode", "Modus")}: ${if (c.useBulkMode == true) s("memberTransferDialog.bulkImport", "Bulk") else s("memberTransferDialog.single", "Einzeln")}",
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 12.dp),
)
if (!c.loginEndpoint.isNullOrBlank()) {
Text(s("memberTransferDialog.loginDataOverride", "Login (optional)"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = loginUser,
onValueChange = { loginUser = it },
label = { Text(s("memberTransferDialog.usernameEmail", "Benutzername / E-Mail")) },
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
enabled = !transferBusy,
singleLine = true,
)
OutlinedTextField(
value = loginPass,
onValueChange = { loginPass = it },
label = { Text(s("memberTransferDialog.passwordPlaceholder", "Passwort")) },
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
enabled = !transferBusy,
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
)
}
statusText?.let {
Text(it, modifier = Modifier.padding(top = 12.dp), color = MaterialTheme.colors.primary)
}
Button(
onClick = {
dependencies.applicationScope.launch {
transferBusy = true
statusText = null
runCatching {
val creds = buildJsonObject {
val u = loginUser.trim()
val p = loginPass.trim()
if (u.isNotEmpty()) put("username", JsonPrimitive(u))
if (p.isNotEmpty()) put("password", JsonPrimitive(p))
}
val body = MemberTransferRunBody(
transferEndpoint = c.transferEndpoint.orEmpty(),
transferMethod = c.transferMethod ?: "POST",
transferFormat = c.transferFormat ?: "json",
transferTemplate = c.transferTemplate.orEmpty(),
useBulkMode = c.useBulkMode == true,
bulkWrapperTemplate = c.bulkWrapperTemplate?.takeIf { it.isNotBlank() },
loginEndpoint = c.loginEndpoint?.takeIf { it.isNotBlank() },
loginFormat = c.loginFormat?.takeIf { it.isNotBlank() },
loginCredentials = creds.takeIf { it.isNotEmpty() },
)
val jo = dependencies.membersManager.transferMembers(clubId, body)
val ok = jo["success"]?.jsonPrimitive?.booleanOrNull == true
if (ok) {
val msg = jo["message"]?.jsonPrimitive?.contentOrNull
?: s("memberTransferDialog.transferSuccessShort", "Transfer abgeschlossen.")
val transferred = jo["transferred"]?.jsonPrimitive?.let { p ->
p.intOrNull?.toString() ?: p.contentOrNull
}
val total = jo["total"]?.jsonPrimitive?.let { p ->
p.intOrNull?.toString() ?: p.contentOrNull
}
statusText = if (transferred != null && total != null) "$msg ($transferred / $total)" else msg
} else {
val err = jo["error"]?.jsonPrimitive?.contentOrNull
?: jo["message"]?.jsonPrimitive?.contentOrNull
?: s("memberTransferDialog.transferFailed", "Transfer fehlgeschlagen.")
statusText = err
}
}.onFailure {
statusText = it.message ?: s("memberTransferDialog.transferError", "Netzwerkfehler.")
}
transferBusy = false
}
},
enabled = !transferBusy,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.heightIn(min = MembersExtraTouchMin),
) {
Text(if (transferBusy) s("memberTransferDialog.transferring", "Übertrage…") else s("memberTransferDialog.transfer", "Transfer starten"))
}
}
}

View File

@@ -0,0 +1,64 @@
package de.tt_tagebuch.app.ui
import de.tt_tagebuch.shared.api.models.Member
import java.time.LocalDate
import java.time.Period
import java.time.ZoneId
/** Erstes Kalenderjahr der laufenden Spielzeit (AugJul). */
fun getSeasonStartYearFromDateToday(zone: ZoneId = ZoneId.systemDefault()): Int {
val d = LocalDate.now(zone)
return if (d.monthValue >= 8) d.year else d.year - 1
}
fun formatSeasonSlash(seasonStartYear: Int): String =
"${seasonStartYear}/${(seasonStartYear + 1).toString().takeLast(2)}"
fun getStichtagDate(seasonStartYear: Int, classNum: Int): LocalDate =
LocalDate.of(seasonStartYear - (classNum - 1), 1, 1)
private fun parseBirth(member: Member): LocalDate? {
val raw = member.birthDate?.trim()?.take(10) ?: return null
return runCatching { LocalDate.parse(raw) }.getOrNull()
}
fun memberBirthLocalDate(member: Member): LocalDate? = parseBirth(member)
fun memberAgeYears(member: Member, today: LocalDate = LocalDate.now()): Int? {
val b = parseBirth(member) ?: return null
return Period.between(b, today).years
}
fun isFemaleGenderMember(member: Member): Boolean =
member.gender?.trim()?.lowercase() == "female"
/**
* Jugendklasse exklusiv (J9…J19) oder adult; `null` = kein gültiges Geburtsdatum.
*/
fun getExclusiveJugendClass(member: Member, seasonStartYear: Int): String? {
val t = parseBirth(member)?.toEpochDay() ?: return null
fun c(k: Int) = getStichtagDate(seasonStartYear, k).toEpochDay()
val c9 = c(9)
val c11 = c(11)
val c13 = c(13)
val c15 = c(15)
val c19 = c(19)
if (t < c19) return "adult"
if (t >= c9) return "J9"
if (t >= c11 && t < c9) return "J11"
if (t >= c13 && t < c11) return "J13"
if (t >= c15 && t < c13) return "J15"
if (t >= c19 && t < c15) return "J19"
return "adult"
}
fun memberMatchesTtAgeClass(member: Member, filterKey: String, seasonStartYear: Int): Boolean {
if (filterKey.isEmpty() || filterKey == "range") return true
val jClass = getExclusiveJugendClass(member, seasonStartYear) ?: return false
return when {
filterKey == "adult" -> jClass == "adult"
filterKey.startsWith("J") -> jClass == filterKey
filterKey.startsWith("M") -> isFemaleGenderMember(member) && jClass == "J${filterKey.substring(1)}"
else -> true
}
}

View File

@@ -65,6 +65,22 @@ private val weekdayFilterLabels = listOf(
"Samstag" to "6",
)
/** Bits für einklappbare Statistik-Bereiche (Start: alle zu). */
private const val EXP_OVERVIEW = 0
private const val EXP_MONTHLY = 1
private const val EXP_WEEKDAY = 2
private const val EXP_STRUCTURE = 3
private const val EXP_BEST_DAY = 4
private const val EXP_GROUPS = 5
private const val EXP_AGE = 6
private const val EXP_RAW = 7
private const val EXP_TRAINING_DAYS = 8
private const val EXP_MEMBERS = 9
private fun statsPanelExpanded(mask: Int, bit: Int): Boolean = (mask shr bit) and 1 == 1
private fun statsPanelToggle(mask: Int, bit: Int): Int = mask xor (1 shl bit)
@Composable
internal fun TrainingStatsScreen(dependencies: AppDependencies) {
val clubState by dependencies.clubManager.state.collectAsState()
@@ -76,8 +92,7 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) {
var selectedWeekday by rememberSaveable { mutableStateOf("all") }
var selectedTrainingDay by rememberSaveable { mutableStateOf("all") }
var selectedTrainingGroup by rememberSaveable { mutableStateOf("all") }
var showTrainingDays by rememberSaveable { mutableStateOf(true) }
var showMembers by rememberSaveable { mutableStateOf(false) }
var expandedPanelsMask by rememberSaveable { mutableStateOf(0) }
var sortField by rememberSaveable { mutableStateOf("name") }
var sortAsc by rememberSaveable { mutableStateOf(true) }
@@ -287,193 +302,241 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) {
}
}
StatsSectionTitle(statsTr("trainingStats.summary", "Übersicht"))
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("members.activeMembers", "Aktive Mitglieder"),
value = filteredMembers.size.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"),
value = filteredTrainingDays.size.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"),
value = "%.1f".format(filteredOverview.averageParticipants),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"),
value = filteredOverview.totalParticipants.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"),
value = "%.1f %%".format(filteredOverview.attendanceRate),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.notInTraining", "Nicht im Training"),
value = filteredMembers.count { it.notInTraining }.toString(),
modifier = Modifier.weight(1f),
)
}
StatsSectionTitle(statsTr("trainingStats.monthlyTrend", "Monatlicher Verlauf"))
Text(
"${filteredMonthlyTrend.size} Monate",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp),
CollapsibleHeader(
title = statsTr("trainingStats.panelSummary", "Kennzahlen (Filter)"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_OVERVIEW),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_OVERVIEW) },
)
filteredMonthlyTrend.forEach { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(month.label, fontWeight = FontWeight.SemiBold)
Text(
"${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}",
style = MaterialTheme.typography.caption,
)
}
Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) {
Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium)
}
if (statsPanelExpanded(expandedPanelsMask, EXP_OVERVIEW)) {
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("members.activeMembers", "Aktive Mitglieder"),
value = filteredMembers.size.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.trainingDays12m", "Trainingstage (Filter)"),
value = filteredTrainingDays.size.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.averageParticipants", "Ø Teilnehmer je Training"),
value = "%.1f".format(filteredOverview.averageParticipants),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.totalParticipations", "Teilnahmen gesamt"),
value = filteredOverview.totalParticipants.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.attendanceRate12m", "Anwesenheitsquote %"),
value = "%.1f %%".format(filteredOverview.attendanceRate),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.notInTraining", "Nicht im Training"),
value = filteredMembers.count { it.notInTraining }.toString(),
modifier = Modifier.weight(1f),
)
}
LinearProgressIndicator(
progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f),
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
)
}
StatsSectionTitle(statsTr("trainingStats.weekdayStats", "Trainingstage nach Wochentag"))
Text(
"${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp),
CollapsibleHeader(
title = statsTr("trainingStats.panelMonthlyTrend", "Monatlicher Verlauf"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_MONTHLY),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_MONTHLY) },
)
filteredWeekdayStats.chunked(2).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { w ->
StatMiniCard(
title = w.weekday,
value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants),
modifier = Modifier.weight(1f),
)
if (statsPanelExpanded(expandedPanelsMask, EXP_MONTHLY)) {
Text(
"${filteredMonthlyTrend.size} Monate",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp),
)
filteredMonthlyTrend.forEach { month ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(month.label, fontWeight = FontWeight.SemiBold)
Text(
"${month.trainingCount} ${statsTr("trainingStats.trainingDaysShort", "Trainingstage")}",
style = MaterialTheme.typography.caption,
)
}
Column(modifier = Modifier.widthIn(min = 96.dp), horizontalAlignment = Alignment.End) {
Text("%.1f".format(month.averageParticipants), fontWeight = FontWeight.Medium)
}
}
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
LinearProgressIndicator(
progress = (month.averageParticipants / maxMonthAvg).toFloat().coerceIn(0f, 1f),
modifier = Modifier
.fillMaxWidth()
.height(6.dp),
)
}
}
StatsSectionTitle(statsTr("trainingStats.memberStructure", "Mitgliederstruktur"))
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"),
value = filteredMemberDistribution.highlyActive.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.distRegular", "Regelmäßig"),
value = filteredMemberDistribution.regular.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.distOccasional", "Gelegentlich"),
value = filteredMemberDistribution.occasional.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"),
value = filteredMemberDistribution.inactive.toString(),
modifier = Modifier.weight(1f),
)
}
StatsSectionTitle(statsTr("trainingStats.bestDay", "Stärkster Trainingstag"))
val best = filteredOverview.bestTrainingDay
Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) {
Column(modifier = Modifier.padding(12.dp)) {
if (best != null) {
Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold)
Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2)
Text(
"${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 4.dp),
)
Text(
statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."),
style = MaterialTheme.typography.caption,
)
} else {
Text(statsTr("trainingStats.noData", "Keine Daten"))
}
}
}
StatsSectionTitle(statsTr("trainingStats.groupPerformance", "Entwicklung pro Gruppe"))
Text(
"${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}",
style = MaterialTheme.typography.caption,
CollapsibleHeader(
title = statsTr("trainingStats.panelWeekdayStats", "Trainingstage nach Wochentag"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_WEEKDAY),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_WEEKDAY) },
)
groupPerformance.forEach { g ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = 1.dp,
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(g.name, fontWeight = FontWeight.SemiBold)
Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption)
Text(
"%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}"
.format(g.averageParticipations12Months, g.participationRate),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 4.dp),
)
if (statsPanelExpanded(expandedPanelsMask, EXP_WEEKDAY)) {
Text(
"${filteredWeekdayStats.size} ${statsTr("trainingStats.weekdays", "Wochentage")}",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 4.dp),
)
filteredWeekdayStats.chunked(2).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { w ->
StatMiniCard(
title = w.weekday,
value = "${w.trainingCount} Term.\nØ %.1f".format(w.averageParticipants),
modifier = Modifier.weight(1f),
)
}
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
}
}
}
StatsSectionTitle(statsTr("trainingStats.ageGroups", "Anwesenheit nach Altersklasse"))
ageGroupStats.chunked(2).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { a ->
StatMiniCard(
title = a.label,
value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months),
modifier = Modifier.weight(1f),
)
}
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
CollapsibleHeader(
title = statsTr("trainingStats.panelMemberStructure", "Mitgliederstruktur"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_STRUCTURE),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_STRUCTURE) },
)
if (statsPanelExpanded(expandedPanelsMask, EXP_STRUCTURE)) {
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.distHighlyActive", "Sehr aktiv"),
value = filteredMemberDistribution.highlyActive.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.distRegular", "Regelmäßig"),
value = filteredMemberDistribution.regular.toString(),
modifier = Modifier.weight(1f),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
StatMiniCard(
title = statsTr("trainingStats.distOccasional", "Gelegentlich"),
value = filteredMemberDistribution.occasional.toString(),
modifier = Modifier.weight(1f),
)
StatMiniCard(
title = statsTr("trainingStats.distInactive", "Ohne Teilnahme"),
value = filteredMemberDistribution.inactive.toString(),
modifier = Modifier.weight(1f),
)
}
}
StatsSectionTitle(statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)"))
StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString())
StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString())
StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString())
CollapsibleHeader(
title = statsTr("trainingStats.panelBestDay", "Stärkster Trainingstag"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_BEST_DAY),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_BEST_DAY) },
)
if (statsPanelExpanded(expandedPanelsMask, EXP_BEST_DAY)) {
val best = filteredOverview.bestTrainingDay
Card(modifier = Modifier.fillMaxWidth(), elevation = 2.dp) {
Column(modifier = Modifier.padding(12.dp)) {
if (best != null) {
Text(TrainingStatsDerived.formatDateGerman(best.date), fontWeight = FontWeight.Bold)
Text(TrainingStatsDerived.weekdayGerman(best.date), style = MaterialTheme.typography.body2)
Text(
"${best.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}",
style = MaterialTheme.typography.h6,
modifier = Modifier.padding(top = 4.dp),
)
Text(
statsTr("trainingStats.bestDayHint", "Beim bestbesuchten Training im Filterzeitraum."),
style = MaterialTheme.typography.caption,
)
} else {
Text(statsTr("trainingStats.noData", "Keine Daten"))
}
}
}
}
CollapsibleHeader(
title = statsTr("trainingStats.panelGroupPerformance", "Entwicklung pro Gruppe"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_GROUPS),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_GROUPS) },
)
if (statsPanelExpanded(expandedPanelsMask, EXP_GROUPS)) {
Text(
"${groupPerformance.size} ${statsTr("trainingStats.groups", "Gruppen")}",
style = MaterialTheme.typography.caption,
)
groupPerformance.forEach { g ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
elevation = 1.dp,
) {
Column(modifier = Modifier.padding(10.dp)) {
Text(g.name, fontWeight = FontWeight.SemiBold)
Text("${g.memberCount} ${statsTr("members.members", "Mitglieder")}", style = MaterialTheme.typography.caption)
Text(
"%.1f Ø / 12M · %.1f %% ${statsTr("trainingStats.presence", "Anwesenheit")}"
.format(g.averageParticipations12Months, g.participationRate),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
CollapsibleHeader(
title = statsTr("trainingStats.panelAgeGroups", "Anwesenheit nach Altersklasse"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_AGE),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_AGE) },
)
if (statsPanelExpanded(expandedPanelsMask, EXP_AGE)) {
ageGroupStats.chunked(2).forEach { row ->
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
row.forEach { a ->
StatMiniCard(
title = a.label,
value = "${a.memberCount}\nØ %.1f / 12M".format(a.averageParticipations12Months),
modifier = Modifier.weight(1f),
)
}
if (row.size == 1) Spacer(modifier = Modifier.weight(1f))
}
}
}
CollapsibleHeader(
title = statsTr("trainingStats.rawCounts", "Rohzahlen (Verein)"),
expanded = statsPanelExpanded(expandedPanelsMask, EXP_RAW),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_RAW) },
)
if (statsPanelExpanded(expandedPanelsMask, EXP_RAW)) {
StatsDetailLine(statsTr("trainingStats.trainings12Months", "Trainings 12 Monate"), s.trainingsCount12Months.toString())
StatsDetailLine(statsTr("trainingStats.trainings3Months", "Trainings 3 Monate"), s.trainingsCount3Months.toString())
StatsDetailLine(statsTr("members.activeMembers", "Aktive Mitglieder (Backend)"), s.overview.activeMembersCount.toString())
}
CollapsibleHeader(
title = statsTr("trainingStats.trainingDays", "Trainingstage"),
expanded = showTrainingDays,
onToggle = { showTrainingDays = !showTrainingDays },
expanded = statsPanelExpanded(expandedPanelsMask, EXP_TRAINING_DAYS),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_TRAINING_DAYS) },
)
if (showTrainingDays) {
if (statsPanelExpanded(expandedPanelsMask, EXP_TRAINING_DAYS)) {
filteredTrainingDays.forEach { day ->
Card(
modifier = Modifier
@@ -510,10 +573,10 @@ internal fun TrainingStatsScreen(dependencies: AppDependencies) {
CollapsibleHeader(
title = statsTr("trainingStats.memberParticipations", "Mitglieder-Teilnahmen"),
expanded = showMembers,
onToggle = { showMembers = !showMembers },
expanded = statsPanelExpanded(expandedPanelsMask, EXP_MEMBERS),
onToggle = { expandedPanelsMask = statsPanelToggle(expandedPanelsMask, EXP_MEMBERS) },
)
if (showMembers) {
if (statsPanelExpanded(expandedPanelsMask, EXP_MEMBERS)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),

View File

@@ -2,7 +2,9 @@ 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.MemberQuickMutationResponse
import de.tt_tagebuch.shared.api.models.MemberSetBody
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
import io.ktor.client.call.body
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
@@ -14,6 +16,7 @@ import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.client.request.forms.MultiPartFormDataContent
import kotlinx.serialization.json.JsonObject
class MembersApi(
private val client: AuthedHttpClient,
@@ -28,6 +31,24 @@ class MembersApi(
}
}
suspend fun updateRatingsFromMyTischtennis(clubId: Int) {
client.http.post("/api/clubmembers/update-ratings/$clubId")
}
suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse {
return client.http.post("/api/clubmembers/quick-update-test-membership/$clubId/$memberId").body()
}
suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse {
return client.http.post("/api/clubmembers/quick-update-member-form/$clubId/$memberId").body()
}
suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject {
return client.http.post("/api/clubmembers/transfer/$clubId") {
setBody(body)
}.body()
}
suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
client.http.post("/api/clubmembers/image/$clubId/$memberId") {
if (makePrimary) {

View File

@@ -107,3 +107,8 @@ fun UserClubPermissions.canReadStatistics(): Boolean {
if (isOwner) return true
return permissions.boolAt("statistics", "read")
}
fun UserClubPermissions.canWriteMyTischtennis(): Boolean {
if (isOwner) return true
return permissions.boolAt("mytischtennis", "write")
}

View File

@@ -27,6 +27,8 @@ data class Member(
val trainingParticipations: Int? = null,
val notInTraining: Boolean? = null,
val missedTrainingWeeks: Int? = null,
/** Aus Training-Stats gemergt (aktive Mitglieder); leer wenn keine Zuordnung. */
val trainingGroups: List<TrainingStatsTrainingGroup> = emptyList(),
val contacts: List<MemberContactDto> = emptyList(),
val images: List<MemberImageDto> = emptyList(),
val primaryImageId: Int? = null,

View File

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

View File

@@ -45,3 +45,16 @@ data class MemberTransferConfigSaveBody(
val useBulkMode: Boolean = false,
val bulkWrapperTemplate: String? = null,
)
@Serializable
data class MemberTransferRunBody(
val transferEndpoint: String,
val transferMethod: String = "POST",
val transferFormat: String = "json",
val transferTemplate: String,
val useBulkMode: Boolean = false,
val bulkWrapperTemplate: String? = null,
val loginEndpoint: String? = null,
val loginFormat: String? = null,
val loginCredentials: JsonObject? = null,
)

View File

@@ -3,24 +3,30 @@ 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.TrainingStatsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
import de.tt_tagebuch.shared.api.models.CreateTrainingTimeBody
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberActivityStatDto
import de.tt_tagebuch.shared.api.models.MemberQuickMutationResponse
import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto
import de.tt_tagebuch.shared.api.models.MemberSetBody
import de.tt_tagebuch.shared.api.models.MemberTransferRunBody
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.JsonObject
class MembersManager(
private val membersApi: MembersApi,
private val trainingGroupsApi: TrainingGroupsApi,
private val memberActivitiesApi: MemberActivitiesApi,
private val trainingTimesApi: TrainingTimesApi,
private val trainingStatsApi: TrainingStatsApi,
) {
private val _state = MutableStateFlow(MembersState())
val state: StateFlow<MembersState> = _state.asStateFlow()
@@ -29,19 +35,37 @@ class MembersManager(
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val members = membersApi.listMembers(clubId)
val merged = runCatching { trainingStatsApi.getStats(clubId) }
.fold(
onSuccess = { mergeTrainingStatsIntoMembers(members, it.members) },
onFailure = { members },
)
.sortedWith(compareBy<Member> { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() })
_state.value = _state.value.copy(members = members, isLoading = false)
_state.value = _state.value.copy(members = merged, isLoading = false)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Mitglieder konnten nicht geladen werden"))
}
}
suspend fun updateRatingsFromMyTischtennis(clubId: Int) {
membersApi.updateRatingsFromMyTischtennis(clubId)
}
suspend fun quickUpdateTestMembership(clubId: Int, memberId: Int): MemberQuickMutationResponse =
membersApi.quickUpdateTestMembership(clubId, memberId)
suspend fun quickUpdateMemberFormHandedOver(clubId: Int, memberId: Int): MemberQuickMutationResponse =
membersApi.quickUpdateMemberFormHandedOver(clubId, memberId)
suspend fun transferMembers(clubId: Int, body: MemberTransferRunBody): JsonObject =
membersApi.transferMembers(clubId, body)
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 uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = makePrimary)
}
suspend fun listTrainingGroups(clubId: Int): List<TrainingGroupDto> = trainingGroupsApi.listGroups(clubId)
@@ -94,3 +118,27 @@ class MembersManager(
_state.value = MembersState()
}
}
private fun mergeTrainingStatsIntoMembers(members: List<Member>, stats: List<TrainingStatsMember>): List<Member> {
val byId = stats.associateBy { it.id }
return members.map { m ->
val s = byId[m.id]
if (s == null) {
m.copy(
trainingParticipations = 0,
missedTrainingWeeks = 0,
notInTraining = false,
lastTraining = null,
trainingGroups = emptyList(),
)
} else {
m.copy(
trainingParticipations = s.participationTotal,
lastTraining = s.lastTraining,
notInTraining = s.notInTraining,
missedTrainingWeeks = s.missedTrainingWeeks,
trainingGroups = s.trainingGroups,
)
}
}
}