feat: integrate new desktop shell and extend backend & ADRs

- Added `meldestelle-desktop` module using JVM/Compose Desktop, registered in `settings.gradle.kts`.
- Integrated new screens and desktop navigation into core: `Veranstaltungen`, `TurnierDetail`, etc.
- Expanded backend with `ExposedFunktionaerRepository` in `officials-infrastructure`.
- Completed ADRs for bounded context mapping (`ADR-0014`) and context map (`ADR-0015`).
- Updated and extended project documentation with session logs and architecture decisions.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-24 18:22:15 +01:00
parent c624df8744
commit 354bd49de6
75 changed files with 7616 additions and 48 deletions
@@ -0,0 +1,79 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
/**
* Shell-Modul: Meldestelle Desktop App
* Reines JVM/Compose-Desktop-Modul Desktop-First gemäß MASTER_ROADMAP.
* Setzt alle Core- und Feature-Module zu einer lauffähigen Desktop-Anwendung zusammen.
*/
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
jvmMain.dependencies {
// Core-Module
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.navigation)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.auth)
// Feature-Module
implementation(projects.frontend.features.nennungFeature)
implementation(projects.frontend.features.pingFeature)
// Compose Desktop
implementation(compose.desktop.currentOs)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.materialIconsExtended)
implementation(compose.uiTooling)
// DI (Koin)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Coroutines
implementation(libs.kotlinx.coroutines.swing)
// Bundles
implementation(libs.bundles.kmp.common)
implementation(libs.bundles.compose.common)
}
jvmTest.dependencies {
implementation(libs.kotlin.test)
}
}
}
compose.desktop {
application {
mainClass = "at.mocode.desktop.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Deb, TargetFormat.Msi, TargetFormat.Dmg)
packageName = "Meldestelle"
packageVersion = "1.0.0"
description = "ÖTO-konforme Turnier-Meldestelle Desktop App"
vendor = "mo-code.at"
linux {
iconFile.set(project.file("src/jvmMain/resources/icon.png"))
}
windows {
menuGroup = "Meldestelle"
upgradeUuid = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}
}
}
@@ -0,0 +1,70 @@
package at.mocode.desktop
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.desktop.screens.DesktopMainLayout
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel
import at.mocode.frontend.core.designsystem.theme.AppTheme
import at.mocode.frontend.core.navigation.AppScreen
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
/**
* Haupt-Composable der Desktop-App.
* Steuert Login-Gate und delegiert an DesktopMainLayout (Sidebar + Content).
*/
@Composable
fun DesktopApp() {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
val nav = koinInject<DesktopNavigationPort>()
val authTokenManager = koinInject<AuthTokenManager>()
val currentScreen by nav.currentScreen.collectAsState()
val loginViewModel: LoginViewModel = koinViewModel()
val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login
if (!authState.isAuthenticated && currentScreen !is AppScreen.Login) {
LaunchedEffect(Unit) {
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
}
}
when (val screen = currentScreen) {
is AppScreen.Login -> LoginScreen(
viewModel = loginViewModel,
onLoginSuccess = {
val returnTo = screen.returnTo ?: AppScreen.Veranstaltungen
nav.navigateToScreen(returnTo)
},
onBack = { /* Desktop hat keine Landing-Page */ },
)
else -> {
// Authentifiziert → Haupt-Layout mit Sidebar
DesktopMainLayout(
currentScreen = screen,
onNavigate = { nav.navigateToScreen(it) },
onLogout = {
authTokenManager.clearToken()
nav.navigateToScreen(AppScreen.Login(returnTo = AppScreen.Veranstaltungen))
},
)
}
}
}
}
}
@@ -0,0 +1,35 @@
package at.mocode.desktop.di
import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.domain.models.User
import at.mocode.frontend.core.navigation.CurrentUserProvider
import at.mocode.frontend.core.navigation.DeepLinkHandler
import at.mocode.frontend.core.navigation.NavigationPort
import org.koin.dsl.module
/**
* CurrentUserProvider-Implementierung für die Desktop-Shell.
* Liest den aktuellen Auth-State aus dem AuthTokenManager.
*/
class DesktopCurrentUserProvider(
private val authTokenManager: AuthTokenManager,
) : CurrentUserProvider {
override fun getCurrentUser(): User? {
val state = authTokenManager.authState.value
if (!state.isAuthenticated) return null
return User(
id = state.userId ?: state.username ?: "unknown",
username = state.username ?: state.userId ?: "unknown",
displayName = null,
roles = emptyList(),
)
}
}
val desktopModule = module {
single { DesktopNavigationPort() }
single<NavigationPort> { get<DesktopNavigationPort>() }
single<CurrentUserProvider> { DesktopCurrentUserProvider(get()) }
single { DeepLinkHandler(get(), get()) }
}
@@ -0,0 +1,56 @@
package at.mocode.desktop
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowState
import androidx.compose.ui.window.application
import at.mocode.desktop.di.desktopModule
import at.mocode.frontend.core.auth.di.authModule
import at.mocode.frontend.core.localdb.AppDatabase
import at.mocode.frontend.core.localdb.DatabaseProvider
import at.mocode.frontend.core.localdb.localDbModule
import at.mocode.frontend.core.network.networkModule
import at.mocode.frontend.core.sync.di.syncModule
import at.mocode.nennung.feature.di.nennungFeatureModule
import at.mocode.ping.feature.di.pingFeatureModule
import kotlinx.coroutines.runBlocking
import org.koin.core.context.GlobalContext
import org.koin.core.context.loadKoinModules
import org.koin.core.context.startKoin
import org.koin.dsl.module
fun main() = application {
try {
startKoin {
modules(
networkModule,
syncModule,
authModule,
localDbModule,
pingFeatureModule,
nennungFeatureModule,
desktopModule,
)
}
println("[DesktopApp] Koin initialisiert")
} catch (e: Exception) {
println("[DesktopApp] Koin-Warnung: ${e.message}")
}
try {
val provider = GlobalContext.get().get<DatabaseProvider>()
val db = runBlocking { provider.createDatabase() }
loadKoinModules(module { single<AppDatabase> { db } })
println("[DesktopApp] Lokale DB bereit")
} catch (e: Exception) {
println("[DesktopApp] DB-Warnung: ${e.message}")
}
Window(
onCloseRequest = ::exitApplication,
title = "Meldestelle",
state = WindowState(width = 1400.dp, height = 900.dp),
) {
DesktopApp()
}
}
@@ -0,0 +1,27 @@
package at.mocode.desktop.navigation
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.frontend.core.navigation.NavigationPort
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* NavigationPort-Implementierung für die Desktop-Shell.
* Hält den aktuellen Screen als StateFlow, den DesktopApp beobachtet.
*/
class DesktopNavigationPort : NavigationPort {
private val _currentScreen = MutableStateFlow<AppScreen>(AppScreen.Login())
val currentScreen: StateFlow<AppScreen> = _currentScreen.asStateFlow()
override fun navigateTo(route: String) {
val screen = AppScreen.fromRoute(route)
println("[DesktopNav] navigateTo $route -> $screen")
_currentScreen.value = screen
}
fun navigateToScreen(screen: AppScreen) {
println("[DesktopNav] navigateToScreen -> $screen")
_currentScreen.value = screen
}
}
@@ -0,0 +1,72 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Placeholder-Screens für Akteur-Verwaltung (actor-context).
* Werden in Phase 4/5 mit echten Daten aus dem actor-context befüllt.
*/
@Composable
fun ReiterScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Reiter", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Reiter-Verwaltung",
subtitle = "Satznummer, Lizenzklasse, Sparten-Lizenz actor-context (Phase 4).",
)
}
}
@Composable
fun PferdeScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Pferde", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Pferde-Verwaltung",
subtitle = "Lebensnummer, ZNS-Daten, Passbesitzer actor-context (Phase 4).",
)
}
}
@Composable
fun FunktionaereScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Funktionäre", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Funktionäre-Verwaltung",
subtitle = "Richter, Parcourschef, Tierarzt actor-context (Phase 4).",
)
}
}
@Composable
fun MeisterschaftenScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Meisterschaften", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Meisterschaften",
subtitle = "Konfigurierbare Reglements, Punktesysteme series-context (Phase 2+).",
)
}
}
@Composable
fun CupsScreen() {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text("Cups", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(24.dp))
PlaceholderContent(
title = "Cups & Serien",
subtitle = "Pluggable Berechnungsmodell, Paar-Bindung series-context (Phase 2+).",
)
}
}
@@ -0,0 +1,230 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.navigation.AppScreen
import at.mocode.nennung.feature.presentation.NennungViewModel
import org.koin.compose.viewmodel.koinViewModel
/**
* Haupt-Layout der Desktop-App gemäß Vision_03.
* Sidebar (links) + Content-Bereich (rechts).
*/
@Composable
fun DesktopMainLayout(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onLogout: () -> Unit,
) {
Row(modifier = Modifier.fillMaxSize()) {
DesktopSidebar(
currentScreen = currentScreen,
onNavigate = onNavigate,
onLogout = onLogout,
)
HorizontalDivider(
modifier = Modifier.fillMaxHeight().width(1.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
Box(modifier = Modifier.fillMaxSize()) {
DesktopContentArea(
currentScreen = currentScreen,
onNavigate = onNavigate,
)
}
}
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
private data class NavItem(
val label: String,
val icon: ImageVector,
val screen: AppScreen,
)
private val navItems = listOf(
NavItem("Veranstaltungen", Icons.Default.Event, AppScreen.Veranstaltungen),
NavItem("Reiter", Icons.Default.Person, AppScreen.Reiter),
NavItem("Pferde", Icons.Default.Star, AppScreen.Pferde),
NavItem("Funktionäre", Icons.Default.Badge, AppScreen.Funktionaere),
NavItem("Meisterschaften", Icons.Default.EmojiEvents, AppScreen.Meisterschaften),
NavItem("Cups", Icons.Default.WorkspacePremium, AppScreen.Cups),
)
@Composable
private fun DesktopSidebar(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
onLogout: () -> Unit,
) {
Column(
modifier = Modifier
.width(220.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(vertical = 16.dp),
) {
// App-Titel
Text(
text = "Meldestelle",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(modifier = Modifier.height(8.dp))
// Navigations-Einträge
navItems.forEach { item ->
val isSelected = currentScreen::class == item.screen::class
SidebarNavItem(
item = item,
isSelected = isSelected,
onClick = { onNavigate(item.screen) },
)
}
Spacer(modifier = Modifier.weight(1f))
HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp))
Spacer(modifier = Modifier.height(8.dp))
// Logout
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onLogout() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = "Logout",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Logout",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
}
@Composable
private fun SidebarNavItem(
item: NavItem,
isSelected: Boolean,
onClick: () -> Unit,
) {
val bgColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isSelected)
MaterialTheme.colorScheme.onPrimaryContainer
else
MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.background(bgColor, RoundedCornerShape(8.dp))
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = item.icon,
contentDescription = item.label,
tint = contentColor,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = item.label,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
),
color = contentColor,
)
}
}
// ---------------------------------------------------------------------------
// Content-Bereich: Screen-Routing
// ---------------------------------------------------------------------------
@Composable
private fun DesktopContentArea(
currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit,
) {
val nennungViewModel: NennungViewModel = koinViewModel()
when (currentScreen) {
is AppScreen.Veranstaltungen -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
)
is AppScreen.VeranstaltungNeu -> VeranstaltungNeuScreen(
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onSave = { onNavigate(AppScreen.Veranstaltungen) },
)
is AppScreen.VeranstaltungDetail -> VeranstaltungDetailScreen(
veranstaltungId = currentScreen.id,
onBack = { onNavigate(AppScreen.Veranstaltungen) },
onTurnierNeu = { onNavigate(AppScreen.TurnierNeu(currentScreen.id)) },
onTurnierOeffnen = { turnierId -> onNavigate(AppScreen.TurnierDetail(currentScreen.id, turnierId)) },
)
is AppScreen.TurnierNeu -> TurnierNeuScreen(
veranstaltungId = currentScreen.veranstaltungId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
onSave = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
)
is AppScreen.TurnierDetail -> TurnierDetailScreen(
veranstaltungId = currentScreen.veranstaltungId,
turnierId = currentScreen.turnierId,
onBack = { onNavigate(AppScreen.VeranstaltungDetail(currentScreen.veranstaltungId)) },
nennungViewModel = nennungViewModel,
)
is AppScreen.Reiter -> ReiterScreen()
is AppScreen.Pferde -> PferdeScreen()
is AppScreen.Funktionaere -> FunktionaereScreen()
is AppScreen.Meisterschaften -> MeisterschaftenScreen()
is AppScreen.Cups -> CupsScreen()
// Fallback für alle anderen Screens (Dashboard, Ping etc.)
else -> VeranstaltungenScreen(
onVeranstaltungNeu = { onNavigate(AppScreen.VeranstaltungNeu) },
onVeranstaltungOeffnen = { id -> onNavigate(AppScreen.VeranstaltungDetail(id)) },
)
}
}
@@ -0,0 +1,47 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Wiederverwendbarer Platzhalter für Screens, die noch nicht implementiert sind.
*/
@Composable
fun PlaceholderContent(
title: String,
subtitle: String = "Wird in einer späteren Phase implementiert.",
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Construction,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.outlineVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
@@ -0,0 +1,84 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.nennung.feature.presentation.NennungViewModel
import at.mocode.nennung.feature.presentation.NennungsMaske
/**
* Detailansicht eines bestehenden Turniers (Vision_03: /veranstaltung/{id}/turnier/{tid}).
* Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe ⭐ | Preisliste
* Der Bewerbe-Tab integriert die NennungsMaske aus dem nennung-feature.
*/
@Composable
fun TurnierDetailScreen(
veranstaltungId: Long,
turnierId: Long,
onBack: () -> Unit,
nennungViewModel: NennungViewModel,
) {
var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐)
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Bewerbe ⭐", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Turnier #$turnierId",
style = MaterialTheme.typography.headlineSmall,
)
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize()) {
when (selectedTab) {
0 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Übersicht", "Turnier-Stammdaten und Status.")
}
1 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Stammdaten (A-Satz)", "OEPS-Turniernummer, Kategorie, Sparte …")
}
2 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …")
}
3 -> {
// Nennungs-Workflow: NennungsMaske aus nennung-feature
NennungsMaske(
viewModel = nennungViewModel,
onStartlisteOeffnen = { /* TODO: Navigation zu Startliste */ },
onErgebnisseOeffnen = { /* TODO: Navigation zu Ergebnisse */ },
onAbrechnungOeffnen = { /* TODO: Navigation zu Abrechnung */ },
)
}
4 -> Box(Modifier.padding(24.dp)) {
PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
}
@@ -0,0 +1,64 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Formular zum Anlegen eines neuen Turniers (Vision_03: /veranstaltung/{id}/turnier/neu).
* Tabs: Übersicht | Stammdaten (A-Satz) | Organisation | Bewerbe ⭐ | Preisliste
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
*/
@Composable
fun TurnierNeuScreen(
veranstaltungId: Long,
onBack: () -> Unit,
onSave: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(3) } // Bewerbe ist Standard-Tab (⭐)
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Bewerbe ⭐", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Neues Turnier (Veranstaltung #$veranstaltungId)",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.alignByBaseline(),
)
}
Button(onClick = onSave) { Text("Speichern") }
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
when (selectedTab) {
0 -> PlaceholderContent("Übersicht", "Wird nach dem Speichern befüllt.")
1 -> PlaceholderContent("Stammdaten (A-Satz)", "OEPS-Turniernummer, Kategorie, Sparte …")
2 -> PlaceholderContent("Organisation", "Richter, Parcourschef, Tierarzt …")
3 -> PlaceholderContent("Bewerbe", "Bewerbe anlegen und Abteilungen konfigurieren …")
4 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
@@ -0,0 +1,66 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Detailansicht einer bestehenden Veranstaltung (Vision_03: /veranstaltung/{id}).
* Zeigt Übersicht-Tab mit Turniere-Section.
* TODO: Echte Daten laden (Phase 4/5).
*/
@Composable
fun VeranstaltungDetailScreen(
veranstaltungId: Long,
onBack: () -> Unit,
onTurnierNeu: () -> Unit,
onTurnierOeffnen: (Long) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Veranstaltung #$veranstaltungId",
style = MaterialTheme.typography.headlineSmall,
)
}
PrimaryTabRow(selectedTabIndex = 0) {
Tab(selected = true, onClick = {}, text = { Text("Veranstaltung Übersicht") })
}
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
// Turniere-Section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("Turniere", style = MaterialTheme.typography.titleMedium)
OutlinedButton(onClick = onTurnierNeu) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text("Neues Turnier")
}
}
Spacer(Modifier.height(16.dp))
PlaceholderContent(
title = "Noch keine Turniere",
subtitle = "Lege ein neues Turnier für diese Veranstaltung an.",
)
}
}
}
@@ -0,0 +1,63 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Formular zum Anlegen einer neuen Veranstaltung (Vision_03: /veranstaltung/neu).
* Tabs: Veranstaltung-Übersicht | Stammdaten (A-Satz) | Organisation | Preisliste
* TODO: Echte Formular-Felder und Persistenz (Phase 4/5).
*/
@Composable
fun VeranstaltungNeuScreen(
onBack: () -> Unit,
onSave: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(1) } // Stammdaten ist Standard-Tab
val tabs = listOf("Übersicht", "Stammdaten (A-Satz)", "Organisation", "Preisliste")
Column(modifier = Modifier.fillMaxSize()) {
// Toolbar
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
}
Spacer(Modifier.width(8.dp))
Text(
text = "Neue Veranstaltung",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.alignByBaseline(),
)
}
Button(onClick = onSave) { Text("Speichern") }
}
PrimaryTabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(title) },
)
}
}
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
when (selectedTab) {
0 -> PlaceholderContent("Veranstaltung Übersicht", "Wird nach dem Speichern befüllt.")
1 -> PlaceholderContent("Stammdaten (A-Satz)", "Felder: Bezeichnung, Datum, Ort, Veranstalter …")
2 -> PlaceholderContent("Organisation", "Felder: Richter, Parcourschef, Tierarzt …")
3 -> PlaceholderContent("Preisliste", "Nenngebühren pro Bewerb/Sparte …")
}
}
}
}
@@ -0,0 +1,50 @@
package at.mocode.desktop.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Veranstaltungs-Übersicht (Drawer-Einstieg gemäß Vision_03).
* Zeigt Liste aller Veranstaltungen + Button "Neue Veranstaltung".
* TODO: Echte Daten aus dem event-management-context laden (Phase 4/5).
*/
@Composable
fun VeranstaltungenScreen(
onVeranstaltungNeu: () -> Unit,
onVeranstaltungOeffnen: (Long) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Veranstaltungen",
style = MaterialTheme.typography.headlineMedium,
)
Button(onClick = onVeranstaltungNeu) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Neue Veranstaltung")
}
}
Spacer(modifier = Modifier.height(24.dp))
// Platzhalter wird durch echte Daten ersetzt
PlaceholderContent(
title = "Noch keine Veranstaltungen",
subtitle = "Lege eine neue Veranstaltung an, um zu beginnen.",
)
}
}
@@ -155,6 +155,8 @@ fun MainApp() {
}
}
)
else -> {}
}
}
}