Tech-Debt-Cleanup: Doku, Security-Hardening, Server-Validierung, Client-Stabilitaet #2

Merged
tim merged 4 commits from feature/techdebt-cleanup into master 2026-09-01 19:00:25 +00:00
26 changed files with 588 additions and 158 deletions
Showing only changes of commit af6528ee68 - Show all commits

26
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Build
on:
push:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Restore
run: dotnet restore SlipItIn.slnx
- name: Build
run: dotnet build SlipItIn.slnx --no-restore --configuration Release

2
.gitignore vendored
View File

@@ -361,6 +361,8 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# Legacy: urspruenglich SQLite-Dateien — die Anwendung nutzt inzwischen PostgreSQL (via Aspire).
# Eintraege bleiben als Sicherheitsnetz, falls lokal noch alte DB-Dateien existieren.
/SlipItIn.Server/slipitIn.db
/SlipItIn.Server/slipitIn.db-shm
/SlipItIn.Server/slipitIn.db-wal

View File

@@ -1 +1,53 @@
# SlipItIn
# SlipItIn
Ein Echtzeit-Multiplayer-Partyspiel: Jeder Spieler erhält Phrasen-Karten und versucht, sie unbemerkt in die laufende Runde zu "slippen". Wer einen fremden Slip erkennt, kann ihn anfechten — bei einer falschen Beschuldigung wandert die Phrase allerdings zum Beschuldiger.
## Tech-Stack
- **Client:** .NET MAUI (.NET 10) mit CommunityToolkit.Mvvm und SignalR-Client
- **Server:** ASP.NET Core 10 Web API + SignalR, EF Core mit PostgreSQL
- **Orchestrierung:** .NET Aspire (`SlipItIn.AppHost`) startet Server inklusive PostgreSQL-Container
- **Shared:** Gemeinsame Modelle und DTOs in `SlipItIn.Shared`
## Voraussetzungen
- .NET 10 SDK
- Docker oder Podman (für den PostgreSQL-Container, den Aspire bereitstellt)
- MAUI-Workload: `dotnet workload install maui`
- Für den Server allein genügt die Aspire-Workload-Unterstützung des SDKs
## Ausführen
```powershell
dotnet run --project SlipItIn.AppHost/SlipItIn.AppHost.csproj
```
Der AppHost startet PostgreSQL (Container), wendet EF-Migrationen automatisch an und seedet Testdaten. Die MAUI-App verbindet sich standardmäßig mit `https://localhost:7274` (Android-Emulator: `http://10.0.2.2:5234`).
## Konfiguration
| Key | Zweck |
| --- | --- |
| `Jwt:Key` | Signaturschlüssel für JWT (mindestens 32 Zeichen). **Nicht committen** — via User-Secrets oder Umgebungsvariable setzen. In Development ist ein Fallback-Key hinterlegt; in Production bricht der Server ohne konfigurierten Key den Start ab. |
| `Jwt:Issuer` / `Jwt:Audience` | Token-Aussteller bzw. -Empfänger (Defaults: `SlipItInServer` / `SlipItInClient`) |
| `ConnectionStrings:postgresdb` | Wird lokal von Aspire injiziert; muss nicht manuell gesetzt werden |
## Projektstruktur
- `SlipItIn/` — MAUI-Client (Views, ViewModels, Services, Offline-Queue)
- `SlipItIn.Server/` — Web API, SignalR-Hub, EF Core, Migrations
- `SlipItIn.Shared/` — Datenbank-Entitäten und SignalR-DTOs
- `SlipItIn.AppHost/` — Aspire-Orchestrierung
- `SlipItIn.ServiceDefaults/` — Geteilte Aspire-Service-Defaults
- `openwiki/` — Generierte Dokumentation (wird automatisch aktualisiert, bitte nicht von Hand editieren)
## Build
```powershell
dotnet build SlipItIn.slnx
```
## Hinweise
- Dokumentationssprache: Handgepflegte Dateien (diese README) sind Deutsch, die generierte OpenWiki-Dokumentation ist Englisch.
- `.gitignore` enthält noch Einträge für `slipitIn.db` — ein Überbleibsel aus der ursprünglichen SQLite-Planung. Die Anwendung nutzt PostgreSQL.

View File

@@ -1,9 +0,0 @@
<Solution>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>
</Solution>

View File

