implement Details View UI for tournaments

This commit is contained in:
David Rodenkirchen 2026-02-02 08:52:38 +01:00
parent b6ef2b5995
commit 1137a9e7c7
7 changed files with 275 additions and 37 deletions

View File

@ -0,0 +1,33 @@
from typing import Optional
from rio import Component, Row, Text, TextStyle, Color
class TournamentDetailsInfoRow(Component):
key: str
value: str
color: Optional[Color] = None
def build(self) -> Component:
return Row(
Text(
text=self.key,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_bottom=0.5,
align_x=0
),
Text(
text=self.value,
style=TextStyle(
fill=self.color if self.color is not None else self.session.theme.background_color,
font_size=1
),
margin_bottom=0.5,
align_x=1
),
margin_left=4,
margin_right=4
)

View File

@ -1,16 +1,26 @@
from typing import Optional
from typing import Optional, Union, Literal
from rio import Column, Component, event, TextStyle, Text, Rectangle, Row, Image, Icon, Tooltip, Spacer, Color, PointerEventListener, ProgressCircle
from from_root import from_root
from rio import Column, Component, event, TextStyle, Text, Row, Image, Spacer, ProgressCircle, Button, Checkbox, ThemeContextSwitcher, Link
from src.ezgg_lan_manager import ConfigurationService, TournamentService
from src.ezgg_lan_manager import ConfigurationService, TournamentService, UserService
from src.ezgg_lan_manager.components.MainViewContentBox import MainViewContentBox
from src.ezgg_lan_manager.components.TournamentPageRow import TournamentPageRow
from src.ezgg_lan_manager.components.TournamentDetailsInfoRow import TournamentDetailsInfoRow
from src.ezgg_lan_manager.types.DateUtil import weekday_to_display_text
from src.ezgg_lan_manager.types.SessionStorage import SessionStorage
from src.ezgg_lan_manager.types.Tournament import Tournament
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus
from src.ezgg_lan_manager.types.TournamentBase import TournamentStatus, tournament_status_to_display_text, tournament_format_to_display_texts
from src.ezgg_lan_manager.types.User import User
class TournamentDetailsPage(Component):
tournament: Optional[Tournament] = None
tournament: Optional[Union[Tournament, str]] = None
rules_accepted: bool = False
user: Optional[User] = None
loading: bool = False
# State for message above register button
message: str = ""
is_success: bool = False
@event.on_populate
async def on_populate(self) -> None:
@ -20,44 +30,166 @@ class TournamentDetailsPage(Component):
tournament_id = None
if tournament_id is not None:
self.tournament = await self.session[TournamentService].get_tournament_by_id(tournament_id)
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}")
if self.tournament is not None:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - {self.tournament.name}")
else:
await self.session.set_title(f"{self.session[ConfigurationService].get_lan_info().name} - Turniere")
self.user = await self.session[UserService].get_user(self.session[SessionStorage].user_id)
self.loading_done()
async def register_pressed(self) -> None:
self.loading = True
if not self.user:
return
self.is_success = True
self.message = f"Erfolgreich angemeldet!" # ToDo: Hook into Tournament Service
self.loading = False
async def unregister_pressed(self) -> None:
self.loading = True
if not self.user:
return
self.is_success = True
self.message = f"Erfolgreich abgemeldet!" # ToDo: Hook into Tournament Service
self.loading = False
def loading_done(self) -> None:
if self.tournament is None:
self.tournament = "Turnier konnte nicht gefunden werden"
self.session[SessionStorage].subscribe_to_logged_in_or_out_event(str(self.__class__), self.on_populate)
def build(self) -> Component:
if not self.tournament:
return Column(
MainViewContentBox(
Column(
Column(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=0,
margin_bottom=0
),
min_height=10
),
Spacer(min_height=1)
)
if self.tournament is None:
content = Column(
ProgressCircle(
color="secondary",
align_x=0.5,
margin_top=0,
margin_bottom=0
),
align_y = 0
min_height=10
)
elif isinstance(self.tournament, str):
content = Row(
Text(
text=self.tournament,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1
),
margin_top=2,
margin_bottom=2,
align_x=0.5
)
)
else:
tournament_status_color = self.session.theme.background_color
if self.tournament.status == TournamentStatus.OPEN:
tournament_status_color = self.session.theme.success_color
elif self.tournament.status == TournamentStatus.CLOSED:
tournament_status_color = self.session.theme.danger_color
elif self.tournament.status == TournamentStatus.ONGOING:
tournament_status_color = self.session.theme.warning_color
return Column(
MainViewContentBox(
Column(
# ToDo: Integrate Teams logic
ids_of_participants = [p.id for p in self.tournament.participants]
color_key: Literal["hud", "danger"] = "hud"
on_press_function = self.register_pressed
if self.user and self.user.user_id in ids_of_participants: # User already registered for tournament
button_text = "Abmelden"
button_sensitive_hook = True # User has already accepted the rules previously
color_key = "danger"
on_press_function = self.unregister_pressed
elif self.user and self.user.user_id not in ids_of_participants:
button_text = "Anmelden"
button_sensitive_hook = self.rules_accepted
else:
# This should NEVER happen
button_text = "Anmelden"
button_sensitive_hook = False
if self.tournament.status != TournamentStatus.OPEN or self.tournament.is_full:
button_sensitive_hook = False # Override button controls if tournament is not open anymore or full
if self.user:
accept_rules_row = Row(
ThemeContextSwitcher(content=Checkbox(is_on=self.bind().rules_accepted, margin_left=4), color=self.session.theme.hud_color),
Text("Ich akzeptiere die ", margin_left=1, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8), overflow="nowrap", justify="right"),
Link(Text("Turnierregeln", margin_right=4, style=TextStyle(fill=self.session.theme.background_color, font_size=0.8, italic=True), overflow="nowrap", justify="left"), "https://google.de") # ToDo: Add rules
)
button = Button(
content=button_text,
shape="rectangle",
style="major",
color=color_key,
margin_left=2,
margin_right=2,
is_sensitive=button_sensitive_hook,
on_press=on_press_function,
is_loading=self.loading
)
else:
# No UI here if user not logged in
accept_rules_row, button = Spacer(), Spacer()
content = Column(
Row(
Image(image=from_root(f"src/ezgg_lan_manager/assets/img/games/{self.tournament.game_title.image_name}")),
Text(
text=self.tournament.name,
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=2,
margin_bottom=2,
margin_top=1,
margin_bottom=1,
align_x=0.5
),
margin_right=6,
margin_left=6
),
Spacer(min_height=1),
TournamentDetailsInfoRow("Status", tournament_status_to_display_text(self.tournament.status), tournament_status_color),
TournamentDetailsInfoRow("Startzeit", f"{weekday_to_display_text(self.tournament.start_time.weekday())}, {self.tournament.start_time.strftime('%H:%M')} Uhr"),
TournamentDetailsInfoRow("Format", tournament_format_to_display_texts(self.tournament.format)[0]),
TournamentDetailsInfoRow("Best of", tournament_format_to_display_texts(self.tournament.format)[1]),
TournamentDetailsInfoRow(
"Teilnehmer",
f"{len(self.tournament.participants)} / {self.tournament.max_participants}",
self.session.theme.danger_color if self.tournament.is_full else self.session.theme.background_color
),
Row(
Text(
text="Info",
style=TextStyle(
fill=self.session.theme.background_color,
font_size=1.2
),
margin_top=1,
margin_bottom=1,
align_x=0.5
)
),
# FixMe: Use rio.Markdown with correct TextStyle instead to allow basic text formatting from DB-side.
Text(self.tournament.description, margin_left=2, margin_right=2, style=TextStyle(fill=self.session.theme.background_color, font_size=1), overflow="wrap"),
Spacer(min_height=2),
accept_rules_row,
Spacer(min_height=0.5),
Text(self.message, margin_left=2, margin_right=2, style=TextStyle(fill=self.session.theme.success_color if self.is_success else self.session.theme.danger_color, font_size=1), overflow="wrap", justify="center"),
Spacer(min_height=0.5),
button
)
return Column(
MainViewContentBox(
Column(
Spacer(min_height=1),
content,
Spacer(min_height=1)
)
),

View File

@ -4,8 +4,17 @@ from typing import Optional
from src.ezgg_lan_manager.services.DatabaseService import DatabaseService
from src.ezgg_lan_manager.services.UserService import UserService
from src.ezgg_lan_manager.types.Participant import Participant
from src.ezgg_lan_manager.types.Tournament import Tournament
from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus
from src.ezgg_lan_manager.types.TournamentBase import GameTitle, TournamentFormat, TournamentStatus, ParticipantType
DEV_LOREM_IPSUM = """
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Dieses LAN-Turnier bringt Spieler aus verschiedenen Regionen zusammen, um gemeinsam spannende Matches zu erleben. Tastaturen klappern, Monitore leuchten und die Stimmung ist von Anfang an von Vorfreude und Ehrgeiz geprägt.
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. In intensiven Partien zählen Strategie, Reaktion und Teamarbeit. Zwischen den Spielen wird gefachsimpelt, gelacht und neue Kontakte entstehen, während Server stabil laufen und das Netzwerk dauerhaft gefordert ist.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Wenn die Finalspiele beginnen, steigt die Spannung spürbar. Am Ende bleiben faire Wettkämpfe, gemeinsame Erinnerungen und das Gefühl, Teil einer starken Community gewesen zu sein.
"""
class TournamentService:
@ -19,6 +28,7 @@ class TournamentService:
Tournament(
0,
"Teeworlds 2vs2",
DEV_LOREM_IPSUM,
GameTitle(
"Teeworlds",
"Teeworlds is a free online multiplayer game, available for all major operating systems. Battle with up to 16 players in a variety of game modes.",
@ -36,6 +46,7 @@ class TournamentService:
Tournament(
1,
"Rocket League 3vs3",
DEV_LOREM_IPSUM,
GameTitle(
"Rocket League",
"Rocket League is a high-powered hybrid of arcade-style soccer and vehicular mayhem with easy-to-understand controls and fluid, physics-driven competition.",
@ -45,7 +56,7 @@ class TournamentService:
TournamentFormat.SINGLE_ELIMINATION_BO_3,
datetime(2026, 5, 8, 18, 0, 0),
TournamentStatus.OPEN,
[],
[Participant(30, "Typhus", ParticipantType.PLAYER)],
None,
[],
8
@ -53,6 +64,7 @@ class TournamentService:
Tournament(
2,
"Worms Armageddon 1vs1",
DEV_LOREM_IPSUM,
GameTitle(
"Worms Armageddon",
"2D turn-based artillery strategy game.",
@ -61,7 +73,7 @@ class TournamentService:
),
TournamentFormat.SINGLE_ELIMINATION_BO_1,
datetime(2026, 5, 8, 18, 30, 0),
TournamentStatus.OPEN,
TournamentStatus.CLOSED,
[],
None,
[],

View File

@ -0,0 +1,15 @@
def weekday_to_display_text(weekday: int) -> str:
if weekday == 0:
return "Montag"
elif weekday == 1:
return "Dienstag"
elif weekday == 2:
return "Mittwoch"
elif weekday == 3:
return "Donnerstag"
elif weekday == 4:
return "Freitag"
elif weekday == 5:
return "Samstag"
else:
return "Sonntag"

View File

@ -12,6 +12,7 @@ class Tournament:
def __init__(self,
id_: int,
name: str,
description: str,
game_title: GameTitle,
format_: TournamentFormat,
start_time: datetime,
@ -22,6 +23,7 @@ class Tournament:
max_participants: int) -> None:
self._id = id_
self._name = name
self._description = description
self._game_title = game_title
self._format = format_
self._start_time = start_time
@ -75,6 +77,14 @@ class Tournament:
def max_participants(self) -> int:
return self._max_participants
@property
def description(self) -> str:
return self._description
@property
def is_full(self) -> bool:
return len(self._participants) >= self._max_participants
def add_participant(self, participant: Participant) -> None:
if participant.id in (p.id for p in self._participants):
raise TournamentError(f"Participant with ID {participant.id} already registered for tournament")
@ -310,16 +320,18 @@ class Tournament:
match.check_completion()
def generate_new_tournament(name: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament:
def generate_new_tournament(name: str, description: str, game_title: GameTitle, format_: TournamentFormat, start_time: datetime, max_participants: int, initial_status: TournamentStatus = TournamentStatus.CLOSED) -> Tournament:
id_ = uuid.uuid4().int
return Tournament(
id_,
name,
description,
game_title,
format_,
start_time,
initial_status,
list(),
None,
list()
list(),
max_participants
)

View File

@ -17,6 +17,23 @@ class TournamentFormat(Enum):
DOUBLE_ELIMINATION_BO_3 = 5
DOUBLE_ELIMINATION_BO_5 = 6
def tournament_format_to_display_texts(tournament_format: TournamentFormat) -> tuple[str, str]:
""" Returns tuple where idx 0 is SE/DE string and idx 1 is match count """
if tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_1:
return "Single Elimination", "1"
elif tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_3:
return "Single Elimination", "3"
elif tournament_format == TournamentFormat.SINGLE_ELIMINATION_BO_5:
return "Single Elimination", "5"
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_1:
return "Double Elimination", "1"
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_3:
return "Double Elimination", "3"
elif tournament_format == TournamentFormat.DOUBLE_ELIMINATION_BO_5:
return "Double Elimination", "5"
else:
raise RuntimeError(f"Unknown tournament status: {str(tournament_format)}")
class TournamentStatus(Enum):
CLOSED = 1
@ -26,6 +43,22 @@ class TournamentStatus(Enum):
INVITE_ONLY = 5 # For Show-matches
ONGOING = 6
def tournament_status_to_display_text(tournament_status: TournamentStatus) -> str:
if tournament_status == TournamentStatus.OPEN:
return "Offen"
elif tournament_status == TournamentStatus.CLOSED:
return "Geschlossen"
elif tournament_status == TournamentStatus.ONGOING:
return "Läuft"
elif tournament_status == TournamentStatus.COMPLETED:
return "Abgeschlossen"
elif tournament_status == TournamentStatus.CANCELED:
return "Abgesagt"
elif tournament_status == TournamentStatus.INVITE_ONLY:
return "Invite-only"
else:
raise RuntimeError(f"Unknown tournament status: {str(tournament_status)}")
class TournamentError(Exception):
def __init__(self, message: str) -> None:

View File

@ -9,7 +9,8 @@ class TournamentDomainTests(unittest.TestCase):
def setUp(self):
# Generic Tournament config
self.name = "Tetris 1vs1"
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99")
self.description = "Just play Tetris, yo"
self.game_title = GameTitle("Tetris99", "Some Description", "https://de.wikipedia.org/wiki/Tetris_99", "tetris.png")
self.format_ = TournamentFormat.SINGLE_ELIMINATION_BO_3
self.start_time = datetime(year=2100, month=6, day=23, hour=16, minute=30, second=0)
self.initial_status = TournamentStatus.CLOSED
@ -20,18 +21,18 @@ class TournamentDomainTests(unittest.TestCase):
self.participant_c = Participant(3, "FunnyUserName", ParticipantType.PLAYER)
def test_tournament_without_participants_can_not_be_started(self) -> None:
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status)
tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
with self.assertRaises(TournamentError):
tournament_under_test.start()
def test_adding_the_same_participant_twice_leads_to_exception(self) -> None:
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status)
tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
tournament_under_test.add_participant(self.participant_a)
with self.assertRaises(TournamentError):
tournament_under_test.add_participant(self.participant_a)
def test_single_elimination_bo3_tournament_gets_generated_correctly(self) -> None:
tournament_under_test = generate_new_tournament(self.name, self.game_title, self.format_, self.start_time, self.initial_status)
tournament_under_test = generate_new_tournament(self.name, self.description, self.game_title, self.format_, self.start_time, 32, self.initial_status)
tournament_under_test.add_participant(self.participant_a)
tournament_under_test.add_participant(self.participant_b)