feat(ClubSettings): add country and state code fields for regional calendar data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Introduced `countryCode` and `stateCode` fields in the Club model to support regional calendar data.
- Updated ClubSettings component to allow users to select their country and state, enhancing the configuration options for clubs.
- Enhanced the ClubService to handle normalization of country and state codes during updates.
- Added new routes and middleware to support the training cancellation feature and calendar integration in the backend.
- Updated frontend navigation to include a calendar link, improving user access to scheduling features.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:46:07 +02:00
parent 1e23171370
commit bea5facb7d
46 changed files with 4286 additions and 12 deletions

View File

@@ -41,6 +41,7 @@ kotlin {
implementation(libs.koin.android)
implementation(libs.coil.compose)
implementation(libs.yalantis.ucrop)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
}

View File

@@ -4,9 +4,12 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import de.tt_tagebuch.shared.api.AccidentApi
import de.tt_tagebuch.shared.api.ApiLogsApi
import de.tt_tagebuch.shared.api.ClubApprovalsApi
import de.tt_tagebuch.shared.api.ApiConfig
import de.tt_tagebuch.shared.api.AuthApi
import de.tt_tagebuch.shared.api.PublicAuthApi
import de.tt_tagebuch.shared.api.ClubTeamsApi
import de.tt_tagebuch.shared.api.ClubsApi
import de.tt_tagebuch.shared.api.DiaryApi
import de.tt_tagebuch.shared.api.DiaryMemberActivitiesApi
@@ -14,26 +17,36 @@ import de.tt_tagebuch.shared.api.DiaryMemberApi
import de.tt_tagebuch.shared.api.GroupApi
import de.tt_tagebuch.shared.api.ParticipantsApi
import de.tt_tagebuch.shared.api.PredefinedActivitiesApi
import de.tt_tagebuch.shared.api.MatchesApi
import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.SessionApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingStatsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
import de.tt_tagebuch.shared.api.TournamentsApi
import de.tt_tagebuch.shared.api.http.AndroidHttpClientEngineFactory
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.http.PublicHttpClient
import de.tt_tagebuch.shared.state.AndroidClubStorage
import de.tt_tagebuch.shared.state.AndroidLanguageStorage
import de.tt_tagebuch.shared.state.AndroidTokenStorage
import de.tt_tagebuch.shared.state.ApiLogsManager
import de.tt_tagebuch.shared.state.AuthManager
import de.tt_tagebuch.shared.state.ClubInternalTournamentsManager
import de.tt_tagebuch.shared.state.ClubManager
import de.tt_tagebuch.shared.state.DiaryManager
import de.tt_tagebuch.shared.state.LanguageManager
import de.tt_tagebuch.shared.state.MembersManager
import de.tt_tagebuch.shared.state.MutableTokenProvider
import de.tt_tagebuch.shared.state.OfficialTournamentsReadManager
import de.tt_tagebuch.shared.state.PendingApprovalsManager
import de.tt_tagebuch.shared.state.PermissionsAdminManager
import de.tt_tagebuch.shared.state.MutableTokenProvider
import de.tt_tagebuch.shared.state.ScheduleManager
import de.tt_tagebuch.shared.state.TrainingStatsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -73,12 +86,20 @@ class AppDependencies(context: Context) {
sessionApi = SessionApi(client),
)
private val permissionsApi = PermissionsApi(client)
val clubManager = ClubManager(
clubStorage = AndroidClubStorage(context.applicationContext),
clubsApi = ClubsApi(client),
permissionsApi = PermissionsApi(client),
permissionsApi = permissionsApi,
)
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client))
val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client))
val diaryManager = DiaryManager(
DiaryApi(client),
ParticipantsApi(client),
@@ -96,6 +117,10 @@ class AppDependencies(context: Context) {
TrainingTimesApi(client),
)
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
val scheduleManager = ScheduleManager(
ClubTeamsApi(client),
MatchesApi(client),
)
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))
val sessionApi = SessionApi(client)

View File