@@ -17,11 +17,13 @@ public class AuthController : ControllerBase
{
private readonly IDbContextFactory<SlipItInDbContext> _contextFactory;
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _environment;
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration)
public AuthController(IDbContextFactory<SlipItInDbContext> contextFactory, IConfiguration configuration, IHostEnvironment environment)
{
_contextFactory = contextFactory;
_configuration = configuration;
_environment = environment;
}
[HttpPost("register")]
@@ -30,6 +32,9 @@ public class AuthController : ControllerBase
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest("Username, Email und Passwort sind erforderlich.");
if (request.Password.Length < 8)
return BadRequest("Das Passwort muss mindestens 8 Zeichen lang sein.");
using var context = _contextFactory.CreateDbContext();
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
@@ -86,7 +91,7 @@ public class AuthController : ControllerBase
return Ok(new
{
UserId = int.Parse(userIdClaim),
UserId = int.TryParse(userIdClaim, out var parsedUserId) ? parsedUserId : 0,
Username = username,
Email = email
});
@@ -94,7 +99,13 @@ public class AuthController : ControllerBase
private AuthResponseDto CreateAuthResponse(User user)
{
var jwtKey = _configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtKey = _configuration["Jwt:Key"];
if (string.IsNullOrWhiteSpace(jwtKey))
{
if (!_environment.IsDevelopment())
throw new InvalidOperationException("Jwt:Key ist nicht konfiguriert.");
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
}
var jwtIssuer = _configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = _configuration["Jwt:Audience"] ?? "SlipItInClient";
var expirationMinutes = int.TryParse(_configuration["Jwt:ExpirationMinutes"], out var parsedMinutes) ? parsedMinutes : 1440;

View File

@@ -142,6 +142,7 @@ public class GameHub : Hub
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler mit festen, harmonsierten Texten dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
@@ -242,6 +243,11 @@ public class GameHub : Hub
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler (z. B. "Round is not active") dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error submitting slip");
@@ -259,10 +265,16 @@ public class GameHub : Hub
var challenge = await _gameService.CreateChallengeAsync(gameId, challengingPlayerId, targetCardId);
var game = await _gameService.GetGameAsync(gameId);
using var context = _contextFactory.CreateDbContext();
var challengingPlayer = await context.Players
.Include(p => p.User)
.FirstOrDefaultAsync(p => p.Id == challengingPlayerId);
var challengeDto = new SlipChallengeDto
{
ChallengeId = challenge.Id,
ChallengingPlayerId = challengingPlayerId,
ChallengingPlayerName = challengingPlayer?.User.Username ?? string.Empty,
TargetPlayerId = challenge.TargetPlayerId,
TargetCardId = targetCardId,
Status = challenge.Status.ToString(),
@@ -272,7 +284,6 @@ public class GameHub : Hub
await Clients.Group(game.LobbyCode).SendAsync("SlipChallenged", challengeDto);
// Sende direkte Notification an den beschuldigten Spieler
using var context = _contextFactory.CreateDbContext();
var targetPlayer = await context.Players.FindAsync(challenge.TargetPlayerId);
if (targetPlayer?.ConnectionId != null)
{
@@ -283,6 +294,11 @@ public class GameHub : Hub
{
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (InvalidOperationException ex)
{
// Geprüfte Fachfehler (z. B. "Card does not belong to the active round") dürfen an den Client.
await Clients.Caller.SendAsync("Error", new { Message = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error challenging slip");
@@ -372,9 +388,26 @@ public class GameHub : Hub
{
player.ConnectionId = Context.ConnectionId;
await context.SaveChangesAsync();
// (Re-)Join der Lobby-Gruppe, damit Group-Broadcasts nach einem Reconnect wieder ankommen.
var lobbyCode = await context.Games
.Where(g => g.Id == player.GameId)
.Select(g => g.LobbyCode)
.FirstAsync();
await Groups.AddToGroupAsync(Context.ConnectionId, lobbyCode);
// Der Client bekommt nach einem Reconnect keinen vollständigen Zustand
// mehr aus der Queue gespielt — er muss RequestGameState aufrufen.
}
}
catch { /* Ignorieren falls nicht authentifiziert */ }
catch (UnauthorizedAccessException)
{
// Nicht authentifiziert — ConnectionId wird nicht gespeichert.
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register connection for authenticated user");
}
await base.OnConnectedAsync();
}
@@ -382,6 +415,23 @@ public class GameHub : Hub
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected", Context.ConnectionId);
// ConnectionId zurücksetzen, damit keine Nachrichten an eine tote Verbindung gehen.
try
{
using var context = _contextFactory.CreateDbContext();
var players = await context.Players
.Where(p => p.ConnectionId == Context.ConnectionId)
.ToListAsync();
foreach (var player in players)
player.ConnectionId = null;
await context.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear connection id on disconnect");
}
await base.OnDisconnectedAsync(exception);
}
}
}

View File

@@ -14,13 +14,27 @@ builder.AddServiceDefaults();
builder.AddNpgsqlDataSource(connectionName: "postgresdb");
builder.Services.AddDbContextFactory<SlipItInDbContext>(options => options.UseNpgsql());
builder.Services.AddDbContextFactory<SlipItInDbContext>((sp, options) =>
options.UseNpgsql(sp.GetRequiredService<Npgsql.NpgsqlDataSource>()));
// Services registrieren
builder.Services.AddTransient<IGameService, GameService>();
// JWT Authentication
var jwtKey = builder.Configuration["Jwt:Key"] ?? "YourSuperSecretKeyThatIsAtLeast32CharactersLong!";
var jwtKey = builder.Configuration["Jwt:Key"];
if (string.IsNullOrWhiteSpace(jwtKey) || jwtKey.Length < 32)
{
if (builder.Environment.IsDevelopment())
{
jwtKey = "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef";
}
else
{
throw new InvalidOperationException(
"Jwt:Key ist nicht konfiguriert oder kürzer als 32 Zeichen. " +
"In Production muss ein eigener Schlüssel via Konfiguration/Umgebungsvariable gesetzt werden.");
}
}
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SlipItInServer";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SlipItInClient";

View File

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using SlipItIn.Server.Data;
using SlipItIn.Shared.DTOs;
using SlipItIn.Shared.Models;
using System.Security.Cryptography;
namespace SlipItIn.Server.Services;
@@ -21,7 +22,7 @@ public class GameService : IGameService
using var context = _contextFactory.CreateDbContext();
var host = await context.Users.FirstOrDefaultAsync(u => u.Id == hostUserId) ?? throw new InvalidOperationException("Host not found");
var lobbyCode = GenerateLobbyCode();
var lobbyCode = await GenerateUniqueLobbyCodeAsync(context);
var game = new Game
{
LobbyCode = lobbyCode,
@@ -70,7 +71,16 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
var game = await context.Games
.Include(g => g.Players)
.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.Lobby)
throw new InvalidOperationException("Game has already started or ended");
if (game.Players.Count < 2)
throw new InvalidOperationException("At least 2 players are required to start the game");
game.Status = GameStatus.InProgress;
game.StartedAt = DateTime.UtcNow;
@@ -85,12 +95,16 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId) ?? throw new InvalidOperationException("Player not found");
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.Lobby)
throw new InvalidOperationException("Game is not in lobby status");
var player = await context.Players.FirstOrDefaultAsync(p => p.Id == playerId && p.GameId == gameId)
?? throw new InvalidOperationException("Player not found in this game");
player.IsReady = true;
await context.SaveChangesAsync();
return await context.Games.FirstOrDefaultAsync(g => g.Id == gameId)
?? throw new InvalidOperationException("Game not found");
return game;
}
public async Task<List<PlayerCard>> DealCardsAsync(int gameId)
@@ -108,7 +122,7 @@ public class GameService : IGameService
foreach (var player in players)
{
var selectedPhrases = allPhrases.OrderBy(_ => Random.Shared.Next()).Take(5);
var selectedPhrases = allPhrases.OrderBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue)).Take(5);
foreach (var phrase in selectedPhrases)
{
@@ -125,6 +139,7 @@ public class GameService : IGameService
}
currentRound.Status = RoundStatus.Active;
currentRound.StartedAt = DateTime.UtcNow;
await context.SaveChangesAsync();
return dealtCards;
@@ -134,10 +149,18 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var game = await context.Games.FirstOrDefaultAsync(g => g.Id == gameId) ?? throw new InvalidOperationException("Game not found");
if (game.Status != GameStatus.InProgress)
throw new InvalidOperationException("Game is not in progress");
var card = await context.PlayerCards
.Include(pc => pc.Player)
.Include(pc => pc.GameRound)
.FirstOrDefaultAsync(pc => pc.Id == cardId) ?? throw new InvalidOperationException("Card not found");
if (card.PlayerId != playerId) throw new UnauthorizedAccessException("Card does not belong to player");
if (card.GameRound.GameId != gameId) throw new InvalidOperationException("Card does not belong to this game");
if (card.GameRound.Status != RoundStatus.Active) throw new InvalidOperationException("Round is not active");
if (card.IsUsed) throw new InvalidOperationException("Card has already been used");
card.IsUsed = true;
await context.SaveChangesAsync();
@@ -149,13 +172,24 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
var targetCard = await context.PlayerCards.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
var currentRound = await context.GameRounds
.Where(gr => gr.GameId == gameId && gr.Status == RoundStatus.Active)
.FirstOrDefaultAsync() ?? throw new InvalidOperationException("No active round found");
if (currentRound == null) throw new InvalidOperationException("No active round found");
var targetCard = await context.PlayerCards
.FirstOrDefaultAsync(pc => pc.Id == targetCardId) ?? throw new InvalidOperationException("Target card not found");
if (targetCard.GameRoundId != currentRound.Id)
throw new InvalidOperationException("Card does not belong to the active round");
if (targetCard.PlayerId == challengingPlayerId)
throw new InvalidOperationException("Cannot challenge your own card");
if (!targetCard.IsUsed)
throw new InvalidOperationException("Card has not been played yet");
var pendingExists = await context.SlipChallenges
.AnyAsync(c => c.TargetCardId == targetCardId && c.Status == ChallengeStatus.Pending);
if (pendingExists)
throw new InvalidOperationException("A challenge for this card is already pending");
var challenge = new SlipChallenge
{
@@ -179,19 +213,30 @@ public class GameService : IGameService
var challenge = await context.SlipChallenges
.Include(sc => sc.GameRound)
.Include(sc => sc.TargetCard)
.Include(sc => sc.ChallengingPlayer)
.Include(sc => sc.TargetPlayer)
.FirstOrDefaultAsync(sc => sc.Id == challengeId) ?? throw new InvalidOperationException("Challenge not found");
// approved = Beschuldigung stimmt (Phrase war falsch)
// rejected = Beschuldigung stimmt nicht (Phrase war richtig)
if (challenge.Status != ChallengeStatus.Pending)
throw new InvalidOperationException("Challenge has already been resolved");
if (!approved)
// approved = Beschuldigung stimmt (Phrase war ein unberechtigtes SlipIn des Beschuldigten)
// rejected = Beschuldigung stimmt nicht (Phrase wurde regelkonform gespielt)
if (approved)
{
// FALSCHE BESCHULDIGUNG: Beschuldiger erhält die Phrase des Beschuldigten
var targetCard = challenge.TargetCard;
targetCard.PlayerId = challenge.ChallengingPlayerId;
context.PlayerCards.Update(targetCard);
// Berechtigte Beschuldigung: Beschuldigter muss die Phrase an den Beschuldiger abgeben
challenge.TargetCard.PlayerId = challenge.ChallengingPlayerId;
challenge.ChallengingPlayer.Score += 1;
challenge.TargetPlayer.FailedSlips += 1;
}
else
{
// Falsche Beschuldigung: Phrase bleibt beim Beschuldigten
challenge.TargetPlayer.SuccessfulSlips += 1;
challenge.TargetPlayer.Score += 1;
challenge.ChallengingPlayer.FailedSlips += 1;
}
// Wenn approved: Phrase bleibt bei Beschuldigtem (= Phrase war tatsächlich ein SlipIn)
challenge.Status = approved ? ChallengeStatus.Approved : ChallengeStatus.Rejected;
challenge.ResolvedAt = DateTime.UtcNow;
@@ -218,7 +263,7 @@ public class GameService : IGameService
Username = p.User.Username,
Score = p.Score,
IsReady = p.IsReady,
CardCount = p.Cards.Count // Nur Anzahl, nicht die Karten selbst!
CardCount = p.Cards.Count(c => !c.IsUsed) // Nur Anzahl, nicht die Karten selbst!
}).ToList();
var currentRound = game.Rounds
@@ -249,9 +294,17 @@ public class GameService : IGameService
{
using var context = _contextFactory.CreateDbContext();
// Nur Karten der aktuell aktiven Runde zurückgeben — sonst wächst die Hand
// über Runden hinweg unbegrenzt und enthält veraltete Karten.
var activeRoundIds = await context.GameRounds
.Where(gr => gr.Status == RoundStatus.Active &&
gr.Game.Players.Any(p => p.Id == playerId))
.Select(gr => gr.Id)
.ToListAsync();
var cards = await context.PlayerCards
.Include(pc => pc.Phrase)
.Where(pc => pc.PlayerId == playerId)
.Where(pc => pc.PlayerId == playerId && activeRoundIds.Contains(pc.GameRoundId))
.Select(pc => new PlayerCardDto
{
CardId = pc.Id,
@@ -275,11 +328,25 @@ public class GameService : IGameService
?? throw new InvalidOperationException("Game not found");
}
private string GenerateLobbyCode()
private static string GenerateLobbyCode()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var code = new string([.. Enumerable.Range(0, 6).Select(_ => chars[Random.Shared.Next(chars.Length)])]);
var buffer = new char[6];
for (var i = 0; i < buffer.Length; i++)
buffer[i] = chars[RandomNumberGenerator.GetInt32(chars.Length)];
return new string(buffer);
}
return code;
private static async Task<string> GenerateUniqueLobbyCodeAsync(SlipItInDbContext context, int maxAttempts = 10)
{
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
var code = GenerateLobbyCode();
var exists = await context.Games.AnyAsync(g => g.LobbyCode == code);
if (!exists)
return code;
}
throw new InvalidOperationException("Could not generate a unique lobby code");
}
}

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Jwt": {
"Key": "DevOnlyKey-NotForProduction-ChangeMe-0123456789abcdef"
}
}

