feat(Calendar): integrate CalendarEvent model and enhance calendar functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added CalendarEvent model to the backend, establishing relationships with the Club model for better event management.
- Updated server.js to include calendarEventRoutes, enabling API access for calendar events.
- Enhanced CalendarView.vue to support custom event creation and management, improving user interaction with the calendar.
- Refactored various components to streamline event handling and improve overall user experience in the calendar interface.
- Updated TODO and DEVELOPMENT documentation to reflect new calendar features and architectural decisions.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 10:21:30 +02:00
parent 9be5f50ede
commit 004801b1a6
33 changed files with 2715 additions and 632 deletions

View File

@@ -4,6 +4,10 @@ val backendBaseUrl = providers.gradleProperty("backendBaseUrl")
.orElse("https://tt-tagebuch.de")
.get()
val socketBaseUrl = providers.gradleProperty("socketBaseUrl")
.orElse("wss://tt-tagebuch.de:3051")
.get()
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@@ -57,6 +61,7 @@ android {
versionCode = 1
versionName = "1.0.0"
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrl\"")
buildConfigField("String", "SOCKET_BASE_URL", "\"$socketBaseUrl\"")
}
buildFeatures {
buildConfig = true

View File

@@ -1,9 +1,8 @@
package de.tt_tagebuch.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.tt_tagebuch.shared.api.BillingApi
import de.tt_tagebuch.shared.api.CalendarHolidayApi
import de.tt_tagebuch.shared.api.AccidentApi
import de.tt_tagebuch.shared.api.ApiLogsApi
import de.tt_tagebuch.shared.api.ClubApprovalsApi
@@ -24,6 +23,7 @@ import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.MemberTransferConfigApi
import de.tt_tagebuch.shared.api.MemberOrdersApi
import de.tt_tagebuch.shared.api.TrainingCancellationApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.MyTischtennisApi
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
@@ -58,7 +58,6 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
class AppDependencies(context: Context) {
private val appContext = context.applicationContext
private val applicationJob = SupervisorJob()
/**
@@ -102,13 +101,17 @@ class AppDependencies(context: Context) {
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client))
val tournamentsApi = TournamentsApi(client)
val matchesApi = MatchesApi(client)
val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi)
val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client))
val memberTransferConfigApi = MemberTransferConfigApi(client)
val myTischtennisApi = MyTischtennisApi(client)
val clickTtAccountApi = ClickTtAccountApi(client)
val memberOrdersApi = MemberOrdersApi(client)
val billingApi = BillingApi(client)
val trainingCancellationApi = TrainingCancellationApi(client)
val calendarHolidayApi = CalendarHolidayApi(client)
val diaryManager = DiaryManager(
DiaryApi(client),
@@ -129,7 +132,7 @@ class AppDependencies(context: Context) {
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
val scheduleManager = ScheduleManager(
ClubTeamsApi(client),
MatchesApi(client),
matchesApi,
)
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))
val sessionApi = SessionApi(client)
@@ -139,13 +142,4 @@ class AppDependencies(context: Context) {
tokenProvider.token?.let { put("authcode", it) }
tokenProvider.username?.let { put("userid", it) }
}
/** Öffnet einen Pfad auf dem konfigurierten Backend im Browser (z. B. Impressum, Datenschutz). */
fun openBackendPath(path: String) {
val base = apiConfig.baseUrl.trimEnd('/')
val suffix = path.trim().let { p -> if (p.startsWith("/")) p else "/$p" }
val uri = Uri.parse("$base$suffix")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { appContext.startActivity(intent) }
}
}

View File

@@ -0,0 +1,393 @@
package de.tt_tagebuch.app.calendar
import de.tt_tagebuch.shared.api.models.CalendarHolidayRowDto
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
import de.tt_tagebuch.shared.api.models.DiaryDate
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.TrainingCancellationDto
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
private val isoFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
private val dmyFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("d.M.yyyy")
enum class CalendarEventType {
Training,
Tournament,
OfficialTournament,
Match,
Holiday,
SchoolHoliday,
TrainingCancellation,
}
enum class CalendarEventAction {
OpenDiaryDate,
OpenDiaryTab,
OpenSchedule,
OpenTournaments,
OpenOfficialParticipations,
None,
}
data class CalendarUiEvent(
val id: String,
val type: CalendarEventType,
val date: LocalDate,
val endDate: LocalDate = date,
val timeLabel: String,
val title: String,
val subtitle: String,
val startsAtSort: Long,
val action: CalendarEventAction = CalendarEventAction.None,
val diaryDateId: Int? = null,
val tournamentId: Int? = null,
val matchId: Int? = null,
val cancellationId: Int? = null,
val isRecurringTraining: Boolean = false,
) {
val dateIso: String get() = date.format(isoFmt)
val endDateIso: String get() = endDate.format(isoFmt)
}
private fun parseIso(s: String?): LocalDate? = try {
if (s.isNullOrBlank()) null
else LocalDate.parse(s.trim().take(10), isoFmt)
} catch (_: DateTimeParseException) {
null
}
private fun parseDmy(s: String?): LocalDate? = try {
if (s.isNullOrBlank()) null
else LocalDate.parse(s.trim(), dmyFmt)
} catch (_: DateTimeParseException) {
null
}
private fun minutesFromTime(time: String?): Int {
val t = time?.trim()?.take(5) ?: return 0
val parts = t.split(':')
val h = parts.getOrNull(0)?.toIntOrNull() ?: 0
val m = parts.getOrNull(1)?.toIntOrNull() ?: 0
return h.coerceIn(0, 23) * 60 + m.coerceIn(0, 59)
}
private fun combineSortKey(d: LocalDate, time: String?): Long {
val day = d.toEpochDay()
return day * 1440L + minutesFromTime(time)
}
private fun formatTimeRange(start: String?, end: String?): String {
val a = start?.trim()?.take(5) ?: ""
val b = end?.trim()?.take(5) ?: ""
return when {
a.isNotEmpty() && b.isNotEmpty() -> "$a$b"
a.isNotEmpty() -> a
b.isNotEmpty() -> b
else -> ""
}
}
private fun formatTime(time: String?): String = time?.trim()?.take(5) ?: ""
/** JS Date.getDay(): 0 = Sunday … 6 = Saturday */
private fun jsWeekday(d: LocalDate): Int {
val dow = d.dayOfWeek
return when (dow) {
DayOfWeek.SUNDAY -> 0
DayOfWeek.MONDAY -> 1
DayOfWeek.TUESDAY -> 2
DayOfWeek.WEDNESDAY -> 3
DayOfWeek.THURSDAY -> 4
DayOfWeek.FRIDAY -> 5
DayOfWeek.SATURDAY -> 6
}
}
private fun normalizeTrainingTimeKey(time: String): String =
time.replace('\u2013', '-').replace('\u2012', '-').replace('\u2212', '-')
.replace(" ", "")
.filter { it != '\u00a0' }
private fun genericTitle(t: String) = t.isBlank() || t.trim().equals("training", ignoreCase = true)
object CalendarAggregator {
fun fromDiaryDates(diary: List<DiaryDate>, displayedYear: Int): List<CalendarUiEvent> =
diary.mapNotNull { entry ->
val d = parseIso(entry.date) ?: return@mapNotNull null
if (d.year != displayedYear) return@mapNotNull null
val timeLabel = formatTimeRange(entry.trainingStart, entry.trainingEnd)
val tags = entry.diaryTags.joinToString(", ") { it.name }
CalendarUiEvent(
id = "training-diary-${entry.id}",
type = CalendarEventType.Training,
date = d,
timeLabel = timeLabel,
title = "Training",
subtitle = tags,
startsAtSort = combineSortKey(d, entry.trainingStart),
action = CalendarEventAction.OpenDiaryDate,
diaryDateId = entry.id,
isRecurringTraining = false,
)
}
fun fromRecurringTraining(groups: List<TrainingGroupDto>, displayedYear: Int): List<CalendarUiEvent> {
val out = ArrayList<CalendarUiEvent>()
for (group in groups) {
for (time in group.trainingTimes) {
out.addAll(createRecurringTrainingForSlot(group, time, displayedYear))
}
}
return out
}
private fun createRecurringTrainingForSlot(group: TrainingGroupDto, time: TrainingTimeDto, year: Int): List<CalendarUiEvent> {
val weekday = time.weekday
if (weekday !in 0..6 || time.startTime.isBlank()) return emptyList()
val jan1 = LocalDate.of(year, 1, 1)
val w0 = jsWeekday(jan1)
var firstOffset = (weekday - w0 + 7) % 7
var cursor = jan1.plusDays(firstOffset.toLong())
val list = ArrayList<CalendarUiEvent>()
while (cursor.year == year) {
val timeLabel = formatTimeRange(time.startTime, time.endTime)
list.add(
CalendarUiEvent(
id = "training-time-${group.id}-${time.id}-${cursor.format(isoFmt)}",
type = CalendarEventType.Training,
date = cursor,
timeLabel = timeLabel,
title = group.name.ifBlank { "Training" },
subtitle = "Regelmäßige Trainingszeit",
startsAtSort = combineSortKey(cursor, time.startTime),
action = CalendarEventAction.OpenDiaryTab,
isRecurringTraining = true,
),
)
cursor = cursor.plusWeeks(1)
}
return list
}
fun fromCancellations(rows: List<TrainingCancellationDto>): List<CalendarUiEvent> =
rows.mapNotNull { c ->
val start = parseIso(c.startDate ?: c.date) ?: return@mapNotNull null
val end = parseIso(c.endDate ?: c.startDate ?: c.date) ?: start
CalendarUiEvent(
id = "training-cancellation-${c.id}",
type = CalendarEventType.TrainingCancellation,
date = start,
endDate = end,
timeLabel = "",
title = c.reason?.trim()?.ifBlank { null } ?: "Training fällt aus",
subtitle = "Trainingsausfall",
startsAtSort = combineSortKey(start, null),
cancellationId = c.id,
action = CalendarEventAction.None,
)
}
fun fromTournaments(rows: List<InternalTournamentSummaryDto>): List<CalendarUiEvent> =
rows.mapNotNull { t ->
val d = parseIso(t.date) ?: return@mapNotNull null
CalendarUiEvent(
id = "tournament-${t.id}",
type = CalendarEventType.Tournament,
date = d,
timeLabel = "",
title = t.name?.trim()?.ifBlank { null } ?: "Turnier",
subtitle = if (t.allowsExternal == true) "Offenes Turnier" else "Vereinsturnier",
startsAtSort = combineSortKey(d, null),
tournamentId = t.id,
action = CalendarEventAction.OpenTournaments,
)
}
fun fromMatches(matches: List<ScheduleMatchDto>): List<CalendarUiEvent> =
matches.mapNotNull { m ->
val d = parseIso(m.date) ?: return@mapNotNull null
val home = m.homeTeam?.name?.ifBlank { null } ?: "Heim"
val guest = m.guestTeam?.name?.ifBlank { null } ?: "Gast"
CalendarUiEvent(
id = "match-${m.id}",
type = CalendarEventType.Match,
date = d,
timeLabel = formatTime(m.time),
title = "$home $guest",
subtitle = m.leagueDetails?.name?.ifBlank { null } ?: "Punktspiel",
startsAtSort = combineSortKey(d, m.time),
matchId = m.id,
action = CalendarEventAction.OpenSchedule,
)
}
fun fromOfficialParticipations(buckets: List<OfficialParticipationBucketDto>): List<CalendarUiEvent> =
buckets.mapNotNull { t ->
if (t.entries.isEmpty()) return@mapNotNull null
val date = parseDmy(t.startDate) ?: parseIso(t.startDate) ?: return@mapNotNull null
val end = parseDmy(t.endDate) ?: parseIso(t.endDate) ?: date
val distinctMembers = t.entries.mapNotNull { it.memberId }.toSet().size
val subtitle = if (distinctMembers > 0) "$distinctMembers Teilnehmer" else "${t.entries.size} Starts"
CalendarUiEvent(
id = "official-tournament-${t.tournamentId ?: t.title}",
type = CalendarEventType.OfficialTournament,
date = date,
endDate = end,
timeLabel = "",
title = t.title?.trim()?.ifBlank { null } ?: "Turnierteilnahme",
subtitle = subtitle,
startsAtSort = combineSortKey(date, null),
action = CalendarEventAction.OpenOfficialParticipations,
)
}
fun fromHolidays(env: ClubCalendarHolidaysEnvelope): List<CalendarUiEvent> {
val h = env.holidays.mapNotNull { mapHolidayRow(it, CalendarEventType.Holiday, "Feiertag") }
val s = env.schoolHolidays.mapNotNull { mapHolidayRow(it, CalendarEventType.SchoolHoliday, "Schulferien") }
return h + s
}
private fun mapHolidayRow(entry: CalendarHolidayRowDto, type: CalendarEventType, fallback: String): CalendarUiEvent? {
val start = parseIso(entry.startDate) ?: return null
val end = parseIso(entry.endDate) ?: start
val name = entry.name?.trim()?.ifBlank { null } ?: fallback
return CalendarUiEvent(
id = "${type.name.lowercase()}-${entry.id ?: "${start.format(isoFmt)}-$name"}",
type = type,
date = start,
endDate = end,
timeLabel = "",
title = name,
subtitle = fallback,
startsAtSort = combineSortKey(start, null),
action = CalendarEventAction.None,
)
}
fun mergeRecurringTrainingSlots(events: List<CalendarUiEvent>): List<CalendarUiEvent> {
val slotMap = LinkedHashMap<String, MutableList<CalendarUiEvent>>()
val passthrough = ArrayList<CalendarUiEvent>()
for (e in events) {
if (e.type != CalendarEventType.Training || e.timeLabel.isBlank()) {
passthrough.add(e)
continue
}
val timeKey = normalizeTrainingTimeKey(e.timeLabel)
if (timeKey.isEmpty()) {
passthrough.add(e)
continue
}
val key = "${e.date.format(isoFmt)}|$timeKey"
slotMap.getOrPut(key) { mutableListOf() }.add(e)
}
val merged = ArrayList<CalendarUiEvent>()
for ((slotKey, list) in slotMap) {
if (list.size == 1) {
merged.add(list[0])
continue
}
val sorted = list.sortedBy { it.startsAtSort }
val base = sorted.first()
val rawTitles = sorted.map { it.title.trim() }.filter { it.isNotEmpty() }.distinct()
val specific = rawTitles.filter { !genericTitle(it) }
val titleJoined = if (specific.isNotEmpty()) specific.joinToString(" · ") else rawTitles.joinToString(" · ").ifBlank { base.title }
val safeIdKey = slotKey.replace('|', '-')
val hasRecurring = sorted.any { it.isRecurringTraining }
val otherSubtitles = sorted.map { it.subtitle.trim() }.filter { it.isNotEmpty() && it != "Regelmäßige Trainingszeit" }.distinct()
val subtitleJoined = if (hasRecurring) {
if (otherSubtitles.isNotEmpty()) "Regelmäßige Trainingszeit · ${otherSubtitles.joinToString(" · ")}"
else "Regelmäßige Trainingszeit"
} else {
otherSubtitles.joinToString(" · ").ifBlank { base.subtitle }
}
merged.add(
base.copy(
id = "training-merged-$safeIdKey",
title = titleJoined,
subtitle = subtitleJoined,
startsAtSort = sorted.minOf { it.startsAtSort },
isRecurringTraining = hasRecurring,
),
)
}
return passthrough + merged
}
private fun expandCancellationDates(start: LocalDate, end: LocalDate): List<LocalDate> {
val out = ArrayList<LocalDate>()
var cur = start
var guard = 0
while (!cur.isAfter(end) && guard++ < 800) {
out.add(cur)
cur = cur.plusDays(1)
}
return out
}
fun applyTrainingCancellationsToRecurring(events: List<CalendarUiEvent>): List<CalendarUiEvent> {
val cancellationDates = events
.filter { it.type == CalendarEventType.TrainingCancellation }
.flatMap { expandCancellationDates(it.date, it.endDate) }
.map { it.format(isoFmt) }
.toSet()
return events.filter { e ->
if (e.type != CalendarEventType.Training || !e.isRecurringTraining) return@filter true
!cancellationDates.contains(e.date.format(isoFmt))
}
}
fun buildAll(
displayedYear: Int,
diary: List<DiaryDate>,
trainingGroups: List<TrainingGroupDto>,
cancellations: List<TrainingCancellationDto>,
tournaments: List<InternalTournamentSummaryDto>,
matches: List<ScheduleMatchDto>,
official: List<OfficialParticipationBucketDto>,
holidays: ClubCalendarHolidaysEnvelope,
): List<CalendarUiEvent> {
val raw = ArrayList<CalendarUiEvent>()
raw.addAll(fromDiaryDates(diary, displayedYear))
raw.addAll(fromRecurringTraining(trainingGroups, displayedYear))
raw.addAll(fromCancellations(cancellations))
raw.addAll(fromTournaments(tournaments))
raw.addAll(fromMatches(matches))
raw.addAll(fromOfficialParticipations(official))
raw.addAll(fromHolidays(holidays))
val afterCancel = applyTrainingCancellationsToRecurring(raw)
return mergeRecurringTrainingSlots(afterCancel)
}
fun eventsForDay(events: List<CalendarUiEvent>, day: LocalDate): List<CalendarUiEvent> =
events.filter { e ->
!day.isBefore(e.date) && !day.isAfter(e.endDate)
}.sortedBy { it.startsAtSort }
fun eventsInMonth(events: List<CalendarUiEvent>, yearMonth: YearMonth): List<CalendarUiEvent> {
val start = yearMonth.atDay(1)
val end = yearMonth.atEndOfMonth()
return events.filter { e ->
!e.date.isAfter(end) && !e.endDate.isBefore(start)
}.sortedBy { it.startsAtSort }
}
fun monthGridStart(yearMonth: YearMonth): LocalDate {
val first = yearMonth.atDay(1)
val wd = jsWeekday(first)
val back = (wd + 6) % 7
return first.minusDays(back.toLong())
}
fun monthGridCells(gridStart: LocalDate): List<LocalDate> =
(0 until 42).map { gridStart.plusDays(it.toLong()) }
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
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.Icon
@@ -592,12 +593,6 @@ private fun BillingClubScreen(dependencies: AppDependencies, onBack: () -> Unit)
)
}
}
OutlinedButton(
onClick = { dependencies.openBackendPath("/billing") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = TouchMin),
) {
Text(tr("mobile.billingOpenWebHint", "Vollständige Abrechnung im Browser (PDF-Vorlagen-Mapping)"))
}
info?.let { Text(it, color = MaterialTheme.colors.primary, modifier = Modifier.padding(top = 8.dp)) }
err?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
@@ -901,6 +896,13 @@ private fun BillingClubScreen(dependencies: AppDependencies, onBack: () -> Unit)
}
}
Spacer(Modifier.height(24.dp))
Divider(Modifier.padding(vertical = 8.dp))
Text(
tr("mobile.billingWebOptionalHint", "PDF-Vorlagen mit interaktiver Feldzuordnung bearbeiten (Webbrowser)."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f),
)
// Web-UI wird in der Produktiv-App nicht aufgerufen.
}
deleteRunTarget?.let { target ->

View File

@@ -0,0 +1,498 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.OutlinedTextField
import androidx.compose.material.Switch
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.calendar.CalendarAggregator
import de.tt_tagebuch.app.calendar.CalendarEventAction
import de.tt_tagebuch.app.calendar.CalendarEventType
import de.tt_tagebuch.app.calendar.CalendarUiEvent
import android.util.Log
import de.tt_tagebuch.shared.api.http.ApiException
import de.tt_tagebuch.shared.api.models.ClubCalendarHolidaysEnvelope
import de.tt_tagebuch.shared.api.models.TrainingCancellationUpsertBody
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.Locale
private val monthTitleFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("LLLL yyyy", Locale.GERMAN)
private val wdShort = listOf("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
private fun localeForApp(languageCode: String): Locale {
val tag = languageCode.replace('_', '-')
return runCatching { Locale.forLanguageTag(tag) }
.getOrNull()
?.takeIf { it.language.isNotBlank() }
?: Locale.GERMAN
}
private fun formatAgendaDay(date: LocalDate, locale: Locale): String {
val fmt = DateTimeFormatter.ofPattern("EEE, dd.MM.", locale)
return date.format(fmt)
}
private fun formatAgendaEventRange(ev: CalendarUiEvent, locale: Locale): String {
if (ev.date == ev.endDate) return formatAgendaDay(ev.date, locale)
return "${formatAgendaDay(ev.date, locale)} ${formatAgendaDay(ev.endDate, locale)}"
}
private const val CalendarLogTag = "TTCalendar"
/**
* Fehlende API-Berechtigung (403) ist für den Kalender erwartbar, wenn die Rolle z. B. kein
* schedule.read hat — dann keine rote „nicht geladen“-Warnung. Andere Fehler erscheinen in Logcat.
*/
private fun recordCalendarSourceFailure(
result: Result<*>,
sourceLabel: String,
warns: MutableList<String>,
) {
if (result.isSuccess) return
val err = result.exceptionOrNull() ?: return
if ((err as? ApiException)?.statusCode == 403) {
Log.i(
CalendarLogTag,
"Kalender-Quelle \"$sourceLabel\": keine Berechtigung (403). ${err.message}",
)
return
}
Log.w(CalendarLogTag, "Kalender-Quelle \"$sourceLabel\" fehlgeschlagen", err)
warns.add(sourceLabel)
}
private fun isUnauthorizedCalendarSource(result: Result<*>): Boolean {
if (result.isSuccess) return false
return (result.exceptionOrNull() as? ApiException)?.statusCode == 403
}
@Composable
fun CalendarScreen(
dependencies: AppDependencies,
onOpenDiaryDate: (Int) -> Unit,
onOpenDiaryTab: () -> Unit,
onOpenSchedule: () -> Unit,
onOpenTournaments: () -> Unit,
onOpenOfficialParticipations: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val scope = rememberCoroutineScope()
var calYear by remember { mutableIntStateOf(LocalDate.now().year) }
var calMonth by remember { mutableIntStateOf(LocalDate.now().monthValue) }
val yearMonth = remember(calYear, calMonth) { YearMonth.of(calYear, calMonth) }
val displayedYear = calYear
var loading by remember { mutableStateOf(false) }
var events by remember { mutableStateOf<List<CalendarUiEvent>>(emptyList()) }
var dataGeneration by remember { mutableIntStateOf(0) }
val activeTypes = remember { mutableStateMapOf<CalendarEventType, Boolean>() }
CalendarEventType.entries.forEach { t ->
if (!activeTypes.containsKey(t)) activeTypes[t] = true
}
var sourceWarnings by remember { mutableStateOf<List<String>>(emptyList()) }
var loadAllSourcesFailed by remember { mutableStateOf(false) }
val agendaLocale = remember(languageCode) { localeForApp(languageCode) }
var cancelStart by remember { mutableStateOf("") }
var cancelEnd by remember { mutableStateOf("") }
var cancelReason by remember { mutableStateOf("") }
var cancelBusy by remember { mutableStateOf(false) }
LaunchedEffect(clubId, displayedYear, calMonth, dataGeneration) {
loading = true
loadAllSourcesFailed = false
sourceWarnings = emptyList()
val warns = mutableListOf<String>()
supervisorScope {
val diaryR = async { runCatching { dependencies.diaryManager.listDates(clubId) } }
val ttR = async { runCatching { dependencies.membersManager.trainingScheduleGroups(clubId) } }
val canR = async { runCatching { dependencies.trainingCancellationApi.list(clubId, displayedYear) } }
val tourR = async { runCatching { dependencies.tournamentsApi.listTournaments(clubId) } }
val matchR = async { runCatching { dependencies.matchesApi.listMatchesForLeagues(clubId) } }
val offR = async { runCatching { dependencies.officialTournamentsReadManager.fetchParticipationSummary(clubId) } }
val holR = async { runCatching { dependencies.calendarHolidayApi.getClubHolidays(clubId, displayedYear) } }
val dr = diaryR.await()
val ttr = ttR.await()
val canr = canR.await()
val tourr = tourR.await()
val matchr = matchR.await()
val offr = offR.await()
val holr = holR.await()
recordCalendarSourceFailure(dr, tr("mobile.calendarSourceDiary", "Tagebuch"), warns)
recordCalendarSourceFailure(ttr, tr("mobile.calendarSourceTrainingTimes", "Trainingszeiten"), warns)
recordCalendarSourceFailure(canr, tr("mobile.calendarSourceCancellations", "Trainingsausfälle"), warns)
recordCalendarSourceFailure(tourr, tr("mobile.calendarSourceTournaments", "Turniere"), warns)
recordCalendarSourceFailure(matchr, tr("mobile.calendarSourceMatches", "Punktspiele"), warns)
recordCalendarSourceFailure(offr, tr("mobile.calendarSourceOfficial", "Turnierteilnahmen"), warns)
recordCalendarSourceFailure(holr, tr("mobile.calendarSourceHolidays", "Ferien/Feiertage"), warns)
val results = listOf(dr, ttr, canr, tourr, matchr, offr, holr)
loadAllSourcesFailed =
results.all { it.isFailure } &&
results.any { !isUnauthorizedCalendarSource(it) }
events = CalendarAggregator.buildAll(
displayedYear = displayedYear,
diary = dr.getOrDefault(emptyList()),
trainingGroups = ttr.getOrDefault(emptyList()),
cancellations = canr.getOrDefault(emptyList()),
tournaments = tourr.getOrDefault(emptyList()),
matches = matchr.getOrDefault(emptyList()),
official = offr.getOrDefault(emptyList()),
holidays = holr.getOrElse { ClubCalendarHolidaysEnvelope() },
)
}
sourceWarnings = warns.distinct()
loading = false
}
val activeSnapshot = activeTypes.toMap()
val filtered = remember(events, activeSnapshot) {
events.filter { activeSnapshot[it.type] != false }
}
val visibleMonthEvents = remember(filtered, yearMonth) {
CalendarAggregator.eventsInMonth(filtered, yearMonth)
}
val gridStart = remember(yearMonth) { CalendarAggregator.monthGridStart(yearMonth) }
val gridCells = remember(gridStart) { CalendarAggregator.monthGridCells(gridStart) }
fun typeLabel(t: CalendarEventType): String = when (t) {
CalendarEventType.Training -> tr("mobile.calendarLegendTraining", "Training")
CalendarEventType.Tournament -> tr("mobile.calendarLegendTournament", "Turnier")
CalendarEventType.OfficialTournament -> tr("mobile.calendarLegendOfficial", "Teilnahme")
CalendarEventType.Match -> tr("mobile.calendarLegendMatch", "Punktspiel")
CalendarEventType.Holiday -> tr("mobile.calendarLegendHoliday", "Feiertag")
CalendarEventType.SchoolHoliday -> tr("mobile.calendarLegendSchool", "Ferien")
CalendarEventType.TrainingCancellation -> tr("mobile.calendarLegendCancellation", "Ausfall")
}
@Composable
fun eventColor(t: CalendarEventType) = when (t) {
CalendarEventType.Training -> MaterialTheme.colors.primary
CalendarEventType.Tournament -> MaterialTheme.colors.secondary
CalendarEventType.OfficialTournament -> MaterialTheme.colors.secondaryVariant
CalendarEventType.Match -> MaterialTheme.colors.primaryVariant
CalendarEventType.Holiday -> MaterialTheme.colors.error.copy(alpha = 0.75f)
CalendarEventType.SchoolHoliday -> MaterialTheme.colors.error.copy(alpha = 0.45f)
CalendarEventType.TrainingCancellation -> MaterialTheme.colors.onSurface.copy(alpha = 0.55f)
}
fun onEventClick(e: CalendarUiEvent) {
when (e.action) {
CalendarEventAction.OpenDiaryDate -> e.diaryDateId?.let { onOpenDiaryDate(it) }
CalendarEventAction.OpenDiaryTab -> onOpenDiaryTab()
CalendarEventAction.OpenSchedule -> onOpenSchedule()
CalendarEventAction.OpenTournaments -> onOpenTournaments()
CalendarEventAction.OpenOfficialParticipations -> onOpenOfficialParticipations()
CalendarEventAction.None -> Unit
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Text(tr("mobile.calendarTitle", "Kalender"), style = MaterialTheme.typography.h6)
Text(
tr("mobile.calendarSubtitle", "Training, Turniere, Spiele und Feiertage im Monat."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(onClick = {
val p = yearMonth.minusMonths(1)
calYear = p.year
calMonth = p.monthValue
}) { Text("") }
TextButton(onClick = {
val n = LocalDate.now()
calYear = n.year
calMonth = n.monthValue
}) { Text(tr("mobile.calendarToday", "Heute")) }
TextButton(onClick = {
val n = yearMonth.plusMonths(1)
calYear = n.year
calMonth = n.monthValue
}) { Text("") }
}
val titleRaw = yearMonth.format(monthTitleFmt)
Text(
titleRaw.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.GERMAN) else it.toString() },
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp),
)
if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(vertical = 8.dp))
}
if (loadAllSourcesFailed && !loading) {
Text(
tr("mobile.calendarAllSourcesFailed", "Kalenderdaten konnten nicht geladen werden."),
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(bottom = 8.dp),
)
}
if (sourceWarnings.isNotEmpty()) {
Text(
sourceWarnings.joinToString(" · ") { "$it ${tr("mobile.calendarSourceFailed", "nicht geladen")}" },
color = MaterialTheme.colors.error,
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(bottom = 8.dp),
)
}
Text(tr("mobile.calendarLegend", "Anzeige"), style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium)
CalendarEventType.entries.chunked(2).forEach { rowTypes ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rowTypes.forEach { t ->
val count = events.count { it.type == t }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f).padding(vertical = 2.dp),
) {
Switch(
checked = activeTypes[t] != false,
onCheckedChange = { activeTypes[t] = it },
)
Text(
"${typeLabel(t)} ($count)",
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
}
Card(Modifier.fillMaxWidth().padding(top = 12.dp), elevation = 1.dp) {
Column(Modifier.padding(12.dp)) {
Text(tr("mobile.calendarCancellationTitle", "Training fällt aus"), fontWeight = FontWeight.SemiBold)
Text(
tr("mobile.calendarCancellationHint", "Blendet regelmäßige Trainingszeiten an den Tagen aus."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
OutlinedTextField(
value = cancelStart,
onValueChange = { cancelStart = it },
label = { Text(tr("mobile.calendarCancellationStart", "Datum (YYYY-MM-DD)")) },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = cancelEnd,
onValueChange = { cancelEnd = it },
label = { Text(tr("mobile.calendarCancellationEnd", "Bis optional")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
singleLine = true,
)
OutlinedTextField(
value = cancelReason,
onValueChange = { cancelReason = it },
label = { Text(tr("mobile.calendarCancellationReason", "Grund")) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
singleLine = true,
)
Button(
onClick = {
if (cancelStart.length < 10) return@Button
scope.launch {
cancelBusy = true
runCatching {
val end = cancelEnd.trim().ifBlank { cancelStart.trim() }
dependencies.trainingCancellationApi.upsert(
clubId,
TrainingCancellationUpsertBody(
startDate = cancelStart.trim().take(10),
endDate = end.take(10),
reason = cancelReason.trim().ifBlank { null },
),
)
cancelStart = ""
cancelEnd = ""
cancelReason = ""
dataGeneration++
}
cancelBusy = false
}
},
enabled = !cancelBusy && cancelStart.length >= 10,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = 48.dp),
) { Text(if (cancelBusy) "" else tr("mobile.calendarCancellationSave", "Eintragen")) }
val monthCancels = events
.filter { it.type == CalendarEventType.TrainingCancellation && it.cancellationId != null }
.filter { it.date.year == calYear && it.date.monthValue == calMonth }
.sortedBy { it.startsAtSort }
if (monthCancels.isNotEmpty()) {
Text(tr("mobile.calendarCancellationInMonth", "Ausfälle diesen Monat"), modifier = Modifier.padding(top = 12.dp), fontWeight = FontWeight.Medium)
monthCancels.forEach { c ->
Row(
Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text("${c.date}${c.endDate}", style = MaterialTheme.typography.caption)
Text(c.title, style = MaterialTheme.typography.body2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
TextButton(
onClick = {
val id = c.cancellationId ?: return@TextButton
scope.launch {
runCatching {
dependencies.trainingCancellationApi.delete(clubId, id)
dataGeneration++
}
}
},
) { Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error) }
}
}
}
}
}
Text(tr("mobile.calendarGridTitle", "Monat"), style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 16.dp))
Row(Modifier.fillMaxWidth()) {
wdShort.forEach { w ->
Text(w, style = MaterialTheme.typography.caption, modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium)
}
}
gridCells.chunked(7).forEach { week ->
Row(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
week.forEach { day ->
val inMonth = day.month == yearMonth.month
val today = day == LocalDate.now()
val dayEvents = CalendarAggregator.eventsForDay(filtered, day)
Column(
Modifier
.weight(1f)
.padding(2.dp)
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f))
.background(
when {
today -> MaterialTheme.colors.primary.copy(alpha = 0.08f)
!inMonth -> MaterialTheme.colors.onSurface.copy(alpha = 0.04f)
else -> MaterialTheme.colors.surface
},
)
.padding(4.dp)
.heightIn(min = 72.dp),
) {
Text(
"${day.dayOfMonth}",
style = MaterialTheme.typography.caption,
fontWeight = if (today) FontWeight.Bold else FontWeight.Normal,
color = if (inMonth) MaterialTheme.colors.onSurface else MaterialTheme.colors.onSurface.copy(alpha = 0.45f),
)
dayEvents.take(2).forEach { ev ->
Text(
buildString {
if (ev.timeLabel.isNotBlank()) append(ev.timeLabel).append(" ")
append(ev.title)
},
style = MaterialTheme.typography.caption,
color = eventColor(ev.type),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(top = 2.dp)
.clickable { onEventClick(ev) },
)
}
if (dayEvents.size > 2) {
Text(
"+${dayEvents.size - 2}",
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f),
)
}
}
}
}
}
Text(tr("mobile.calendarAgendaTitle", "Termine im Monat"), style = MaterialTheme.typography.subtitle2, modifier = Modifier.padding(top = 16.dp))
if (visibleMonthEvents.isEmpty()) {
Text(tr("mobile.calendarAgendaEmpty", "Keine Termine in diesem Monat."), style = MaterialTheme.typography.caption)
} else {
visibleMonthEvents.forEach { ev ->
Card(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clickable { onEventClick(ev) },
elevation = 1.dp,
) {
Row(
Modifier.padding(10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
val dr = formatAgendaEventRange(ev, agendaLocale)
Text(dr, style = MaterialTheme.typography.caption, color = eventColor(ev.type))
Text(ev.title, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (ev.subtitle.isNotBlank()) {
Text(ev.subtitle, style = MaterialTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
if (ev.timeLabel.isNotBlank()) {
Text(ev.timeLabel, style = MaterialTheme.typography.caption)
}
}
}
}
}
Spacer(Modifier.height(24.dp))
}
}

View File

@@ -72,6 +72,7 @@ internal enum class ClubStammdatenSection {
ClubSettings,
PredefinedActivities,
MemberTransfer,
Teams,
}
@Composable
@@ -85,6 +86,7 @@ internal fun ClubStammdatenFlowScreen(
ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack)
ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack)
ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack)
ClubStammdatenSection.Teams -> MobileTeamsScreen(dependencies, onBack)
}
}
@@ -132,10 +134,6 @@ private fun MobilePredefinedActivitiesScreen(dependencies: AppDependencies, onBa
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
TextButton(
onClick = { dependencies.openBackendPath("/predefined-activities") },
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.openPredefinedWeb", "Volle Verwaltung (Web)")) }
if (perms.canWritePredefinedActivities()) {
Button(
onClick = { creatingNew = true },
@@ -333,10 +331,6 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
TextButton(
onClick = { dependencies.openBackendPath("/member-transfer-settings") },
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.openMemberTransferWeb", "Erweiterte Einstellungen (Web)")) }
if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
@@ -466,6 +460,59 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
}
}
@Composable
private fun MobileTeamsScreen(dependencies: AppDependencies, onBack: () -> Unit) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
val scheduleState by dependencies.scheduleManager.state.collectAsState()
LaunchedEffect(clubId) {
dependencies.scheduleManager.loadClubTeams(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(StammdatenPad),
) {
StammdatenTopBar(tr("mobile.teamManagement", "Team-Verwaltung"), onBack)
if (perms?.canReadMembers() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
scheduleState.error?.let { Text(it, color = MaterialTheme.colors.error) }
if (scheduleState.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
}
if (scheduleState.teams.isEmpty()) {
Text(tr("mobile.noTeams", "Keine Mannschaften gefunden."))
return@Column
}
Text(
tr("mobile.teamsHint", "Mannschaften werden aus Click-TT/nuLiga importiert und im Terminplan genutzt."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(8.dp))
scheduleState.teams.forEach { t ->
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(t.name ?: tr("mobile.team", "Mannschaft"), fontWeight = FontWeight.SemiBold)
val league = t.league?.name ?: ""
if (league.isNotBlank()) {
Text(league, style = MaterialTheme.typography.caption)
}
}
}
}
}
}
@Composable
private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(

View File

@@ -108,179 +108,186 @@ internal fun ScheduleScreen(dependencies: AppDependencies) {
return
}
Column(
val matches = scheduleState.displayedMatches
LazyColumn(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.padding(horizontal = SchedulePad, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(tr("navigation.schedule", "Terminplan"), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = {
scope.launch {
dependencies.scheduleManager.loadOverallSchedule(clubId)
}
},
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
item {
Text(tr("navigation.schedule", "Terminplan"), style = MaterialTheme.typography.h5, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(tr("schedule.overallSchedule", "Gesamtplan"), maxLines = 2)
}
OutlinedButton(
onClick = {
scope.launch {
dependencies.scheduleManager.loadAdultSchedule(clubId)
}
},
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
) {
Text(tr("schedule.adultSchedule", "Erwachsene"), maxLines = 2)
OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.loadOverallSchedule(clubId) } },
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
) { Text(tr("schedule.overallSchedule", "Gesamtplan"), maxLines = 2) }
OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.loadAdultSchedule(clubId) } },
modifier = Modifier.weight(1f).heightIn(min = ScheduleTouchMin),
) { Text(tr("schedule.adultSchedule", "Erwachsene"), maxLines = 2) }
}
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(8.dp))
Box {
OutlinedButton(
onClick = { teamMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) {
val label = scheduleState.selectedTeam?.let { t ->
val lg = t.league?.name?.takeIf { it.isNotBlank() }
if (lg != null) "${t.name} ($lg)" else t.name
} ?: tr("schedule.selectTeam", "Mannschaft wählen")
Text(label, maxLines = 2)
}
DropdownMenu(expanded = teamMenu, onDismissRequest = { teamMenu = false }) {
scheduleState.teams.forEach { team ->
DropdownMenuItem(
onClick = {
teamMenu = false
scope.launch { dependencies.scheduleManager.selectTeam(clubId, team.id) }
},
) {
val lg = team.league?.name?.takeIf { it.isNotBlank() }
Text(if (lg != null) "${team.name} ($lg)" else team.name)
item {
Box {
OutlinedButton(
onClick = { teamMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) {
val label = scheduleState.selectedTeam?.let { t ->
val lg = t.league?.name?.takeIf { it.isNotBlank() }
if (lg != null) "${t.name} ($lg)" else t.name
} ?: tr("schedule.selectTeam", "Mannschaft wählen")
Text(label, maxLines = 2)
}
DropdownMenu(expanded = teamMenu, onDismissRequest = { teamMenu = false }) {
scheduleState.teams.forEach { team ->
DropdownMenuItem(
onClick = {
teamMenu = false
scope.launch { dependencies.scheduleManager.selectTeam(clubId, team.id) }
},
) {
val lg = team.league?.name?.takeIf { it.isNotBlank() }
Text(if (lg != null) "${team.name} ($lg)" else team.name)
}
}
}
}
}
if (scheduleState.viewMode == ScheduleViewMode.Team && scheduleState.selectedTeam != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(tr("schedule.matchScope", "Spiele anzeigen"), style = MaterialTheme.typography.caption)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ScheduleScopeChip(
label = tr("schedule.ownTeamMatches", "Eigene"),
selected = scheduleState.matchScope == ScheduleMatchScope.Own,
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Own) },
)
ScheduleScopeChip(
label = tr("schedule.allLeagueMatches", "Alle"),
selected = scheduleState.matchScope == ScheduleMatchScope.All,
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.All) },
)
ScheduleScopeChip(
label = tr("schedule.otherTeamMatches", "Andere"),
selected = scheduleState.matchScope == ScheduleMatchScope.Other,
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Other) },
)
item {
Spacer(modifier = Modifier.height(8.dp))
Text(tr("schedule.matchScope", "Spiele anzeigen"), style = MaterialTheme.typography.caption)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ScheduleScopeChip(
label = tr("schedule.ownTeamMatches", "Eigene"),
selected = scheduleState.matchScope == ScheduleMatchScope.Own,
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Own) },
)
ScheduleScopeChip(
label = tr("schedule.allLeagueMatches", "Alle"),
selected = scheduleState.matchScope == ScheduleMatchScope.All,
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.All) },
)
ScheduleScopeChip(
label = tr("schedule.otherTeamMatches", "Andere"),
selected = scheduleState.matchScope == ScheduleMatchScope.Other,
onClick = { dependencies.scheduleManager.setMatchScope(ScheduleMatchScope.Other) },
)
}
}
if (scheduleState.matchScope == ScheduleMatchScope.Other) {
Box(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
OutlinedButton(
onClick = { otherTeamMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) {
Text(
scheduleState.otherTeamName.ifBlank { tr("schedule.selectOtherTeam", "Mannschaft wählen") },
maxLines = 2,
)
}
DropdownMenu(expanded = otherTeamMenu, onDismissRequest = { otherTeamMenu = false }) {
scheduleState.leagueTeamOptions.forEach { name ->
DropdownMenuItem(
onClick = {
otherTeamMenu = false
dependencies.scheduleManager.setOtherTeamName(name)
},
) { Text(name) }
item {
Box(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
OutlinedButton(
onClick = { otherTeamMenu = true },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) {
Text(
scheduleState.otherTeamName.ifBlank { tr("schedule.selectOtherTeam", "Mannschaft wählen") },
maxLines = 2,
)
}
DropdownMenu(expanded = otherTeamMenu, onDismissRequest = { otherTeamMenu = false }) {
scheduleState.leagueTeamOptions.forEach { name ->
DropdownMenuItem(
onClick = {
otherTeamMenu = false
dependencies.scheduleManager.setOtherTeamName(name)
},
) { Text(name) }
}
}
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.refresh(clubId) } },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) { Text(tr("mobile.refresh", "Aktualisieren")) }
item {
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = { scope.launch { dependencies.scheduleManager.refresh(clubId) } },
modifier = Modifier.fillMaxWidth().heightIn(min = ScheduleTouchMin),
) { Text(tr("mobile.refresh", "Aktualisieren")) }
if (scheduleState.isLoading) {
Row(
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
if (scheduleState.isLoading) {
Row(
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.Center,
) { CircularProgressIndicator() }
}
scheduleState.error?.let {
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
}
}
scheduleState.error?.let {
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(vertical = 8.dp))
}
if (scheduleState.viewMode == ScheduleViewMode.Team && scheduleState.leagueTable.isNotEmpty()) {
Text(tr("schedule.leagueTable", "Tabelle"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), elevation = 1.dp) {
Column(modifier = Modifier.padding(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("#", fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(28.dp))
Text(tr("schedule.team", "Team"), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(tr("schedule.points", "Pkt"), fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(40.dp))
}
Divider()
scheduleState.leagueTable.forEachIndexed { idx, row ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${idx + 1}", modifier = Modifier.widthIn(28.dp))
Text(row.teamName, modifier = Modifier.weight(1f), maxLines = 2)
Text(row.tablePoints, modifier = Modifier.widthIn(40.dp))
item {
Text(
tr("schedule.leagueTable", "Tabelle"),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 12.dp),
)
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), elevation = 1.dp) {
Column(modifier = Modifier.padding(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("#", fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(28.dp))
Text(tr("schedule.team", "Team"), fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
Text(tr("schedule.points", "Pkt"), fontWeight = FontWeight.Bold, modifier = Modifier.widthIn(40.dp))
}
Divider()
scheduleState.leagueTable.forEachIndexed { idx, row ->
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${idx + 1}", modifier = Modifier.widthIn(28.dp))
Text(row.teamName, modifier = Modifier.weight(1f), maxLines = 2)
Text(row.tablePoints, modifier = Modifier.widthIn(40.dp))
}
}
}
}
}
}
Text(tr("schedule.games", "Spiele"), style = MaterialTheme.typography.subtitle1, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
item {
Text(
tr("schedule.games", "Spiele"),
style = MaterialTheme.typography.subtitle1,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 12.dp),
)
}
val matches = scheduleState.displayedMatches
if (matches.isEmpty() && !scheduleState.isLoading) {
Text(tr("schedule.noGames", "Keine Spiele"), modifier = Modifier.padding(top = 8.dp))
item { Text(tr("schedule.noGames", "Keine Spiele"), modifier = Modifier.padding(top = 8.dp)) }
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.weight(1f),
) {
items(matches, key = { it.id }) { m ->
ScheduleMatchCard(
match = m,
highlightClubName = clubState.clubs.find { it.id == clubId }?.name.orEmpty(),
showLeagueColumn = scheduleState.viewMode != ScheduleViewMode.Team,
onClick = { detailMatch = m },
)
}
items(matches, key = { it.id }) { m ->
ScheduleMatchCard(
match = m,
highlightClubName = clubState.clubs.find { it.id == clubId }?.name.orEmpty(),
showLeagueColumn = scheduleState.viewMode != ScheduleViewMode.Team,
onClick = { detailMatch = m },
)
}
}
}

View File

@@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
@@ -95,17 +95,11 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
fontWeight = FontWeight.SemiBold,
)
Text(
tr("mobile.tournamentsHubHint", "Vereins-Turniere und offizielle Meldelisten. Verwaltung im Browser."),
tr("mobile.tournamentsHubHint", "Vereins-Turniere, importierte Meldelisten und Teilnahmen hier in der App."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
)
TextButton(
onClick = { dependencies.openBackendPath("/tournaments") },
modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin),
) {
Text(tr("mobile.openTournamentsInWeb", "Turniere im Browser öffnen"))
}
}
item {
@@ -146,7 +140,10 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
} else {
items(internalState.tournaments, key = { it.id }) { t ->
itemsIndexed(
internalState.tournaments,
key = { index, t -> "${t.id}-$index" },
) { _, t ->
val selected = internalState.selectedId == t.id
Card(
modifier = Modifier.fillMaxWidth(),
@@ -225,7 +222,10 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
} else {
items(officialState.tournaments, key = { it.id }) { ot ->
itemsIndexed(
officialState.tournaments,
key = { index, ot -> "${ot.id}-$index" },
) { _, ot ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(ot.title ?: "Turnier #${ot.id}", fontWeight = FontWeight.Medium)
ot.eventDate?.takeIf { it.isNotBlank() }?.let {
@@ -250,13 +250,23 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
} else {
items(
itemsIndexed(
participationFlatRows,
key = { r -> "${r.tournamentId}_${r.entry.memberId}_${r.entry.competitionId}_${r.entry.date}_${r.entry.competitionName}" },
) { r ->
key = { index, _ -> "participation-$index" },
) { _, r ->
ParticipationRow(tournamentTitle = r.tournamentTitle, entry = r.entry)
}
}
item {
Spacer(modifier = Modifier.height(20.dp))
Text(
tr("mobile.tournamentsWebHintFooter", "Turniere anlegen, bearbeiten oder Meldelisten verwalten optional im Webbrowser."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.62f),
)
// Web-UI wird in der Produktiv-App nicht aufgerufen.
}
}
}