feat(TournamentTab): add HTML escaping utility and improve player name rendering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 42s
- Introduced `escapeHtml` method to sanitize HTML content, enhancing security against XSS attacks. - Refactored player name rendering in tournament results to utilize the new HTML escaping method, ensuring safe display of player names and table data.
This commit is contained in:
@@ -1999,6 +1999,14 @@ export default {
|
||||
name2: this.getPlayerName(match.player2)
|
||||
};
|
||||
},
|
||||
escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
},
|
||||
|
||||
async loadTournaments() {
|
||||
try {
|
||||
@@ -2507,9 +2515,8 @@ export default {
|
||||
|
||||
// 8. Dialog mit Ergebnis anzeigen
|
||||
const rows = assignments.map(({ match, table }) => {
|
||||
const name1 = this.getPlayerName(match.player1);
|
||||
const name2 = this.getPlayerName(match.player2);
|
||||
return `<tr><td style="font-weight:bold; padding:0.35rem 0.75rem;">${table}</td><td style="padding:0.35rem 0.75rem;">${name1}</td><td style="padding:0.35rem 0.75rem;">${name2}</td></tr>`;
|
||||
const { name1, name2 } = this.getMatchPlayerNames(match);
|
||||
return `<tr><td style="font-weight:bold; padding:0.35rem 0.75rem;">${this.escapeHtml(table)}</td><td style="padding:0.35rem 0.75rem;">${this.escapeHtml(name1)}</td><td style="padding:0.35rem 0.75rem;">${this.escapeHtml(name2)}</td></tr>`;
|
||||
});
|
||||
const html = `<table style="margin:0.75rem auto; border-collapse:collapse; color:#000;"><thead><tr><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">${this.$t('tournaments.table')}</th><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">${this.$t('tournaments.playerOne')}</th><th style="padding:0.35rem 0.75rem; border-bottom:2px solid #ccc; text-align:left;">${this.$t('tournaments.playerTwo')}</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
||||
|
||||
|
||||
@@ -132,12 +132,13 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug – in Web nach `apiClien
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 – Trainings-Statistik (Parität TrainingStatsView)
|
||||
## Phase 5 – Trainings-Statistik (Parität TrainingStatsView) — erledigt
|
||||
|
||||
- [ ] Alle Kennzahlen/Tabellen/Filter aus Web
|
||||
- [ ] Zeiträume, Exporte, falls vorhanden
|
||||
|
||||
---
|
||||
- [x] **DTO / API:** `TrainingStats.kt` – `weekdayStats`, `monthlyTrend`, `memberDistribution`, `overview.bestTrainingDay`, Mitglied mit `birthDate`, `participationRate12Months`, `trainingGroups`, `trainingDetails`, `lastTrainingTs` usw.
|
||||
- [x] **Ableitungen wie Web:** `TrainingStatsDerived.kt` – Filter (Wochentag, Trainingstag, Gruppe), `filteredOverview`, Monats-/Wochentags-Trends, Mitgliederstruktur, Gruppen-Performance, Altersklassen, Sortierung, CSV
|
||||
- [x] **UI:** `TrainingStatsScreen.kt` – Kennzahlen-Kacheln, Panels, kollabierbare Listen Trainingstage/Mitglieder, Detail-Dialog, CSV-Share (`shareFileWithMime` in `DiaryPdfShare.kt`)
|
||||
- [x] **i18n-Local:** `LanguageLocals.kt` (`LocalLanguageCode`) aus `AppRoot.kt` ausgelagert
|
||||
- [x] **Hinweis:** Web hat keinen CSV-Export für diese Statistik; mobil zusätzlich **CSV exportieren** (gefilterte/sortierte Mitgliederliste)
|
||||
|
||||
## Phase 6 – Terminplan (ScheduleView)
|
||||
|
||||
|
||||
@@ -6,17 +6,21 @@ import androidx.core.content.FileProvider
|
||||
import de.tt_tagebuch.app.BuildConfig
|
||||
import java.io.File
|
||||
|
||||
fun sharePdfFile(context: Context, file: File, chooserTitle: String) {
|
||||
fun shareFileWithMime(context: Context, file: File, mimeType: String, chooserTitle: String) {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
file,
|
||||
)
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/pdf"
|
||||
type = mimeType
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooser = Intent.createChooser(send, chooserTitle).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(chooser)
|
||||
}
|
||||
|
||||
fun sharePdfFile(context: Context, file: File, chooserTitle: String) {
|
||||
shareFileWithMime(context, file, "application/pdf", chooserTitle)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package de.tt_tagebuch.app.stats
|
||||
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsDay
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsMemberDistribution
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsMonthlyTrend
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsWeekdayBucket
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.util.Locale
|
||||
|
||||
data class FilteredOverview(
|
||||
val totalParticipants: Int,
|
||||
val averageParticipants: Double,
|
||||
val attendanceRate: Double,
|
||||
val bestTrainingDay: TrainingStatsDay?,
|
||||
)
|
||||
|
||||
data class GroupPerformanceRow(
|
||||
val name: String,
|
||||
val memberCount: Int,
|
||||
val averageParticipations12Months: Double,
|
||||
val participationRate: Double,
|
||||
)
|
||||
|
||||
data class AgeGroupStatRow(
|
||||
val label: String,
|
||||
val memberCount: Int,
|
||||
val averageParticipations12Months: Double,
|
||||
)
|
||||
|
||||
private data class GroupAgg(
|
||||
val name: String,
|
||||
var memberCount: Int = 0,
|
||||
var totalParticipations12Months: Int = 0,
|
||||
var participationRateSum: Double = 0.0,
|
||||
)
|
||||
|
||||
private val weekdayDe = listOf("Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag")
|
||||
|
||||
object TrainingStatsDerived {
|
||||
|
||||
fun parseLocalDate(dateStr: String?): LocalDate? {
|
||||
if (dateStr.isNullOrBlank()) return null
|
||||
val prefix = dateStr.trim().take(10)
|
||||
return runCatching { LocalDate.parse(prefix) }.getOrNull()
|
||||
}
|
||||
|
||||
fun jsWeekdayIndex(dateStr: String): Int {
|
||||
val ld = parseLocalDate(dateStr) ?: return 0
|
||||
return when (ld.dayOfWeek) {
|
||||
DayOfWeek.SUNDAY -> 0
|
||||
DayOfWeek.MONDAY -> 1
|
||||
DayOfWeek.TUESDAY -> 2
|
||||
DayOfWeek.WEDNESDAY -> 3
|
||||
DayOfWeek.THURSDAY -> 4
|
||||
DayOfWeek.FRIDAY -> 5
|
||||
DayOfWeek.SATURDAY -> 6
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDateGerman(dateStr: String?): String {
|
||||
val ld = parseLocalDate(dateStr) ?: return "-"
|
||||
return String.format(Locale.GERMAN, "%02d.%02d.%d", ld.dayOfMonth, ld.monthValue, ld.year)
|
||||
}
|
||||
|
||||
fun weekdayGerman(dateStr: String): String = weekdayDe.getOrElse(jsWeekdayIndex(dateStr)) { "" }
|
||||
|
||||
fun ageFromBirthDate(birthDate: String?): Int? {
|
||||
val bd = parseLocalDate(birthDate) ?: return null
|
||||
val now = LocalDate.now()
|
||||
var age = now.year - bd.year
|
||||
if (now.monthValue < bd.monthValue || (now.monthValue == bd.monthValue && now.dayOfMonth < bd.dayOfMonth)) {
|
||||
age--
|
||||
}
|
||||
return age
|
||||
}
|
||||
|
||||
fun trainingDaysByWeekday(trainingDays: List<TrainingStatsDay>, selectedWeekday: String): List<TrainingStatsDay> {
|
||||
if (selectedWeekday == "all") return trainingDays
|
||||
val w = selectedWeekday.toIntOrNull() ?: return trainingDays
|
||||
return trainingDays.filter { jsWeekdayIndex(it.date) == w }
|
||||
}
|
||||
|
||||
/** Wie Vue: Teilnehmermenge für den gewählten Trainingstag aus der vollen `trainingDays`-Liste. */
|
||||
fun trainingDayParticipantIds(allTrainingDays: List<TrainingStatsDay>, trainingDayId: String): Set<Int>? {
|
||||
if (trainingDayId == "all") return null
|
||||
val day = allTrainingDays.find { it.id.toString() == trainingDayId } ?: return emptySet()
|
||||
return day.participants.map { it.id }.toSet()
|
||||
}
|
||||
|
||||
fun filteredMembers(
|
||||
members: List<TrainingStatsMember>,
|
||||
selectedTrainingGroup: String,
|
||||
participantIds: Set<Int>?,
|
||||
): List<TrainingStatsMember> {
|
||||
var m = members
|
||||
if (selectedTrainingGroup != "all") {
|
||||
m = m.filter { mem -> mem.trainingGroups.any { it.id.toString() == selectedTrainingGroup } }
|
||||
}
|
||||
if (participantIds != null) {
|
||||
m = m.filter { participantIds.contains(it.id) }
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
fun filteredTrainingDays(
|
||||
daysByWeekday: List<TrainingStatsDay>,
|
||||
selectedTrainingDay: String,
|
||||
): List<TrainingStatsDay> {
|
||||
if (selectedTrainingDay == "all") return daysByWeekday
|
||||
return daysByWeekday.filter { it.id.toString() == selectedTrainingDay }
|
||||
}
|
||||
|
||||
fun filteredOverview(
|
||||
filteredTrainingDays: List<TrainingStatsDay>,
|
||||
filteredMembers: List<TrainingStatsMember>,
|
||||
): FilteredOverview {
|
||||
val totalParticipants = filteredTrainingDays.sumOf { it.participantCount }
|
||||
val averageParticipants = if (filteredTrainingDays.isNotEmpty()) {
|
||||
totalParticipants.toDouble() / filteredTrainingDays.size
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
val denom = filteredMembers.size
|
||||
val attendanceRate = if (denom > 0 && filteredTrainingDays.isNotEmpty()) {
|
||||
(totalParticipants.toDouble() / (denom * filteredTrainingDays.size)) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
val bestTrainingDay = filteredTrainingDays.maxByOrNull { it.participantCount }
|
||||
return FilteredOverview(
|
||||
totalParticipants = totalParticipants,
|
||||
averageParticipants = averageParticipants,
|
||||
attendanceRate = attendanceRate,
|
||||
bestTrainingDay = bestTrainingDay,
|
||||
)
|
||||
}
|
||||
|
||||
fun filteredMonthlyTrend(filteredTrainingDays: List<TrainingStatsDay>): List<TrainingStatsMonthlyTrend> {
|
||||
data class Acc(var label: String, var trainingCount: Int, var participantCount: Int)
|
||||
val map = linkedMapOf<String, Acc>()
|
||||
for (day in filteredTrainingDays) {
|
||||
val ld = parseLocalDate(day.date) ?: continue
|
||||
val key = "${ld.year}-${ld.monthValue.toString().padStart(2, '0')}"
|
||||
val label = "${ld.monthValue.toString().padStart(2, '0')}.${ld.year}"
|
||||
val acc = map.getOrPut(key) { Acc(label, 0, 0) }
|
||||
acc.trainingCount += 1
|
||||
acc.participantCount += day.participantCount
|
||||
}
|
||||
return map.entries
|
||||
.sortedBy { it.key }
|
||||
.map { (key, acc) ->
|
||||
val avg = if (acc.trainingCount > 0) acc.participantCount.toDouble() / acc.trainingCount else 0.0
|
||||
TrainingStatsMonthlyTrend(
|
||||
key = key,
|
||||
label = acc.label,
|
||||
trainingCount = acc.trainingCount,
|
||||
participantCount = acc.participantCount,
|
||||
averageParticipants = avg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun filteredWeekdayStats(filteredTrainingDays: List<TrainingStatsDay>): List<TrainingStatsWeekdayBucket> {
|
||||
val acc = mutableMapOf<Int, IntArray>()
|
||||
for (day in filteredTrainingDays) {
|
||||
val idx = jsWeekdayIndex(day.date)
|
||||
val a = acc.getOrPut(idx) { intArrayOf(0, 0) }
|
||||
a[0] += 1
|
||||
a[1] += day.participantCount
|
||||
}
|
||||
return acc.entries
|
||||
.sortedBy { it.key }
|
||||
.map { (idx, ar) ->
|
||||
val tc = ar[0]
|
||||
val pc = ar[1]
|
||||
TrainingStatsWeekdayBucket(
|
||||
weekday = weekdayDe[idx],
|
||||
weekdayIndex = idx,
|
||||
trainingCount = tc,
|
||||
participantCount = pc,
|
||||
averageParticipants = if (tc > 0) pc.toDouble() / tc else 0.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun filteredMemberDistribution(members: List<TrainingStatsMember>): TrainingStatsMemberDistribution =
|
||||
TrainingStatsMemberDistribution(
|
||||
highlyActive = members.count { it.participationRate12Months >= 0.75 },
|
||||
regular = members.count { it.participationRate12Months >= 0.4 && it.participationRate12Months < 0.75 },
|
||||
occasional = members.count { it.participationRate12Months > 0 && it.participationRate12Months < 0.4 },
|
||||
inactive = members.count { it.participation12Months == 0 },
|
||||
)
|
||||
|
||||
fun groupPerformance(filteredMembers: List<TrainingStatsMember>): List<GroupPerformanceRow> {
|
||||
val groups = linkedMapOf<String, GroupAgg>()
|
||||
for (member in filteredMembers) {
|
||||
val memberGroups = member.trainingGroups
|
||||
if (memberGroups.isEmpty()) {
|
||||
val e = groups.getOrPut("ohne-gruppe") { GroupAgg("Ohne Trainingsgruppe") }
|
||||
e.memberCount += 1
|
||||
e.totalParticipations12Months += member.participation12Months
|
||||
e.participationRateSum += member.participationRate12Months
|
||||
} else {
|
||||
for (g in memberGroups) {
|
||||
val key = g.id.toString()
|
||||
val e = groups.getOrPut(key) { GroupAgg(g.name) }
|
||||
e.memberCount += 1
|
||||
e.totalParticipations12Months += member.participation12Months
|
||||
e.participationRateSum += member.participationRate12Months
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups.values
|
||||
.map { entry ->
|
||||
val mc = entry.memberCount
|
||||
GroupPerformanceRow(
|
||||
name = entry.name,
|
||||
memberCount = mc,
|
||||
averageParticipations12Months = if (mc > 0) entry.totalParticipations12Months.toDouble() / mc else 0.0,
|
||||
participationRate = if (mc > 0) (entry.participationRateSum / mc) * 100.0 else 0.0,
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.averageParticipations12Months }
|
||||
}
|
||||
|
||||
fun ageGroupStats(filteredMembers: List<TrainingStatsMember>): List<AgeGroupStatRow> {
|
||||
data class Bucket(val label: String, val match: (Int?) -> Boolean, var memberCount: Int = 0, var totalP12: Int = 0)
|
||||
val buckets = listOf(
|
||||
Bucket("Kinder U13", { a -> a != null && a <= 12 }),
|
||||
Bucket("Jugend U19", { a -> a != null && a in 13..18 }),
|
||||
Bucket("Erwachsene", { a -> a != null && a in 19..59 }),
|
||||
Bucket("Senioren 60+", { a -> a != null && a >= 60 }),
|
||||
Bucket("Ohne Geburtsdatum", { a -> a == null }),
|
||||
)
|
||||
for (member in filteredMembers) {
|
||||
val age = ageFromBirthDate(member.birthDate)
|
||||
val bucket = buckets.find { it.match(age) } ?: continue
|
||||
bucket.memberCount += 1
|
||||
bucket.totalP12 += member.participation12Months
|
||||
}
|
||||
return buckets
|
||||
.filter { it.memberCount > 0 }
|
||||
.map {
|
||||
val mc = it.memberCount
|
||||
AgeGroupStatRow(
|
||||
label = it.label,
|
||||
memberCount = mc,
|
||||
averageParticipations12Months = if (mc > 0) it.totalP12.toDouble() / mc else 0.0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun trainingGroupOptions(members: List<TrainingStatsMember>): List<Pair<String, String>> {
|
||||
val map = linkedMapOf<String, String>()
|
||||
for (m in members) {
|
||||
for (g in m.trainingGroups) {
|
||||
map.putIfAbsent(g.id.toString(), g.name)
|
||||
}
|
||||
}
|
||||
return map.entries.map { it.key to it.value }.sortedWith(compareBy { it.second.lowercase(Locale.GERMAN) })
|
||||
}
|
||||
|
||||
fun sortedMembers(
|
||||
members: List<TrainingStatsMember>,
|
||||
sortField: String,
|
||||
sortDirectionAsc: Boolean,
|
||||
): List<TrainingStatsMember> =
|
||||
when (sortField) {
|
||||
"name" -> members.sortedWith { a, b ->
|
||||
val na = "${a.firstName} ${a.lastName}".lowercase(Locale.GERMAN)
|
||||
val nb = "${b.firstName} ${b.lastName}".lowercase(Locale.GERMAN)
|
||||
val c = na.compareTo(nb)
|
||||
if (sortDirectionAsc) c else -c
|
||||
}
|
||||
"participation12Months" -> numericSort(members, sortDirectionAsc) { it.participation12Months }
|
||||
"participation3Months" -> numericSort(members, sortDirectionAsc) { it.participation3Months }
|
||||
"participationTotal" -> numericSort(members, sortDirectionAsc) { it.participationTotal }
|
||||
"lastTrainingTs" -> numericSort(members, sortDirectionAsc) { it.lastTrainingTs }
|
||||
else -> members
|
||||
}
|
||||
|
||||
private fun numericSort(
|
||||
members: List<TrainingStatsMember>,
|
||||
asc: Boolean,
|
||||
selector: (TrainingStatsMember) -> Number,
|
||||
): List<TrainingStatsMember> =
|
||||
if (asc) members.sortedBy { selector(it).toDouble() } else members.sortedByDescending { selector(it).toDouble() }
|
||||
|
||||
fun buildMembersCsv(members: List<TrainingStatsMember>): String {
|
||||
fun esc(s: String): String {
|
||||
val needs = s.contains(';') || s.contains('"') || s.contains('\n') || s.contains('\r')
|
||||
if (!needs) return s
|
||||
return "\"" + s.replace("\"", "\"\"") + "\""
|
||||
}
|
||||
val header = listOf(
|
||||
"Vorname", "Nachname", "TTR", "QTTR", "Geburtsdatum", "Teilnahmen 12M", "Teilnahmen 3M", "Teilnahmen gesamt",
|
||||
"Quote 12M", "Letztes Training", "Trainingsgruppen",
|
||||
).joinToString(";")
|
||||
val lines = members.map { m ->
|
||||
val groups = m.trainingGroups.joinToString(", ") { it.name }
|
||||
listOf(
|
||||
esc(m.firstName),
|
||||
esc(m.lastName),
|
||||
m.ttr?.toString() ?: "",
|
||||
m.qttr?.toString() ?: "",
|
||||
formatDateGerman(m.birthDate).takeUnless { it == "-" } ?: "",
|
||||
m.participation12Months.toString(),
|
||||
m.participation3Months.toString(),
|
||||
m.participationTotal.toString(),
|
||||
String.format(Locale.GERMAN, "%.4f", m.participationRate12Months),
|
||||
formatDateGerman(m.lastTraining).takeUnless { it == "-" } ?: "",
|
||||
esc(groups),
|
||||
).joinToString(";")
|
||||
}
|
||||
return (sequenceOf(header) + lines.asSequence()).joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,6 @@ import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -124,8 +123,6 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
private val LocalLanguageCode = compositionLocalOf { MobileStrings.DEFAULT_LANGUAGE }
|
||||
|
||||
/** Ab dieser Fensterbreite (dp): seitliche Navigation wie auf Tablet/Web. */
|
||||
private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600
|
||||
|
||||
@@ -3914,52 +3911,6 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrainingStatsScreen(dependencies: AppDependencies) {
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val statsState by dependencies.trainingStatsManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
dependencies.trainingStatsManager.loadStats(clubId)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = ScreenHorizontalPadding, vertical = 16.dp),
|
||||
) {
|
||||
Header(tr("trainingStats.title", "Trainings-Statistik"))
|
||||
OutlinedButton(
|
||||
onClick = { dependencies.applicationScope.launch { dependencies.trainingStatsManager.loadStats(clubId) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = TouchMinHeight),
|
||||
) {
|
||||
Text(tr("mobile.refresh", "Aktualisieren"))
|
||||
}
|
||||
if (statsState.isLoading) LoadingInline()
|
||||
ErrorText(statsState.error)
|
||||
statsState.stats?.let { stats ->
|
||||
DetailLine(tr("trainingStats.trainings12Months", "Trainings 12 Monate"), stats.trainingsCount12Months.toString())
|
||||
DetailLine(tr("trainingStats.trainings3Months", "Trainings 3 Monate"), stats.trainingsCount3Months.toString())
|
||||
DetailLine(tr("members.activeMembers", "Aktive Mitglieder"), stats.overview.activeMembersCount.toString())
|
||||
DetailLine(tr("trainingStats.averageParticipants", "Durchschnitt Teilnehmer"), "%.1f".format(stats.overview.averageParticipants12Months))
|
||||
SectionTitle(tr("mobile.participationTop", "Top Teilnahmen"))
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
items(stats.members.take(20)) { member ->
|
||||
Text(
|
||||
"${member.lastName}, ${member.firstName}: ${member.participationTotal}",
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsScreen(dependencies: AppDependencies) {
|
||||
val authState by dependencies.authManager.state.collectAsState()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
|
||||
internal val LocalLanguageCode = compositionLocalOf { MobileStrings.DEFAULT_LANGUAGE }
|
||||
@@ -0,0 +1,716 @@
|
||||
package de.tt_tagebuch.app.ui
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.LinearProgressIndicator
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.tt_tagebuch.app.AppDependencies
|
||||
import de.tt_tagebuch.app.pdf.shareFileWithMime
|
||||
import de.tt_tagebuch.app.stats.TrainingStatsDerived
|
||||
import de.tt_tagebuch.shared.api.models.TrainingStatsMember
|
||||
import de.tt_tagebuch.shared.i18n.MobileStrings
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
private val StatsPad = 20.dp
|
||||
private val StatsTouchMin = 48.dp
|
||||
|
||||
private val weekdayFilterLabels = listOf(
|
||||
"Sonntag" to "0",
|
||||
"Montag" to "1",
|
||||
"Dienstag" to "2",
|
||||
"Mittwoch" to "3",
|
||||
"Donnerstag" to "4",
|
||||
"Freitag" to "5",
|
||||
"Samstag" to "6",
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun TrainingStatsScreen(dependencies: AppDependencies) {
|
||||
val clubState by dependencies.clubManager.state.collectAsState()
|
||||
val statsState by dependencies.trainingStatsManager.state.collectAsState()
|
||||
val clubId = clubState.currentClubId ?: return
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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 sortField by rememberSaveable { mutableStateOf("name") }
|
||||
var sortAsc by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
var weekdayMenu by remember { mutableStateOf(false) }
|
||||
var trainingDayMenu by remember { mutableStateOf(false) }
|
||||
var trainingGroupMenu by remember { mutableStateOf(false) }
|
||||
|
||||
var detailMember by remember { mutableStateOf<TrainingStatsMember?>(null) }
|
||||
|
||||
LaunchedEffect(clubId) {
|
||||
selectedWeekday = "all"
|
||||
selectedTrainingDay = "all"
|
||||
selectedTrainingGroup = "all"
|
||||
dependencies.trainingStatsManager.loadStats(clubId)
|
||||
}
|
||||
|
||||
val languageCode = LocalLanguageCode.current
|
||||
fun statsTr(key: String, fallback: String) = MobileStrings.get(languageCode, key, fallback)
|
||||
|
||||
fun onSortColumn(field: String) {
|
||||
if (sortField == field) {
|
||||
sortAsc = !sortAsc
|
||||
} else {
|
||||
sortField = field
|
||||
sortAsc = false
|
||||
}
|
||||
}
|
||||
|
||||
fun sortIcon(field: String): String = when {
|
||||
sortField != field -> "↕"
|
||||
sortAsc -> "↑"
|
||||
else -> "↓"
|
||||
}
|
||||
|
||||
val stats = statsState.stats
|
||||
val trainingDaysByWeekday = remember(stats, selectedWeekday) {
|
||||
if (stats == null) emptyList() else TrainingStatsDerived.trainingDaysByWeekday(stats.trainingDays, selectedWeekday)
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedWeekday, trainingDaysByWeekday) {
|
||||
if (selectedTrainingDay != "all" && trainingDaysByWeekday.none { it.id.toString() == selectedTrainingDay }) {
|
||||
selectedTrainingDay = "all"
|
||||
}
|
||||
}
|
||||
|
||||
val trainingDayOptions = remember(trainingDaysByWeekday) {
|
||||
trainingDaysByWeekday.map { day ->
|
||||
day.id.toString() to "${TrainingStatsDerived.formatDateGerman(day.date)} (${TrainingStatsDerived.weekdayGerman(day.date)})"
|
||||
}
|
||||
}
|
||||
|
||||
val trainingGroupOptions = remember(stats) {
|
||||
if (stats == null) emptyList() else TrainingStatsDerived.trainingGroupOptions(stats.members)
|
||||
}
|
||||
|
||||
val participantIds = remember(stats, selectedTrainingDay) {
|
||||
if (stats == null) null else TrainingStatsDerived.trainingDayParticipantIds(stats.trainingDays, selectedTrainingDay)
|
||||
}
|
||||
|
||||
val filteredMembers = remember(stats, selectedTrainingGroup, participantIds) {
|
||||
if (stats == null) emptyList() else TrainingStatsDerived.filteredMembers(stats.members, selectedTrainingGroup, participantIds)
|
||||
}
|
||||
|
||||
val filteredTrainingDays = remember(trainingDaysByWeekday, selectedTrainingDay) {
|
||||
TrainingStatsDerived.filteredTrainingDays(trainingDaysByWeekday, selectedTrainingDay)
|
||||
}
|
||||
|
||||
val filteredOverview = remember(filteredTrainingDays, filteredMembers) {
|
||||
TrainingStatsDerived.filteredOverview(filteredTrainingDays, filteredMembers)
|
||||
}
|
||||
|
||||
val filteredMonthlyTrend = remember(filteredTrainingDays) {
|
||||
TrainingStatsDerived.filteredMonthlyTrend(filteredTrainingDays)
|
||||
}
|
||||
|
||||
val filteredWeekdayStats = remember(filteredTrainingDays) {
|
||||
TrainingStatsDerived.filteredWeekdayStats(filteredTrainingDays)
|
||||
}
|
||||
|
||||
val filteredMemberDistribution = remember(filteredMembers) {
|
||||
TrainingStatsDerived.filteredMemberDistribution(filteredMembers)
|
||||
}
|
||||
|
||||
val groupPerformance = remember(filteredMembers) {
|
||||
TrainingStatsDerived.groupPerformance(filteredMembers)
|
||||
}
|
||||
|
||||
val ageGroupStats = remember(filteredMembers) {
|
||||
TrainingStatsDerived.ageGroupStats(filteredMembers)
|
||||
}
|
||||
|
||||
val sortedMembers = remember(filteredMembers, sortField, sortAsc) {
|
||||
TrainingStatsDerived.sortedMembers(filteredMembers, sortField, sortAsc)
|
||||
}
|
||||
|
||||
val maxMonthAvg = remember(filteredMonthlyTrend) {
|
||||
(filteredMonthlyTrend.maxOfOrNull { it.averageParticipants } ?: 1.0).coerceAtLeast(1.0)
|
||||
}
|
||||
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.imePadding()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = StatsPad, vertical = 16.dp),
|
||||
) {
|
||||
TrainingStatsMainHeader(statsTr("trainingStats.title", "Trainings-Statistik"))
|
||||
OutlinedButton(
|
||||
onClick = { dependencies.applicationScope.launch { dependencies.trainingStatsManager.loadStats(clubId) } },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = StatsTouchMin),
|
||||
) {
|
||||
Text(statsTr("mobile.refresh", "Aktualisieren"))
|
||||
}
|
||||
if (statsState.isLoading) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
if (!statsState.error.isNullOrBlank()) {
|
||||
Text(statsState.error!!, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
|
||||
stats?.let { s ->
|
||||
StatsSectionTitle(statsTr("mobile.filters", "Filter"))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
OutlinedButton(
|
||||
onClick = { weekdayMenu = true },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = StatsTouchMin),
|
||||
) {
|
||||
val label = if (selectedWeekday == "all") {
|
||||
statsTr("trainingStats.allWeekdays", "Alle Wochentage")
|
||||
} else {
|
||||
weekdayFilterLabels.find { it.second == selectedWeekday }?.first ?: selectedWeekday
|
||||
}
|
||||
Text(label, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(expanded = weekdayMenu, onDismissRequest = { weekdayMenu = false }) {
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedWeekday = "all"
|
||||
weekdayMenu = false
|
||||
}) { Text(statsTr("trainingStats.allWeekdays", "Alle Wochentage")) }
|
||||
weekdayFilterLabels.forEach { (label, value) ->
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedWeekday = value
|
||||
weekdayMenu = false
|
||||
}) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
OutlinedButton(
|
||||
onClick = { trainingDayMenu = true },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = StatsTouchMin),
|
||||
) {
|
||||
val label = trainingDayOptions.find { it.first == selectedTrainingDay }?.second
|
||||
?: statsTr("trainingStats.allTrainingDays", "Alle Trainingstage")
|
||||
Text(label, maxLines = 2)
|
||||
}
|
||||
DropdownMenu(expanded = trainingDayMenu, onDismissRequest = { trainingDayMenu = false }) {
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedTrainingDay = "all"
|
||||
trainingDayMenu = false
|
||||
}) { Text(statsTr("trainingStats.allTrainingDays", "Alle Trainingstage")) }
|
||||
trainingDayOptions.forEach { (id, label) ->
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedTrainingDay = id
|
||||
trainingDayMenu = false
|
||||
}) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { trainingGroupMenu = true },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = StatsTouchMin),
|
||||
) {
|
||||
val label = if (selectedTrainingGroup == "all") {
|
||||
statsTr("trainingStats.allGroups", "Alle Trainingsgruppen")
|
||||
} else {
|
||||
trainingGroupOptions.find { it.first == selectedTrainingGroup }?.second ?: selectedTrainingGroup
|
||||
}
|
||||
Text(label, maxLines = 1)
|
||||
}
|
||||
DropdownMenu(expanded = trainingGroupMenu, onDismissRequest = { trainingGroupMenu = false }) {
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedTrainingGroup = "all"
|
||||
trainingGroupMenu = false
|
||||
}) { Text(statsTr("trainingStats.allGroups", "Alle Trainingsgruppen")) }
|
||||
trainingGroupOptions.forEach { (id, name) ->
|
||||
DropdownMenuItem(onClick = {
|
||||
selectedTrainingGroup = id
|
||||
trainingGroupMenu = false
|
||||
}) { Text(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
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.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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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.trainingDays", "Trainingstage"),
|
||||
expanded = showTrainingDays,
|
||||
onToggle = { showTrainingDays = !showTrainingDays },
|
||||
)
|
||||
if (showTrainingDays) {
|
||||
filteredTrainingDays.forEach { day ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
elevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(TrainingStatsDerived.formatDateGerman(day.date), fontWeight = FontWeight.SemiBold)
|
||||
Text("${day.participantCount} ${statsTr("trainingStats.participants", "Teilnehmer")}")
|
||||
}
|
||||
Text(TrainingStatsDerived.weekdayGerman(day.date), style = MaterialTheme.typography.caption)
|
||||
if (day.participants.isNotEmpty()) {
|
||||
Text(
|
||||
day.participants.joinToString(", ") { p -> "${p.firstName} ${p.lastName}".trim() },
|
||||
style = MaterialTheme.typography.body2,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
statsTr("trainingStats.noParticipants", "Keine Teilnehmer"),
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleHeader(
|
||||
title = statsTr("trainingStats.memberParticipations", "Mitglieder-Teilnahmen"),
|
||||
expanded = showMembers,
|
||||
onToggle = { showMembers = !showMembers },
|
||||
)
|
||||
if (showMembers) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val csv = TrainingStatsDerived.buildMembersCsv(sortedMembers)
|
||||
val f = File(context.cacheDir, "training-stats-${System.currentTimeMillis()}.csv")
|
||||
f.writeText(csv, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
shareFileWithMime(
|
||||
context,
|
||||
f,
|
||||
"text/csv",
|
||||
statsTr("trainingStats.shareCsv", "Mitgliederstatistik teilen"),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.heightIn(min = StatsTouchMin),
|
||||
) {
|
||||
Text(statsTr("trainingStats.exportCsv", "CSV exportieren"))
|
||||
}
|
||||
}
|
||||
val hScroll = rememberScrollState()
|
||||
Row(Modifier.horizontalScroll(hScroll)) {
|
||||
Column(modifier = Modifier.widthIn(min = 920.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SortHeader(statsTr("trainingStats.name", "Name"), "name", ::sortIcon, ::onSortColumn, 140.dp)
|
||||
Text("TTR", modifier = Modifier.widthIn(min = 48.dp), fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.caption)
|
||||
Text("QTTR", modifier = Modifier.widthIn(min = 52.dp), fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.caption)
|
||||
Text(
|
||||
statsTr("trainingStats.birthdate", "Geb."),
|
||||
modifier = Modifier.widthIn(min = 88.dp),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
SortHeader("12M", "participation12Months", ::sortIcon, ::onSortColumn, 44.dp)
|
||||
SortHeader("3M", "participation3Months", ::sortIcon, ::onSortColumn, 44.dp)
|
||||
SortHeader(statsTr("trainingStats.totalShort", "Σ"), "participationTotal", ::sortIcon, ::onSortColumn, 44.dp)
|
||||
SortHeader(statsTr("trainingStats.lastShort", "Letzt."), "lastTrainingTs", ::sortIcon, ::onSortColumn, 88.dp)
|
||||
Text(statsTr("trainingStats.actions", "Aktion"), modifier = Modifier.widthIn(min = 88.dp), fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Divider()
|
||||
sortedMembers.forEach { m ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"${m.firstName} ${m.lastName}".trim().ifBlank { "—" },
|
||||
modifier = Modifier.widthIn(min = 140.dp, max = 180.dp),
|
||||
maxLines = 2,
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
Text((m.ttr ?: "–").toString(), modifier = Modifier.widthIn(48.dp))
|
||||
Text((m.qttr ?: "–").toString(), modifier = Modifier.widthIn(52.dp))
|
||||
Text(
|
||||
TrainingStatsDerived.formatDateGerman(m.birthDate).takeUnless { it == "-" } ?: "–",
|
||||
modifier = Modifier.widthIn(88.dp),
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
Text(m.participation12Months.toString(), modifier = Modifier.widthIn(44.dp))
|
||||
Text(m.participation3Months.toString(), modifier = Modifier.widthIn(44.dp))
|
||||
Text(m.participationTotal.toString(), modifier = Modifier.widthIn(44.dp))
|
||||
Text(
|
||||
TrainingStatsDerived.formatDateGerman(m.lastTraining).takeUnless { it == "-" } ?: "–",
|
||||
modifier = Modifier.widthIn(88.dp),
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
TextButton(onClick = { detailMember = m }) {
|
||||
Text(statsTr("trainingStats.showDetails", "Details"))
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detailMember?.let { member ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { detailMember = null },
|
||||
title = {
|
||||
Text(
|
||||
"${statsTr("trainingDetails.title", "Trainingsdetails")}: ${member.firstName} ${member.lastName}",
|
||||
)
|
||||
},
|
||||
text = {
|
||||
val dScroll = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 420.dp)
|
||||
.verticalScroll(dScroll),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"${statsTr("trainingDetails.birthdate", "Geburtsdatum")}: ${TrainingStatsDerived.formatDateGerman(member.birthDate)}",
|
||||
)
|
||||
val by = TrainingStatsDerived.parseLocalDate(member.birthDate)?.year
|
||||
Text("${statsTr("trainingDetails.birthYear", "Geburtsjahr")}: ${by ?: "–"}")
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text("${statsTr("trainingDetails.last12Months", "12 Monate")}: ${member.participation12Months}")
|
||||
Text("${statsTr("trainingDetails.last3Months", "3 Monate")}: ${member.participation3Months}")
|
||||
Text("${statsTr("trainingDetails.total", "Gesamt")}: ${member.participationTotal}")
|
||||
}
|
||||
Text(statsTr("trainingDetails.trainingParticipations", "Trainingsteilnahmen"), fontWeight = FontWeight.SemiBold)
|
||||
if (member.trainingDetails.isEmpty()) {
|
||||
Text(statsTr("trainingDetails.noTrainings", "Keine Einträge"), style = MaterialTheme.typography.caption)
|
||||
} else {
|
||||
member.trainingDetails.forEach { t ->
|
||||
Text(
|
||||
"${TrainingStatsDerived.formatDateGerman(t.date)} · ${t.activityName ?: "—"}",
|
||||
style = MaterialTheme.typography.body2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { detailMember = null }) {
|
||||
Text(statsTr("common.close", "Schließen"))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrainingStatsMainHeader(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SortHeader(
|
||||
label: String,
|
||||
field: String,
|
||||
sortIcon: (String) -> String,
|
||||
onSort: (String) -> Unit,
|
||||
minWidth: Dp,
|
||||
) {
|
||||
TextButton(onClick = { onSort(field) }, modifier = Modifier.widthIn(min = minWidth)) {
|
||||
Text("$label ${sortIcon(field)}", maxLines = 2, style = MaterialTheme.typography.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CollapsibleHeader(title: String, expanded: Boolean, onToggle: () -> Unit) {
|
||||
TextButton(
|
||||
onClick = onToggle,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold)
|
||||
Text(if (expanded) "▼" else "▶")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatMiniCard(title: String, value: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.padding(4.dp), elevation = 1.dp) {
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.caption, maxLines = 3)
|
||||
Text(value, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsSectionTitle(text: String) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Divider()
|
||||
Text(text, style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatsDetailLine(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp)) {
|
||||
Text(label, modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
Text(value, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user