View File

@@ -7,11 +7,7 @@
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"postgresdb": "Server=(localdb)\\postgres;Database=postgresdb"
},
"Jwt": {
"Key": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "SlipItInServer",
"Audience": "SlipItInClient",
"ExpirationMinutes": 1440

View File

@@ -1,9 +1,9 @@
<Solution>
<Project Path="SlipItIN.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.AppHost/SlipItIn.AppHost.csproj" />
<Project Path="SlipItIn.Server/SlipItIn.Server.csproj" />
<Project Path="SlipItIN.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.ServiceDefaults/SlipItIn.ServiceDefaults.csproj" />
<Project Path="SlipItIn.Shared/SlipItIn.Shared.csproj" />
<Project Path="SlipItIN/SlipItIn.csproj">
<Project Path="SlipItIn/SlipItIn.csproj">
<Deploy Solution="Debug|*" />
</Project>
</Solution>

View File

@@ -5,6 +5,8 @@ namespace SlipItIn;
public partial class App : Application
{
private readonly SemaphoreSlim _startupLock = new(1, 1);
public App()
{
InitializeComponent();
@@ -21,15 +23,25 @@ public partial class App : Application
window.Resumed += async (_, _) =>
{
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync();
try
{
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync();
}
catch
{
// Resume darf nicht abstürzen; Sync-Status wurde bereits gemeldet.
}
};
return window;
}
private static async Task InitializeSafeAsync()
private async Task InitializeSafeAsync()
{
if (!await _startupLock.WaitAsync(0))
return;
try
{
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
@@ -43,8 +55,14 @@ public partial class App : Application
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
return;
var isSessionValid = await apiService.ValidateSessionAsync();
if (!isSessionValid)
var sessionResult = await apiService.ValidateSessionAsync();
if (sessionResult == SessionValidationResult.NetworkError)
{
// Netzwerkfehler darf die lokale Session nicht zerstören.
return;
}
if (sessionResult == SessionValidationResult.Invalid)
{
await authSession.ClearSessionAsync();
await gameStateService.ClearLocalGameDataAsync();
@@ -60,5 +78,9 @@ public partial class App : Application
{
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
}
finally
{
_startupLock.Release();
}
}
}

View File

@@ -5,4 +5,5 @@ public class QueuedGameAction
public string Method { get; set; } = string.Empty;
public string ArgumentsJson { get; set; } = "[]";
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public int RetryCount { get; set; }
}

View File

@@ -28,20 +28,26 @@ public class ApiService : IApiService
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/login", request, cancellationToken);
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default)
public async Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
return false;
return SessionValidationResult.Invalid;
try
{
AttachAuthHeader();
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
return response.IsSuccessStatusCode;
return response.IsSuccessStatusCode
? SessionValidationResult.Valid
: SessionValidationResult.Invalid;
}
catch
catch (HttpRequestException)
{
return false;
return SessionValidationResult.NetworkError;
}
catch (TaskCanceledException)
{
return SessionValidationResult.NetworkError;
}
}