@@ -47,8 +47,10 @@ import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Home
@@ -81,8 +83,13 @@ import de.tt_tagebuch.app.pdf.writeTrainingPlanPdf
import de.tt_tagebuch.shared.api.memberProfileImagePath
import de.tt_tagebuch.shared.api.toAbsoluteUrl
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tt_tagebuch.shared.api.models.canReadApprovals
import de.tt_tagebuch.shared.api.models.canReadClubPermissions
import de.tt_tagebuch.shared.api.models.canReadDiary
import de.tt_tagebuch.shared.api.models.canReadTeams
import de.tt_tagebuch.shared.api.models.canReadMembers
import de.tt_tagebuch.shared.api.models.canReadSchedule
import de.tt_tagebuch.shared.api.models.canReadTournaments
import de.tt_tagebuch.shared.api.models.canWriteDiary
import de.tt_tagebuch.shared.api.models.canWriteMembers
import de.tt_tagebuch.shared.api.models.mainActivityImagePath
@@ -115,6 +122,7 @@ import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
import de.tt_tagebuch.shared.api.models.toSetBody
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.UserClubPermissions
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
@@ -129,6 +137,14 @@ private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600
private val ScreenHorizontalPadding = 20.dp
private val TouchMinHeight = 48.dp
private fun visibleMainTabs(perms: UserClubPermissions?): List<MainTab> =
MainTab.entries.filter { tab ->
when (tab) {
MainTab.Tournaments -> perms?.canReadTournaments() == true
else -> true
}
}
private const val AUTH_ROUTE_LOGIN = "login"
private const val AUTH_ROUTE_REGISTER = "register"
private const val AUTH_ROUTE_FORGOT = "forgot"
@@ -139,7 +155,9 @@ private enum class MainTab {
Home,
Diary,
Members,
Schedule,
Stats,
Tournaments,
Settings,
}
@@ -162,6 +180,12 @@ fun AppRoot(dependencies: AppDependencies) {
dependencies.diaryManager.clear()
dependencies.membersManager.clear()
dependencies.trainingStatsManager.clear()
dependencies.scheduleManager.clear()
dependencies.pendingApprovalsManager.clear()
dependencies.permissionsAdminManager.clear()
dependencies.apiLogsManager.clear()
dependencies.clubInternalTournamentsManager.clear()
dependencies.officialTournamentsReadManager.clear()
}
}
}
@@ -184,6 +208,14 @@ private fun MainTabs(dependencies: AppDependencies) {
var diarySelectedEntryId by remember { mutableStateOf<Int?>(null) }
var membersNestedOpen by remember { mutableStateOf(false) }
val useWideMainNav = LocalConfiguration.current.screenWidthDp >= MAIN_NAV_RAIL_MIN_WIDTH_DP
val clubState by dependencies.clubManager.state.collectAsState()
val visibleTabs = visibleMainTabs(clubState.currentPermissions)
LaunchedEffect(visibleTabs, selectedTab) {
if (!visibleTabs.contains(selectedTab)) {
selectedTab = MainTab.Home
}
}
val isNestedDetail = when (selectedTab) {
MainTab.Diary -> diarySelectedEntryId != null
@@ -205,6 +237,7 @@ private fun MainTabs(dependencies: AppDependencies) {
MainNavigationRail(
selectedTab = selectedTab,
onTabSelected = { selectMainTab(it) },
visibleTabs = visibleTabs,
)
Divider(
color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
@@ -240,7 +273,7 @@ private fun MainTabs(dependencies: AppDependencies) {
contentColor = MaterialTheme.colors.onSurface,
elevation = 8.dp,
) {
MainTab.values().forEach { tab ->
visibleTabs.forEach { tab ->
BottomNavigationItem(
icon = { Icon(mainTabIcon(tab), contentDescription = tabTitle(tab)) },
label = { Text(tabTitle(tab)) },
@@ -280,6 +313,8 @@ private fun MainTabContent(
dependencies = dependencies,
onNestedOpenChange = onMembersNestedOpenChange,
)
MainTab.Schedule -> ScheduleScreen(dependencies)
MainTab.Tournaments -> TournamentsScreen(dependencies)
MainTab.Stats -> TrainingStatsScreen(dependencies)
MainTab.Settings -> SettingsScreen(dependencies)
}
@@ -337,6 +372,15 @@ private fun HomeScreen(
subtitle = tr("home.tileMembers", "Liste und Profile"),
onClick = { onOpenTab(MainTab.Members) },
)
clubState.currentPermissions?.let { p ->
if (p.canReadSchedule()) {
HomeHubTile(
title = tr("navigation.schedule", "Terminplan"),
subtitle = tr("home.tileSchedule", "Mannschaften, Spiele, Tabelle"),
onClick = { onOpenTab(MainTab.Schedule) },
)
}
}
HomeHubTile(
title = tr("navigation.statistics", "Statistik"),
subtitle = tr("home.tileStats", "Kennzahlen und Teilnahmen"),
@@ -397,6 +441,7 @@ private fun HomeHubTile(title: String, subtitle: String, onClick: () -> Unit) {
private fun MainNavigationRail(
selectedTab: MainTab,
onTabSelected: (MainTab) -> Unit,
visibleTabs: List<MainTab>,
modifier: Modifier = Modifier,
) {
Column(
@@ -407,7 +452,7 @@ private fun MainNavigationRail(
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
MainTab.values().forEach { tab ->
visibleTabs.forEach { tab ->
val selected = selectedTab == tab
Surface(
modifier = Modifier
@@ -455,6 +500,8 @@ private fun mainTabIcon(tab: MainTab): ImageVector = when (tab) {
MainTab.Home -> Icons.Filled.Home
MainTab.Diary -> Icons.Filled.DateRange
MainTab.Members -> Icons.Filled.People
MainTab.Schedule -> Icons.AutoMirrored.Filled.List
MainTab.Tournaments -> Icons.Filled.EmojiEvents
MainTab.Stats -> Icons.Filled.BarChart
MainTab.Settings -> Icons.Filled.Settings
}
@@ -3913,6 +3960,15 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
@Composable
private fun SettingsScreen(dependencies: AppDependencies) {
var clubAdminSection by remember { mutableStateOf<ClubAdminSettingsSection?>(null) }
if (clubAdminSection != null) {
ClubAdminFlowScreen(
destination = clubAdminSection!!,
dependencies = dependencies,
onBack = { clubAdminSection = null },
)
return
}
val authState by dependencies.authManager.state.collectAsState()
val clubState by dependencies.clubManager.state.collectAsState()
var sessionStatus by rememberSaveable { mutableStateOf<String?>(null) }
@@ -3936,6 +3992,33 @@ private fun SettingsScreen(dependencies: AppDependencies) {
clubState.currentPermissions?.let { permissions ->
DetailLine(tr("mobile.role", "Rolle"), permissions.role)
}
val clubId = clubState.currentClubId
val perms = clubState.currentPermissions
if (clubId != null && perms != null) {
SectionTitle(tr("mobile.clubAdmin", "Club-Verwaltung"))
if (perms.canReadApprovals()) {
TextButton(
onClick = { clubAdminSection = ClubAdminSettingsSection.Pending },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.pendingApprovals", "Ausstehende Freigaben")) }
}
if (perms.canReadClubPermissions()) {
TextButton(
onClick = { clubAdminSection = ClubAdminSettingsSection.Permissions },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.permissionsAdmin", "Berechtigungen")) }
}
TextButton(
onClick = { clubAdminSection = ClubAdminSettingsSection.Logs },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.apiLogs", "API-Logs")) }
if (perms.canReadTeams()) {
TextButton(
onClick = { dependencies.openBackendPath("/team-management") },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.teamManagementWeb", "Team-Verwaltung (Web)")) }
}
}
SectionTitle(tr("mobile.language", "Sprache"))
MobileStrings.supportedLanguages.forEach { language ->
TextButton(
@@ -3992,6 +4075,10 @@ private fun SettingsScreen(dependencies: AppDependencies) {
dependencies.diaryManager.clear()
dependencies.membersManager.clear()
dependencies.trainingStatsManager.clear()
dependencies.scheduleManager.clear()
dependencies.pendingApprovalsManager.clear()
dependencies.permissionsAdminManager.clear()
dependencies.apiLogsManager.clear()
}
},
modifier = Modifier
@@ -4411,6 +4498,8 @@ private fun tabTitle(tab: MainTab): String = when (tab) {
MainTab.Home -> tr("navigation.home", "Start")
MainTab.Diary -> tr("navigation.diary", "Tagebuch")
MainTab.Members -> tr("navigation.members", "Mitglieder")
MainTab.Schedule -> tr("navigation.schedule", "Terminplan")
MainTab.Tournaments -> tr("navigation.clubTournaments", "Turniere")
MainTab.Stats -> tr("navigation.statistics", "Statistik")
MainTab.Settings -> tr("mobile.more", "Mehr")
}

View File

@@ -0,0 +1,569 @@
package de.tt_tagebuch.app.ui
import androidx.activity.compose.BackHandler
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.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
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.RolePermissionMatrix
import de.tt_tagebuch.shared.api.models.ApiLogDetailDto
import de.tt_tagebuch.shared.api.models.ClubPermissionMemberDto
import de.tt_tagebuch.shared.api.models.PermissionResourceDto
import de.tt_tagebuch.shared.api.models.canReadApprovals
import de.tt_tagebuch.shared.api.models.canReadClubPermissions
import de.tt_tagebuch.shared.api.models.canWriteApprovals
import de.tt_tagebuch.shared.api.models.canWriteClubPermissions
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
private val ClubAdminPad = 20.dp
private val ClubAdminTouchMin = 48.dp
internal enum class ClubAdminSettingsSection {
Pending,
Permissions,
Logs,
}
@Composable
internal fun ClubAdminFlowScreen(
destination: ClubAdminSettingsSection,
dependencies: AppDependencies,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
when (destination) {
ClubAdminSettingsSection.Pending ->
ClubAdminPendingScreen(dependencies = dependencies, onBack = onBack)
ClubAdminSettingsSection.Permissions ->
ClubAdminPermissionsScreen(dependencies = dependencies, onBack = onBack)
ClubAdminSettingsSection.Logs ->
ClubAdminLogsScreen(dependencies = dependencies, onBack = onBack)
}
}
@Composable
private fun ClubAdminTopBar(title: String, onBack: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(title, style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(8.dp))
}
private fun readTri(root: JsonObject?, resource: String, action: String): Boolean? {
val mod = root?.get(resource) ?: return null
val obj = mod as? JsonObject ?: return null
if (!obj.containsKey(action)) return null
val el = obj[action] ?: return null
if (el is JsonNull) return null
return (el as? JsonPrimitive)?.booleanOrNull
}
private fun overridesToJson(
structure: Map<String, PermissionResourceDto>,
overrides: Map<String, Boolean?>,
): JsonObject = buildJsonObject {
structure.forEach { (res, spec) ->
val inner = buildJsonObject {
spec.actions.forEach { a ->
val k = permKey(res, a)
when (val v = overrides[k]) {
true -> put(a, JsonPrimitive(true))
false -> put(a, JsonPrimitive(false))
null -> {}
}
}
}
if (inner.isNotEmpty()) put(res, inner)
}
}
private fun permKey(resource: String, action: String) = "$resource#$action"
@Composable
private fun ClubAdminPendingScreen(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 pendingState by dependencies.pendingApprovalsManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
val canWrite = perms?.canWriteApprovals() == true
val scope = rememberCoroutineScope()
var actionError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(clubId) {
dependencies.pendingApprovalsManager.load(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = ClubAdminPad, vertical = 16.dp),
) {
ClubAdminTopBar(tr("mobile.pendingApprovals", "Ausstehende Freigaben"), onBack)
actionError?.let {
Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp))
}
if (perms != null && !perms.canReadApprovals()) {
Text(tr("mobile.noAccess", "Keine Berechtigung."), modifier = Modifier.padding(top = 8.dp))
return
}
if (pendingState.isLoading && pendingState.pending.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return
}
pendingState.error?.let { Text(it, color = MaterialTheme.colors.error) }
if (pendingState.pending.isEmpty()) {
Text(tr("mobile.pendingEmpty", "Keine ausstehenden Anträge."), modifier = Modifier.padding(top = 16.dp))
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(pendingState.pending, key = { it.userId }) { row ->
val u = row.user
val label = listOfNotNull(u?.firstName, u?.lastName).joinToString(" ").trim()
.ifEmpty { u?.email ?: "User ${row.userId}" }
Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) {
Column(modifier = Modifier.padding(12.dp)) {
Text(label, fontWeight = FontWeight.SemiBold)
u?.email?.let { Text(it, style = MaterialTheme.typography.caption) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
actionError = null
scope.launch {
try {
dependencies.pendingApprovalsManager.approve(clubId, row.userId)
} catch (t: Throwable) {
actionError = t.message
}
}
},
enabled = canWrite,
modifier = Modifier.weight(1f).heightIn(min = ClubAdminTouchMin),
) { Text(tr("mobile.approve", "Freigeben")) }
OutlinedButton(
onClick = {
actionError = null
scope.launch {
try {
dependencies.pendingApprovalsManager.reject(clubId, row.userId)
} catch (t: Throwable) {
actionError = t.message
}
}
},
enabled = canWrite,
modifier = Modifier.weight(1f).heightIn(min = ClubAdminTouchMin),
) { Text(tr("mobile.reject", "Ablehnen")) }
}
}
}
}
}
}
}
}
@Composable
private fun ClubAdminPermissionsScreen(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 adminState by dependencies.permissionsAdminManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
val canWrite = perms?.canWriteClubPermissions() == true
val scope = rememberCoroutineScope()
var roleMenuFor by remember { mutableStateOf<Int?>(null) }
var customizeFor by remember { mutableStateOf<ClubPermissionMemberDto?>(null) }
val overrides = remember { mutableStateMapOf<String, Boolean?>() }
var customizeSaving by remember { mutableStateOf(false) }
var opError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(clubId) {
dependencies.permissionsAdminManager.load(clubId)
}
LaunchedEffect(customizeFor, adminState.permissionStructure) {
val m = customizeFor ?: return@LaunchedEffect
if (adminState.permissionStructure.isEmpty()) return@LaunchedEffect
overrides.clear()
adminState.permissionStructure.forEach { (res, spec) ->
spec.actions.forEach { a ->
overrides[permKey(res, a)] = readTri(m.permissions, res, a)
}
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = ClubAdminPad, vertical = 16.dp),
) {
ClubAdminTopBar(tr("mobile.permissionsAdmin", "Berechtigungen"), onBack)
opError?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(bottom = 8.dp)) }
if (perms != null && !perms.canReadClubPermissions()) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return
}
if (adminState.isLoading && adminState.members.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return
}
adminState.error?.let { Text(it, color = MaterialTheme.colors.error) }
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(adminState.members, key = { it.userId }) { m ->
val email = m.user?.email ?: "User ${m.userId}"
Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) {
Column(modifier = Modifier.padding(12.dp)) {
Text(email, fontWeight = FontWeight.SemiBold)
if (m.isOwner) {
Text(tr("mobile.clubOwner", "Vereinsbesitzer"), style = MaterialTheme.typography.caption)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(tr("mobile.role", "Rolle"))
Box {
TextButton(
enabled = canWrite && !m.isOwner,
onClick = { roleMenuFor = m.userId },
) {
Text(adminState.availableRoles.firstOrNull { it.value == m.role }?.label ?: m.role)
}
DropdownMenu(
expanded = roleMenuFor == m.userId,
onDismissRequest = { roleMenuFor = null },
) {
adminState.availableRoles.forEach { role ->
DropdownMenuItem(
onClick = {
roleMenuFor = null
opError = null
scope.launch {
try {
dependencies.permissionsAdminManager.updateRole(clubId, m.userId, role.value)
} catch (t: Throwable) {
opError = t.message
}
}
},
) { Text(role.label) }
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(tr("mobile.memberApproved", "Aktiv"))
Switch(
checked = m.approved != false,
enabled = canWrite && !m.isOwner,
onCheckedChange = { on ->
opError = null
scope.launch {
try {
dependencies.permissionsAdminManager.updateApproved(clubId, m.userId, on)
} catch (t: Throwable) {
opError = t.message
}
}
},
)
}
TextButton(
enabled = canWrite && !m.isOwner,
onClick = { customizeFor = m },
modifier = Modifier.fillMaxWidth().heightIn(min = ClubAdminTouchMin),
) { Text(tr("mobile.customizePermissions", "Berechtigungen anpassen")) }
}
}
}
}
}
val dialogMember = customizeFor
if (dialogMember != null) {
AlertDialog(
onDismissRequest = { if (!customizeSaving) customizeFor = null },
title = { Text(dialogMember.user?.email ?: "User ${dialogMember.userId}") },
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.heightIn(max = 420.dp),
) {
adminState.permissionStructure.forEach { (res, spec) ->
Text(
spec.label.ifEmpty { res },
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
)
spec.actions.forEach { action ->
val k = permKey(res, action)
val tri = overrides[k]
val inherited = RolePermissionMatrix.defaultAction(dialogMember.role, res, action)
val label = when (tri) {
null -> tr("mobile.permInherit", "Standard") + " (${if (inherited) tr("mobile.yes", "Ja") else tr("mobile.no", "Nein")})"
true -> tr("mobile.permAllow", "Erlaubt")
false -> tr("mobile.permDeny", "Verweigert")
}
TextButton(
onClick = {
overrides[k] = when (tri) {
null -> true
true -> false
false -> null
}
},
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(action, style = MaterialTheme.typography.caption)
Text(label)
}
}
Divider()
}
}
}
},
confirmButton = {
TextButton(
enabled = !customizeSaving,
onClick = {
scope.launch {
customizeSaving = true
opError = null
try {
val json = overridesToJson(adminState.permissionStructure, overrides)
dependencies.permissionsAdminManager.saveCustomPermissions(
clubId,
dialogMember.userId,
json,
)
customizeFor = null
} catch (t: Throwable) {
opError = t.message
} finally {
customizeSaving = false
}
}
},
) { Text(tr("common.save", "Speichern")) }
},
dismissButton = {
TextButton(enabled = !customizeSaving, onClick = { customizeFor = null }) {
Text(tr("common.cancel", "Abbrechen"))
}
},
)
}
}
@Composable
private fun ClubAdminLogsScreen(dependencies: AppDependencies, onBack: () -> Unit) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val logsState by dependencies.apiLogsManager.state.collectAsState()
val scope = rememberCoroutineScope()
var pathFilter by remember { mutableStateOf("") }
var detailId by remember { mutableStateOf<Int?>(null) }
var detail by remember { mutableStateOf<ApiLogDetailDto?>(null) }
var detailLoading by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
dependencies.apiLogsManager.load(resetOffset = true)
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = ClubAdminPad, vertical = 16.dp),
) {
ClubAdminTopBar(tr("mobile.apiLogs", "API-Logs"), onBack)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = pathFilter,
onValueChange = { pathFilter = it },
label = { Text(tr("mobile.logPathFilter", "Pfad enthält")) },
singleLine = true,
modifier = Modifier.weight(1f),
)
Button(
onClick = {
scope.launch {
dependencies.apiLogsManager.load(
pathContains = pathFilter.takeIf { it.isNotBlank() },
resetOffset = true,
)
}
},
modifier = Modifier.heightIn(min = ClubAdminTouchMin),
) { Text(tr("mobile.apply", "Anwenden")) }
}
logsState.error?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(
enabled = !logsState.isLoading && logsState.offset > 0,
onClick = {
scope.launch {
dependencies.apiLogsManager.previousPage(pathContains = pathFilter.takeIf { it.isNotBlank() })
}
},
modifier = Modifier.heightIn(min = ClubAdminTouchMin),
) { Text(tr("mobile.prevPage", "Zurück")) }
Text(
"${logsState.offset + 1}${(logsState.offset + logsState.logs.size).coerceAtLeast(logsState.offset)} / ${logsState.total}",
style = MaterialTheme.typography.caption,
)
OutlinedButton(
enabled = !logsState.isLoading && logsState.offset + logsState.logs.size < logsState.total,
onClick = {
scope.launch {
dependencies.apiLogsManager.nextPage(pathContains = pathFilter.takeIf { it.isNotBlank() })
}
},
modifier = Modifier.heightIn(min = ClubAdminTouchMin),
) { Text(tr("mobile.nextPage", "Weiter")) }
}
if (logsState.isLoading && logsState.logs.isEmpty()) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
} else {
LazyColumn(modifier = Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(logsState.logs, key = { it.id }) { row ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
elevation = 1.dp,
) {
TextButton(
onClick = {
scope.launch {
detailId = row.id
detail = null
detailLoading = true
detail = dependencies.apiLogsManager.fetchDetail(row.id)
detailLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.fillMaxWidth().padding(4.dp)) {
Text(
"${row.method ?: "?"} ${row.statusCode ?: "-"}",
fontWeight = FontWeight.SemiBold,
)
Text(row.path ?: "-", style = MaterialTheme.typography.caption)
row.createdAt?.let { Text(it, style = MaterialTheme.typography.caption) }
}
}
}
}
}
}
}
if (detailId != null) {
AlertDialog(
onDismissRequest = { detailId = null },
title = { Text(tr("mobile.logDetail", "Log-Detail")) },
text = {
when {
detailLoading -> CircularProgressIndicator()
detail != null -> {
val d = detail!!
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("id=${d.id}")
d.method?.let { Text("method=$it") }
d.path?.let { Text("path=$it") }
d.statusCode?.let { Text("status=$it") }
d.executionTime?.let { Text("ms=$it") }
d.createdAt?.let { Text(it) }
d.errorMessage?.takeIf { it.isNotBlank() }?.let { Text(it, color = MaterialTheme.colors.error) }
d.ipAddress?.let { Text("ip=$it") }
d.userAgent?.let { Text(it, style = MaterialTheme.typography.caption) }
}
}
else -> Text(tr("mobile.logDetailFailed", "Konnte nicht geladen werden."))
}
},
confirmButton = {
TextButton(onClick = { detailId = null }) { Text(tr("common.close", "Schließen")) }
},
)
}
}

View File

@@ -0,0 +1,502 @@
package de.tt_tagebuch.app.ui
import android.content.Intent
import android.net.Uri
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Card
import androidx.compose.material.Checkbox
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.app.stats.TrainingStatsDerived
import de.tt_tagebuch.shared.api.models.ScheduleMatchDto
import de.tt_tagebuch.shared.api.models.ScheduleMatchScope
import de.tt_tagebuch.shared.api.models.ScheduleViewMode
import de.tt_tagebuch.shared.api.models.canReadSchedule
import de.tt_tagebuch.shared.api.models.canWriteSchedule
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.launch
private val SchedulePad = 20.dp
private val ScheduleTouchMin = 48.dp
@Composable
internal fun ScheduleScreen(dependencies: AppDependencies) {
val clubState by dependencies.clubManager.state.collectAsState()
val scheduleState by dependencies.scheduleManager.state.collectAsState()
val membersState by dependencies.membersManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val permissions = clubState.currentPermissions
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
var teamMenu by remember { mutableStateOf(false) }
var otherTeamMenu by remember { mutableStateOf(false) }
var detailMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var playerMatch by remember { mutableStateOf<ScheduleMatchDto?>(null) }
var playerError by remember { mutableStateOf<String?>(null) }
var playerSaving by remember { mutableStateOf(false) }
var readyIds by remember { mutableStateOf(emptyList<Int>()) }
var plannedIds by remember { mutableStateOf(emptyList<Int>()) }
var playedIds by remember { mutableStateOf(emptyList<Int>()) }
LaunchedEffect(playerMatch?.id) {
val pm = playerMatch ?: return@LaunchedEffect
readyIds = pm.playersReady
plannedIds = pm.playersPlanned
playedIds = pm.playersPlayed
}
LaunchedEffect(clubId) {
dependencies.scheduleManager.clear()
dependencies.scheduleManager.loadClubTeams(clubId)
}
if (permissions != null && !permissions.canReadSchedule()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(SchedulePad)
.imePadding()
.navigationBarsPadding(),
) {
Text(tr("schedule.noPermission", "Keine Berechtigung für den Spielplan."), style = MaterialTheme.typography.body1)
}
return
}
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.navigationBarsPadding()
.padding(horizontal = SchedulePad, vertical = 12.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),
) {
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))
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) },
)
}
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) }
}
}
}
}
}
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()
}
}
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))
}
}
}
}
}
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))
} 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 },
)
}
}
}
}
detailMatch?.let { m ->
AlertDialog(
onDismissRequest = { detailMatch = null },
title = { Text("${m.homeTeam?.name ?: "?"} : ${m.guestTeam?.name ?: "?"}") },
text = {
val scroll = rememberScrollState()
Column(Modifier.verticalScroll(scroll)) {
Text("${TrainingStatsDerived.formatDateGerman(m.date)} · ${TrainingStatsDerived.weekdayGerman(m.date ?: "")}")
Text(
tr("schedule.time", "Zeit") + ": " + (m.time?.take(5)?.takeIf { it.isNotBlank() } ?: ""),
)
if (m.isCompleted) {
Text(
tr("schedule.result", "Ergebnis") + ": ${m.homeMatchPoints}:${m.guestMatchPoints}",
fontWeight = FontWeight.SemiBold,
)
}
val loc = m.location
loc?.name?.takeIf { it.isNotBlank() && it != "Unbekannt" }?.let { hallName ->
Text(tr("schedule.location", "Halle") + ": $hallName")
val addr = listOfNotNull(
loc.address.takeIf { a -> a.isNotBlank() },
listOfNotNull(loc.zip.takeIf { z -> z.isNotBlank() }, loc.city.takeIf { c -> c.isNotBlank() })
.joinToString(" ")
.takeIf { s -> s.isNotBlank() },
).joinToString(", ")
if (addr.isNotBlank()) Text(addr, style = MaterialTheme.typography.caption)
}
m.leagueDetails?.name?.takeIf { it.isNotBlank() }?.let {
Text(tr("schedule.ageClass", "Liga") + ": $it", style = MaterialTheme.typography.caption)
}
m.code?.takeIf { it.isNotBlank() }?.let { code ->
TextButton(onClick = { clipboard.setText(AnnotatedString(code)) }) {
Text(tr("schedule.code", "Code") + ": $code")
}
}
Row {
m.homePin?.takeIf { it.isNotBlank() }?.let { pin ->
TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) {
Text(tr("schedule.homePin", "Heim-PIN") + ": $pin")
}
}
m.guestPin?.takeIf { it.isNotBlank() }?.let { pin ->
TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) {
Text(tr("schedule.guestPin", "Gast-PIN") + ": $pin")
}
}
}
m.pdfUrl?.takeIf { it.startsWith("http") }?.let { url ->
TextButton(
onClick = {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
},
) { Text(tr("schedule.openMatchReport", "Bericht öffnen")) }
}
if (permissions?.canWriteSchedule() == true) {
TextButton(
onClick = {
playerError = null
playerMatch = m
detailMatch = null
},
) {
Text(tr("schedule.players", "Aufstellung / Spieler"))
}
}
}
},
confirmButton = {
TextButton(onClick = { detailMatch = null }) { Text(tr("common.close", "Schließen")) }
},
)
}
playerMatch?.let { m ->
LaunchedEffect(m.id, clubId) {
dependencies.membersManager.loadMembers(clubId)
}
AlertDialog(
onDismissRequest = { if (!playerSaving) playerMatch = null },
title = { Text(tr("schedule.playerSelectionTitle", "Spieler")) },
text = {
Column(modifier = Modifier.heightIn(max = 400.dp)) {
playerError?.let { Text(it, color = MaterialTheme.colors.error) }
val memberList = membersState.members.filter { it.active }
if (membersState.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
} else {
val scroll = rememberScrollState()
Column(Modifier.verticalScroll(scroll)) {
memberList.forEach { mem ->
val id = mem.id
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.weight(1f)) {
Text("${mem.firstName} ${mem.lastName}".trim(), maxLines = 1)
}
Text("R", style = MaterialTheme.typography.caption)
Checkbox(
checked = id in readyIds,
onCheckedChange = { c ->
readyIds = if (c) (readyIds + id).distinct() else readyIds.filter { it != id }
},
)
Text("P", style = MaterialTheme.typography.caption)
Checkbox(
checked = id in plannedIds,
onCheckedChange = { c ->
plannedIds = if (c) (plannedIds + id).distinct() else plannedIds.filter { it != id }
},
)
Text("S", style = MaterialTheme.typography.caption)
Checkbox(
checked = id in playedIds,
onCheckedChange = { c ->
playedIds = if (c) (playedIds + id).distinct() else playedIds.filter { it != id }
},
)
}
Divider()
}
}
}
}
},
confirmButton = {
TextButton(
enabled = !playerSaving,
onClick = {
scope.launch {
playerSaving = true
playerError = null
runCatching {
dependencies.scheduleManager.updateMatchPlayers(
clubId = clubId,
matchId = m.id,
ready = readyIds,
planned = plannedIds,
played = playedIds,
)
playerMatch = null
}.onFailure { playerError = it.message ?: tr("schedule.errorSavingPlayerSelection", "Speichern fehlgeschlagen") }
playerSaving = false
}
},
) { Text(tr("common.save", "Speichern")) }
},
dismissButton = {
TextButton(enabled = !playerSaving, onClick = { playerMatch = null }) {
Text(tr("common.cancel", "Abbrechen"))
}
},
)
}
}
@Composable
private fun ScheduleScopeChip(label: String, selected: Boolean, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
modifier = Modifier.heightIn(min = 40.dp),
) {
Text(label, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, maxLines = 1)
}
}
@Composable
private fun ScheduleMatchCard(
match: ScheduleMatchDto,
highlightClubName: String,
showLeagueColumn: Boolean,
onClick: () -> Unit,
) {
val homeH = highlightClubName.isNotBlank() && match.homeTeam?.name?.contains(highlightClubName) == true
val guestH = highlightClubName.isNotBlank() && match.guestTeam?.name?.contains(highlightClubName) == true
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = 1.dp,
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
TrainingStatsDerived.formatDateGerman(match.date),
fontWeight = FontWeight.SemiBold,
)
Text(match.time?.take(5) ?: "", style = MaterialTheme.typography.caption)
}
Text(
"${match.homeTeam?.name ?: "?"} : ${match.guestTeam?.name ?: "?"}",
color = when {
homeH || guestH -> MaterialTheme.colors.primary
else -> MaterialTheme.colors.onSurface
},
)
if (showLeagueColumn) {
Text(match.leagueDetails?.name ?: "", style = MaterialTheme.typography.caption)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (match.isCompleted) {
Text("${match.homeMatchPoints}:${match.guestMatchPoints}", fontWeight = FontWeight.Bold)
} else {
Text("", color = MaterialTheme.colors.onSurface.copy(alpha = 0.5f))
}
}
}
}
}