View File

@@ -28,21 +28,21 @@ public class GameStateService : IGameStateService
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
_signalR.GameStateUpdated += async (_, state) =>
_signalR.GameStateUpdated += (_, state) => RunSafeBridge(async () =>
{
CurrentGameState = state;
await _localStorage.SaveGameStateAsync(state);
_messenger.Send(new GameStateChangedMessage(state));
};
});
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
_signalR.PlayerHandUpdated += async (_, hand) =>
_signalR.PlayerHandUpdated += (_, hand) => RunSafeBridge(async () =>
{
CurrentPlayerHand = hand;
await _localStorage.SavePlayerHandAsync(hand);
_messenger.Send(new PlayerHandChangedMessage(hand));
};
});
_signalR.ChallengeReceived += (_, challenge) =>
{
@@ -51,21 +51,26 @@ public class GameStateService : IGameStateService
};
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
_signalR.ConnectionStateChanged += async (_, connected) =>
_signalR.ConnectionStateChanged += (_, connected) => RunSafeBridge(async () =>
{
_messenger.Send(new ConnectionStateChangedMessage(connected));
if (!connected)
return;
try
{
await ResyncAsync();
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
}
};
await ResyncAsync();
});
}
private async void RunSafeBridge(Func<Task> action)
{
try
{
await action();
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Hintergrundfehler: {ex.Message}"));
}
}
public async Task InitializeAsync()
@@ -153,12 +158,16 @@ public class GameStateService : IGameStateService
PublishSyncState(false, "Bereit", 0);
}
private const int MaxQueueRetries = 3;
private async Task FlushQueuedActionsCoreAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
var poisoned = new List<QueuedGameAction>();
foreach (var action in snapshot)
{
try
@@ -169,11 +178,23 @@ public class GameStateService : IGameStateService
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break;
action.RetryCount++;
if (action.RetryCount >= MaxQueueRetries)
{
poisoned.Add(action);
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion verworfen ({action.Method}): {ex.Message}"));
}
else
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break; // Bei transienten Fehlern den Rest der Queue nicht blockieren
}
}
}
foreach (var item in poisoned)
_queuedActions.Remove(item);
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}

View File

@@ -2,10 +2,17 @@ using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public enum SessionValidationResult
{
Valid,
Invalid,
NetworkError
}
public interface IApiService
{
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task<SessionValidationResult> ValidateSessionAsync(CancellationToken cancellationToken = default);
Task LogoutAsync();
}

View File

@@ -1,21 +1,25 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using SlipItIn.Models;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class SignalRService : ISignalRService
public class SignalRService : ISignalRService, IAsyncDisposable
{
private readonly IAppConfigurationService _configuration;
private readonly IAuthSessionService _authSession;
private readonly IServiceProvider _serviceProvider;
private readonly SemaphoreSlim _connectLock = new(1, 1);
private HubConnection? _hubConnection;
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession, IServiceProvider serviceProvider)
{
_configuration = configuration;
_authSession = authSession;
_serviceProvider = serviceProvider;
}
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
@@ -33,36 +37,76 @@ public class SignalRService : ISignalRService
if (IsConnected)
return true;
EnsureConnection(token);
if (_hubConnection is null)
return false;
ConnectionStateChanged?.Invoke(this, false);
await _connectLock.WaitAsync(cancellationToken);
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch (Exception ex)
{
if (IsConnected)
return true;
// Bestehende (möglicherweise tote) Verbindung vollständig entsorgen,
// damit der AccessTokenProvider keinen veralteten Token-Closure verwendet.
if (_hubConnection is not null)
{
try
{
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync(cancellationToken);
}
catch
{
// Best effort; Dispose folgt trotzdem.
}
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
EnsureConnection(token);
if (_hubConnection is null)
return false;
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch (Exception ex)
{
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
}
}
finally
{
_connectLock.Release();
}
}
public async Task DisconnectAsync()
{
if (_hubConnection is null)
return;
await _connectLock.WaitAsync();
try
{
if (_hubConnection is null)
return;
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false);
await _hubConnection.DisposeAsync();
_hubConnection = null;
ConnectionStateChanged?.Invoke(this, false);
}
finally
{
_connectLock.Release();
}
}
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
@@ -87,7 +131,7 @@ public class SignalRService : ISignalRService
public async Task SendQueuedActionAsync(QueuedGameAction action)
{
if (_hubConnection is null)
return;
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
object?[] args;
try
@@ -95,11 +139,11 @@ public class SignalRService : ISignalRService
var doc = JsonDocument.Parse(action.ArgumentsJson);
args = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
: [];
: throw new InvalidOperationException($"Ungültige Queue-Aktion: {action.ArgumentsJson}");
}
catch
catch (JsonException ex)
{
args = [];
throw new InvalidOperationException($"Queue-Aktion '{action.Method}' ist beschädigt.", ex);
}
await InvokeAsync(action.Method, args);
@@ -113,7 +157,8 @@ public class SignalRService : ISignalRService
_hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(_authSession.AccessToken ?? token);
// Token immer zur Laufzeit aus der Session lesen — nie den Startwert cachen.
options.AccessTokenProvider = () => Task.FromResult(_authSession.AccessToken);
})
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
.Build();
@@ -155,6 +200,13 @@ public class SignalRService : ISignalRService
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto));
// Server-Broadcasts nach Slip/Challenge/Resolve, damit die UI auch ohne
// vollständiges GameState-Update reagieren kann.
_hubConnection.On<JsonElement>("SlipSubmitted", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("SlipChallenged", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("ChallengeResolved", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("CardsDealt", _ => RequestCurrentGameStateSafe());
_hubConnection.On<JsonElement>("Error", payload =>
{
var message = payload.TryGetProperty("message", out var msgElement)
@@ -164,6 +216,26 @@ public class SignalRService : ISignalRService
});
}
private void RequestCurrentGameStateSafe()
{
_ = Task.Run(async () =>
{
try
{
// Lazy über ServiceProvider auflösen, um eine zirkuläre Abhängigkeit
// SignalRService ↔ GameStateService zu vermeiden.
var gameStateService = _serviceProvider.GetService<IGameStateService>();
var gameState = gameStateService?.CurrentGameState;
if (gameState?.GameId > 0 && IsConnected)
await RequestGameStateAsync(gameState.GameId);
}
catch
{
// Hintergrund-Refresh darf nicht abstürzen.
}
});
}
private async Task InvokeAsync(string method, params object?[] args)
{
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
@@ -186,4 +258,10 @@ public class SignalRService : ISignalRService
_ => element.GetRawText()
};
}
public async ValueTask DisposeAsync()
{
if (_hubConnection is not null)
await _hubConnection.DisposeAsync();
}
}