View File

@@ -0,0 +1,295 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto
import de.tt_tagebuch.shared.api.models.canReadTournaments
import de.tt_tagebuch.shared.i18n.MobileStrings
import de.tt_tagebuch.shared.state.ClubTournamentDisplayFilter
private data class ParticipationFlatRow(
val tournamentId: String?,
val tournamentTitle: String?,
val entry: OfficialParticipationEntryDto,
)
private val TournamentsPad = 20.dp
private val TournamentsTouchMin = 48.dp
@Composable
internal fun TournamentsScreen(dependencies: AppDependencies) {
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 internalState by dependencies.clubInternalTournamentsManager.state.collectAsState()
val officialState by dependencies.officialTournamentsReadManager.state.collectAsState()
if (perms?.canReadTournaments() != true) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(TournamentsPad),
) {
Text(tr("mobile.noTournamentAccess", "Keine Berechtigung für Turniere."))
}
return
}
LaunchedEffect(clubId, internalState.filter) {
dependencies.clubInternalTournamentsManager.loadList(clubId)
}
LaunchedEffect(clubId, internalState.selectedId) {
val id = internalState.selectedId ?: return@LaunchedEffect
dependencies.clubInternalTournamentsManager.loadDetail(clubId, id)
}
LaunchedEffect(clubId) {
dependencies.officialTournamentsReadManager.load(clubId)
}
val participationFlatRows = remember(officialState.participationBuckets) {
officialState.participationBuckets.flatMap { bucket ->
bucket.entries.map { entry ->
ParticipationFlatRow(bucket.tournamentId, bucket.title, entry)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TournamentsPad, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
tr("navigation.clubTournaments", "Turniere"),
style = MaterialTheme.typography.h5,
fontWeight = FontWeight.SemiBold,
)
Text(
tr("mobile.tournamentsHubHint", "Vereins-Turniere und offizielle Meldelisten. Verwaltung im Browser."),
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 {
Text(tr("tournaments.internalTournaments", "Vereins-Turniere"), fontWeight = FontWeight.SemiBold)
Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) {
ModeFilterChip(
label = tr("mobile.tournamentFilterInternal", "Intern"),
selected = internalState.filter == ClubTournamentDisplayFilter.Internal,
onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Internal) },
)
ModeFilterChip(
label = tr("tournaments.openTournaments", "Offen"),
selected = internalState.filter == ClubTournamentDisplayFilter.External,
onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.External) },
)
ModeFilterChip(
label = tr("tournaments.miniChampionships", "Mini"),
selected = internalState.filter == ClubTournamentDisplayFilter.Mini,
onClick = { dependencies.clubInternalTournamentsManager.setFilter(ClubTournamentDisplayFilter.Mini) },
)
}
}
if (internalState.isLoadingList) {
item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp)) }
} else {
internalState.error?.let { err ->
item {
Text(err, color = MaterialTheme.colors.error)
}
}
if (internalState.tournaments.isEmpty()) {
item {
Text(
tr("mobile.noClubTournaments", "Keine Turniere in dieser Ansicht."),
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(vertical = 8.dp),
)
}
} else {
items(internalState.tournaments, key = { it.id }) { t ->
val selected = internalState.selectedId == t.id
Card(
modifier = Modifier.fillMaxWidth(),
elevation = if (selected) 2.dp else 1.dp,
backgroundColor = if (selected) {
MaterialTheme.colors.primary.copy(alpha = 0.08f)
} else {
MaterialTheme.colors.surface
},
) {
TextButton(
onClick = {
dependencies.clubInternalTournamentsManager.selectTournament(
if (selected) null else t.id,
)
},
modifier = Modifier.fillMaxWidth().heightIn(min = TournamentsTouchMin),
) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) {
Text(t.name ?: "Turnier #${t.id}", fontWeight = FontWeight.SemiBold)
t.date?.let { d -> Text(d, style = MaterialTheme.typography.caption) }
}
}
}
}
}
}
if (internalState.selectedId != null) {
item {
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(tr("mobile.tournamentDetails", "Details"), fontWeight = FontWeight.SemiBold)
when {
internalState.isLoadingDetail -> CircularProgressIndicator(modifier = Modifier.padding(8.dp))
internalState.detail != null -> {
val d = internalState.detail!!
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
d.name?.let { Text(it, fontWeight = FontWeight.Medium) }
d.date?.let { Text("${tr("tournaments.date", "Datum")}: $it") }
d.type?.takeIf { it.isNotBlank() }?.let { Text("${tr("mobile.mode", "Modus")}: $it") }
d.winningSets?.let { Text("${tr("tournaments.winningSets", "Gewinnsätze")}: $it") }
d.numberOfGroups?.let { Text("${tr("mobile.groups", "Gruppen")}: $it") }
d.numberOfTables?.let { Text("${tr("mobile.tables", "Tische")}: $it") }
if (d.miniChampionshipYear != null) {
Text("${tr("tournaments.miniChampionshipYear", "Minimeisterschaft-Jahr")}: ${d.miniChampionshipYear}")
}
if (d.allowsExternal == true) {
Text(tr("tournaments.openTournaments", "Offenes Turnier"))
}
if (d.isDoublesTournament == true) {
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
}
}
}
else -> Text(tr("mobile.tournamentDetailPending", "Details werden geladen …"))
}
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
Text(tr("officialTournaments.savedEvents", "Offizielle Turniere (Import)"), fontWeight = FontWeight.SemiBold)
}
if (officialState.isLoading) {
item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 8.dp)) }
} else {
officialState.error?.let { err ->
item { Text(err, color = MaterialTheme.colors.error) }
}
if (officialState.tournaments.isEmpty()) {
item {
Text(
tr("officialTournaments.noEvents", "Keine importierten Turniere."),
style = MaterialTheme.typography.body2,
)
}
} else {
items(officialState.tournaments, key = { it.id }) { ot ->
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(ot.title ?: "Turnier #${ot.id}", fontWeight = FontWeight.Medium)
ot.eventDate?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.caption)
}
}
Divider()
}
}
}
item {
Spacer(modifier = Modifier.height(12.dp))
Text(tr("officialTournaments.participations", "Teilnahmen (übersicht)"), fontWeight = FontWeight.SemiBold)
}
if (participationFlatRows.isEmpty() && !officialState.isLoading) {
item {
Text(
tr("mobile.noOfficialParticipations", "Keine erfassten Teilnahmen."),
style = MaterialTheme.typography.body2,
)
}
} else {
items(
participationFlatRows,
key = { r -> "${r.tournamentId}_${r.entry.memberId}_${r.entry.competitionId}_${r.entry.date}_${r.entry.competitionName}" },
) { r ->
ParticipationRow(tournamentTitle = r.tournamentTitle, entry = r.entry)
}
}
}
}
@Composable
@Composable
private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
TextButton(
onClick = onClick,
modifier = Modifier.heightIn(min = TournamentsTouchMin),
) {
Text(
label,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.onSurface,
)
}
}
@Composable
private fun ParticipationRow(tournamentTitle: String?, entry: OfficialParticipationEntryDto) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(
listOfNotNull(entry.memberName, tournamentTitle).joinToString(" · "),
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Medium,
)
Text(
listOfNotNull(entry.competitionName, entry.date, entry.placement?.let { p -> "Pl. $p" })
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
)
}
Divider()
}