View File

@@ -48,35 +48,11 @@
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-android|AnyCPU'">
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-ios|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-maccatalyst|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Debug|net10.0-windows10.0.19041.0|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-android|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-ios|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-maccatalyst|AnyCPU'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)'=='Release|net10.0-windows10.0.19041.0|AnyCPU'">
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<ApplicationTitle>SlipItIn</ApplicationTitle>
</PropertyGroup>
@@ -89,7 +65,6 @@
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
@@ -100,7 +75,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="11.0.0-preview.6.26359.118" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.10" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" />
</ItemGroup>
@@ -110,9 +85,6 @@
</ItemGroup>
<ItemGroup>
<MauiXaml Update="MainPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>

View File

@@ -19,6 +19,8 @@ public partial class GameBoardViewModel : BaseViewModel,
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
private readonly IAuthSessionService _authSession;
private readonly IDispatcherTimer? _roundTimer;
private DateTime _lastStateUtc = DateTime.UtcNow;
[ObservableProperty]
private int gameId;
@@ -58,6 +60,26 @@ public partial class GameBoardViewModel : BaseViewModel,
IsConnected = _signalR.IsConnected;
IsActive = true;
if (Application.Current?.Dispatcher is { } dispatcher)
{
_roundTimer = dispatcher.CreateTimer();
_roundTimer.Interval = TimeSpan.FromSeconds(1);
_roundTimer.Tick += OnRoundTimerTick;
}
}
private void OnRoundTimerTick(object? sender, EventArgs e)
{
if (RoundTimeRemaining <= 0)
return;
var elapsed = (int)(DateTime.UtcNow - _lastStateUtc).TotalSeconds;
if (elapsed > 0)
{
RoundTimeRemaining = Math.Max(0, RoundTimeRemaining - elapsed);
_lastStateUtc = DateTime.UtcNow;
}
}
[RelayCommand]
@@ -123,9 +145,22 @@ public partial class GameBoardViewModel : BaseViewModel,
if (PendingChallenge is null)
return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true);
var challengeId = PendingChallenge.ChallengeId;
PendingChallenge = null;
IsChallengeVisible = false;
await RunSafeAsync(async () =>
{
if (_signalR.IsConnected)
{
await _signalR.ResolveChallengeAsync(challengeId, true);
}
else
{
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, true);
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
}
});
}
[RelayCommand]
@@ -134,9 +169,22 @@ public partial class GameBoardViewModel : BaseViewModel,
if (PendingChallenge is null)
return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false);
var challengeId = PendingChallenge.ChallengeId;
PendingChallenge = null;
IsChallengeVisible = false;
await RunSafeAsync(async () =>
{
if (_signalR.IsConnected)
{
await _signalR.ResolveChallengeAsync(challengeId, false);
}
else
{
await _gameStateService.EnqueueActionAsync("ResolveChallenge", challengeId, false);
StatusMessage = "Offline: Antwort wurde zwischengespeichert.";
}
});
}
public void Receive(GameStateChangedMessage message)
@@ -146,6 +194,7 @@ public partial class GameBoardViewModel : BaseViewModel,
GameId = message.Value.GameId;
CurrentRound = message.Value.CurrentRound;
RoundTimeRemaining = message.Value.RoundTimeRemaining;
_lastStateUtc = DateTime.UtcNow;
var me = _authSession.CurrentUser?.Username;
var others = message.Value.Players
@@ -170,27 +219,36 @@ public partial class GameBoardViewModel : BaseViewModel,
public void Receive(ChallengeReceivedMessage message)
{
PendingChallenge = message.Value;
IsChallengeVisible = true;
MainThread.BeginInvokeOnMainThread(() =>
{
PendingChallenge = message.Value;
IsChallengeVisible = true;
});
}
public void Receive(ErrorOccurredMessage message)
{
StatusMessage = message.Value;
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
}
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
MainThread.BeginInvokeOnMainThread(() =>
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
}
public void StartRoundTimer() => _roundTimer?.Start();
public void StopRoundTimer() => _roundTimer?.Stop();
private int GetCurrentPlayerId()
{
var username = _authSession.CurrentUser?.Username;

View File

@@ -55,6 +55,16 @@ public partial class LobbyViewModel : BaseViewModel,
IsActive = true;
}
/// <summary>
/// Reaktiviert das ViewModel nach einem Logout (Singleton-Lebensdauer).
/// Wird von LobbyPage.OnNavigatedTo aufgerufen.
/// </summary>
public void Activate()
{
IsConnected = _signalR.IsConnected;
IsActive = true;
}
[RelayCommand]
private async Task CreateLobbyAsync()
{
@@ -105,6 +115,7 @@ public partial class LobbyViewModel : BaseViewModel,
{
await RunSafeAsync(async () =>
{
IsActive = false;
await _signalR.DisconnectAsync();
await _apiService.LogoutAsync();
await _gameStateService.ClearLocalGameDataAsync();
@@ -134,26 +145,39 @@ public partial class LobbyViewModel : BaseViewModel,
});
}
public async void Receive(GameStartedMessage message)
public void Receive(GameStartedMessage message)
{
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync(nameof(Views.GameBoardPage)));
MainThread.BeginInvokeOnMainThread(() =>
{
try
{
Shell.Current.GoToAsync(nameof(Views.GameBoardPage));
}
catch (Exception ex)
{
StatusMessage = $"Navigation fehlgeschlagen: {ex.Message}";
}
});
}
public void Receive(ErrorOccurredMessage message)
{
StatusMessage = message.Value;
MainThread.BeginInvokeOnMainThread(() => StatusMessage = message.Value);
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
MainThread.BeginInvokeOnMainThread(() => IsConnected = message.Value);
}
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
MainThread.BeginInvokeOnMainThread(() =>
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
});
}
private int GetCurrentPlayerId()

View File

@@ -58,8 +58,11 @@ public partial class LoginViewModel : BaseViewModel
if (!_authSession.IsAuthenticated)
return;
var isSessionValid = await _apiService.ValidateSessionAsync();
if (!isSessionValid)
var sessionResult = await _apiService.ValidateSessionAsync();
if (sessionResult == SessionValidationResult.NetworkError)
return;
if (sessionResult == SessionValidationResult.Invalid)
{
await _authSession.ClearSessionAsync();
await _gameStateService.ClearLocalGameDataAsync();

View File

@@ -44,7 +44,9 @@ public partial class RegisterViewModel : BaseViewModel
});
await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token);
var connected = await _signalR.ConnectAsync(response.Token);
if (!connected)
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
await Shell.Current.GoToAsync(nameof(LobbyPage));
}, "Registrierung läuft...");
}

View File

@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay">
xmlns:vm="clr-namespace:SlipItIn.ViewModels"
x:Class="SlipItIn.Views.ChallengeNotificationOverlay"
x:DataType="vm:GameBoardViewModel">
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24">
<Frame VerticalOptions="Center" HorizontalOptions="Center">
<Border VerticalOptions="Center" HorizontalOptions="Center" Stroke="LightGray" StrokeShape="RoundRectangle 12" BackgroundColor="White" Padding="16">
<VerticalStackLayout Spacing="10">
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
@@ -12,6 +14,6 @@
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</Border>
</Grid>
</ContentView>

View File

@@ -5,9 +5,23 @@ namespace SlipItIn.Views;
public partial class GameBoardPage : ContentPage
{
private readonly GameBoardViewModel _viewModel;
public GameBoardPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<GameBoardViewModel>();
BindingContext = _viewModel = ServiceHelper.GetRequiredService<GameBoardViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
_viewModel.StartRoundTimer();
}
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
{
base.OnNavigatedFrom(args);
_viewModel.StopRoundTimer();
}
}

View File

@@ -5,9 +5,17 @@ namespace SlipItIn.Views;
public partial class LobbyPage : ContentPage
{
private readonly LobbyViewModel _viewModel;
public LobbyPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<LobbyViewModel>();
BindingContext = _viewModel = ServiceHelper.GetRequiredService<LobbyViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
_viewModel.Activate();
}
}

View File

@@ -1,5 +1,5 @@
{
"appHost": {
"path": "SlipItIN.AppHost/SlipItIN.AppHost.csproj"
"path": "SlipItIn.AppHost/SlipItIn.AppHost.csproj"
}
}