Architektur-Redesign: SignalR-Multiplayer & MVVM

Umfassende Umstrukturierung der SlipItIn-App für eine moderne, zustandsbasierte und SignalR-gestützte Multiplayer-Architektur. Einführung neuer Services für Auth, API, SignalR, lokalen Speicher und Game-State-Management via Dependency Injection. Komplette Neugestaltung der Views (Login, Registrierung, Lobby, Spielbrett) mit MVVM und CommunityToolkit.Mvvm. Navigation jetzt über MAUI Shell mit expliziten Routen. Game-Logik auf Events/Messaging umgestellt, UI und Logik entkoppelt, Echtzeit-Updates integriert. SignalR für alle Spielaktionen inkl. Wiederverbindung und Offline-Queueing. Lokaler Speicher für Auth, Userdaten, GameState und Aktionen. MainPage entfernt, neue Views implementiert. Projektdatei um NuGet-Pakete und Shared-Projekt erweitert. Backend-Änderungen für direkte Spieler-Zuordnung und korrekte EF Core-Relationen. App ist nun robust, testbar und unterstützt Multiplayer- sowie Offline-Szenarien.
This commit is contained in:
Tim Krampitz
2026-07-26 17:31:26 +02:00
parent 85fb2c9ce7
commit bbc6ad6080
45 changed files with 1544 additions and 75 deletions

View File

@@ -1,7 +1,8 @@
var builder = DistributedApplication.CreateBuilder(args);
var db = builder.AddPostgres("pgsql")
.AddDatabase("postgresdb");
.AddDatabase("postgresdb")
;
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
.WithReference(db)

View File

@@ -29,11 +29,15 @@ public class GameService : IGameService
Status = GameStatus.Lobby
};
context.Games.Add(game);
// Host wird automatisch als erster Spieler hinzugefügt
var hostPlayer = new Player { UserId = host.Id, GameId = game.Id, ConnectionId = connectionId };
context.Players.Add(hostPlayer);
var hostPlayer = new Player { UserId = host.Id, ConnectionId = connectionId };
// Spieler direkt zur Liste des Spiels hinzufügen
game.Players.Add(hostPlayer);
// Es reicht, nur das Game hinzuzufügen — EF Core fügt den Player automatisch mit hinzu
context.Games.Add(game);
await context.SaveChangesAsync();

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using SlipItIn.Infrastructure;
using SlipItIn.Services.Interfaces;
namespace SlipItIn;
@@ -7,10 +8,34 @@ public partial class App : Application
public App()
{
InitializeComponent();
_ = InitializeAsync();
}
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
var window = new Window(new AppShell());
window.Resumed += async (_, _) =>
{
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await gameStateService.ResyncAsync();
};
return window;
}
private static async Task InitializeAsync()
{
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
await authSession.InitializeAsync();
await gameStateService.InitializeAsync();
if (!string.IsNullOrWhiteSpace(authSession.AccessToken))
{
var connected = await signalR.ConnectAsync(authSession.AccessToken);
if (connected)
await gameStateService.ResyncAsync();
}
}
}

View File

@@ -3,12 +3,22 @@
x:Class="SlipItIn.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:SlipItIn"
Title="Slip It In">
xmlns:views="clr-namespace:SlipItIn.Views">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
Route="LoginPage"
ContentTemplate="{DataTemplate views:LoginPage}" />
<ShellContent
Route="RegisterPage"
ContentTemplate="{DataTemplate views:RegisterPage}" />
<ShellContent
Route="LobbyPage"
ContentTemplate="{DataTemplate views:LobbyPage}" />
<ShellContent
Route="GameBoardPage"
ContentTemplate="{DataTemplate views:GameBoardPage}" />
</Shell>

View File

@@ -1,4 +1,6 @@
namespace SlipItIn;
using SlipItIn.Views;
namespace SlipItIn;
public partial class AppShell : Shell
{

View File

@@ -0,0 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
namespace SlipItIn.Infrastructure;
public static class ServiceHelper
{
public static IServiceProvider? Services { get; set; }
public static T GetRequiredService<T>() where T : notnull
{
if (Services is null)
throw new InvalidOperationException("Service provider is not initialized.");
return Services.GetRequiredService<T>();
}
}

View File

@@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="SlipItIn.MainPage">
<ScrollView>
<VerticalStackLayout
Padding="30,0"
Spacing="25">
<Image
Source="dotnet_bot.png"
HeightRequest="185"
Aspect="AspectFit"
SemanticProperties.Description="dot net bot in a submarine number ten" />
<Label
Text="Hello, World!"
Style="{StaticResource Headline}"
SemanticProperties.HeadingLevel="Level1" />
<Label
Text="Welcome to &#10;.NET Multi-platform App UI"
Style="{StaticResource SubHeadline}"
SemanticProperties.HeadingLevel="Level2"
SemanticProperties.Description="Welcome to dot net Multi platform App U I" />
<Button
x:Name="CounterBtn"
Text="Click me"
SemanticProperties.Hint="Counts the number of times you click"
Clicked="OnCounterClicked"
HorizontalOptions="Fill" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -1,23 +0,0 @@
namespace SlipItIn;
public partial class MainPage : ContentPage
{
int count = 0;
public MainPage()
{
InitializeComponent();
}
private void OnCounterClicked(object? sender, EventArgs e)
{
count++;
if (count == 1)
CounterBtn.Text = $"Clicked {count} time";
else
CounterBtn.Text = $"Clicked {count} times";
SemanticScreenReader.Announce(CounterBtn.Text);
}
}

View File

@@ -1,4 +1,8 @@
using Microsoft.Extensions.Logging;
using SlipItIn.Infrastructure;
using SlipItIn.Services;
using SlipItIn.Services.Interfaces;
using SlipItIn.ViewModels;
namespace SlipItIn;
@@ -15,10 +19,24 @@ public static class MauiProgram
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
builder.Services.AddSingleton<IAppConfigurationService, AppConfigurationService>();
builder.Services.AddSingleton<ILocalStorageService, LocalStorageService>();
builder.Services.AddSingleton<IAuthSessionService, AuthSessionService>();
builder.Services.AddSingleton<IApiService, ApiService>();
builder.Services.AddSingleton<ISignalRService, SignalRService>();
builder.Services.AddSingleton<IGameStateService, GameStateService>();
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<RegisterViewModel>();
builder.Services.AddSingleton<LobbyViewModel>();
builder.Services.AddSingleton<GameBoardViewModel>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
var app = builder.Build();
ServiceHelper.Services = app.Services;
return app;
}
}

View File

@@ -0,0 +1,11 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Messages;
public class ChallengeReceivedMessage : ValueChangedMessage<SlipChallengeDto>
{
public ChallengeReceivedMessage(SlipChallengeDto value) : base(value)
{
}
}

View File

@@ -0,0 +1,10 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public class ConnectionStateChangedMessage : ValueChangedMessage<bool>
{
public ConnectionStateChangedMessage(bool value) : base(value)
{
}
}

View File

@@ -0,0 +1,10 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public class ErrorOccurredMessage : ValueChangedMessage<string>
{
public ErrorOccurredMessage(string value) : base(value)
{
}
}

View File

@@ -0,0 +1,10 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public class GameStartedMessage : ValueChangedMessage<int>
{
public GameStartedMessage(int value) : base(value)
{
}
}

View File

@@ -0,0 +1,11 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Messages;
public class GameStateChangedMessage : ValueChangedMessage<GameStateDto>
{
public GameStateChangedMessage(GameStateDto value) : base(value)
{
}
}

View File

@@ -0,0 +1,10 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public class LobbyCreatedMessage : ValueChangedMessage<(int GameId, string LobbyCode)>
{
public LobbyCreatedMessage((int GameId, string LobbyCode) value) : base(value)
{
}
}

View File

@@ -0,0 +1,11 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Messages;
public class PlayerHandChangedMessage : ValueChangedMessage<PlayerHandDto>
{
public PlayerHandChangedMessage(PlayerHandDto value) : base(value)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace SlipItIn.Models;
public class QueuedGameAction
{
public string Method { get; set; } = string.Empty;
public string ArgumentsJson { get; set; } = "[]";
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,84 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class ApiService : IApiService
{
private readonly HttpClient _httpClient;
private readonly IAppConfigurationService _configuration;
private readonly IAuthSessionService _authSession;
public ApiService(IAppConfigurationService configuration, IAuthSessionService authSession)
{
_configuration = configuration;
_authSession = authSession;
_httpClient = new HttpClient
{
BaseAddress = new Uri(_configuration.ApiBaseUrl)
};
}
public Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/register", request, cancellationToken);
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
=> PostAuthAsync("api/auth/login", request, cancellationToken);
public Task LogoutAsync() => _authSession.ClearSessionAsync();
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)
{
AttachAuthHeader();
var response = await _httpClient.PostAsJsonAsync(endpoint, request, cancellationToken);
if (response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadFromJsonAsync<AuthResponseDto>(cancellationToken: cancellationToken);
if (payload is null)
throw new InvalidOperationException("Leere Auth-Antwort vom Server.");
return payload;
}
var message = await TryReadErrorMessageAsync(response, cancellationToken);
throw new InvalidOperationException(message);
}
private void AttachAuthHeader()
{
var token = _authSession.AccessToken;
_httpClient.DefaultRequestHeaders.Authorization = string.IsNullOrWhiteSpace(token)
? null
: new AuthenticationHeaderValue("Bearer", token);
}
private static async Task<string> TryReadErrorMessageAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(raw))
return $"Serverfehler ({(int)response.StatusCode}).";
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.TryGetProperty("message", out var msgElement))
{
var msg = msgElement.GetString();
if (!string.IsNullOrWhiteSpace(msg))
return msg;
}
}
catch
{
// ignore
}
return raw;
}
}

View File

@@ -0,0 +1,12 @@
using SlipItIn.Services.Interfaces;
namespace SlipItIn.Services;
public class AppConfigurationService : IAppConfigurationService
{
private const string DefaultApiBaseUrl = "https://localhost:7274";
public string ApiBaseUrl => DefaultApiBaseUrl;
public string HubUrl => $"{DefaultApiBaseUrl.TrimEnd('/')}/hubs/game";
}

View File

@@ -0,0 +1,42 @@
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class AuthSessionService : IAuthSessionService
{
private readonly ILocalStorageService _localStorageService;
public AuthSessionService(ILocalStorageService localStorageService)
{
_localStorageService = localStorageService;
}
public string? AccessToken { get; private set; }
public AuthResponseDto? CurrentUser { get; private set; }
public bool IsAuthenticated => !string.IsNullOrWhiteSpace(AccessToken) && CurrentUser is not null;
public async Task InitializeAsync()
{
AccessToken = await _localStorageService.GetAuthTokenAsync();
CurrentUser = await _localStorageService.GetAuthUserAsync();
}
public async Task SetSessionAsync(AuthResponseDto authResponse)
{
AccessToken = authResponse.Token;
CurrentUser = authResponse;
await _localStorageService.SaveAuthTokenAsync(authResponse.Token);
await _localStorageService.SaveAuthUserAsync(authResponse);
}
public async Task ClearSessionAsync()
{
AccessToken = null;
CurrentUser = null;
await _localStorageService.ClearAuthTokenAsync();
await _localStorageService.ClearAuthUserAsync();
}
}

View File

@@ -0,0 +1,119 @@
using System.Text.Json;
using CommunityToolkit.Mvvm.Messaging;
using SlipItIn.Messages;
using SlipItIn.Models;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class GameStateService : IGameStateService
{
private readonly ISignalRService _signalR;
private readonly ILocalStorageService _localStorage;
private readonly IMessenger _messenger;
private readonly List<QueuedGameAction> _queuedActions = [];
public GameStateDto? CurrentGameState { get; private set; }
public PlayerHandDto? CurrentPlayerHand { get; private set; }
public SlipChallengeDto? PendingChallenge { get; private set; }
public GameStateService(ISignalRService signalR, ILocalStorageService localStorage)
{
_signalR = signalR;
_localStorage = localStorage;
_messenger = WeakReferenceMessenger.Default;
_signalR.LobbyCreated += (_, value) => _messenger.Send(new LobbyCreatedMessage(value));
_signalR.GameStateUpdated += async (_, state) =>
{
CurrentGameState = state;
await _localStorage.SaveGameStateAsync(state);
_messenger.Send(new GameStateChangedMessage(state));
};
_signalR.GameStarted += (_, gameId) => _messenger.Send(new GameStartedMessage(gameId));
_signalR.PlayerHandUpdated += async (_, hand) =>
{
CurrentPlayerHand = hand;
await _localStorage.SavePlayerHandAsync(hand);
_messenger.Send(new PlayerHandChangedMessage(hand));
};
_signalR.ChallengeReceived += (_, challenge) =>
{
PendingChallenge = challenge;
_messenger.Send(new ChallengeReceivedMessage(challenge));
};
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
_signalR.ConnectionStateChanged += (_, connected) => _messenger.Send(new ConnectionStateChangedMessage(connected));
}
public async Task InitializeAsync()
{
CurrentGameState = await _localStorage.GetGameStateAsync();
CurrentPlayerHand = await _localStorage.GetPlayerHandAsync();
var queued = await _localStorage.GetQueuedActionsAsync();
_queuedActions.Clear();
_queuedActions.AddRange(queued.OrderBy(a => a.CreatedAtUtc));
if (CurrentGameState is not null)
_messenger.Send(new GameStateChangedMessage(CurrentGameState));
if (CurrentPlayerHand is not null)
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
}
public async Task ResyncAsync()
{
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await FlushQueuedActionsAsync();
}
public async Task EnqueueActionAsync(string method, params object[] arguments)
{
var action = new QueuedGameAction
{
Method = method,
ArgumentsJson = JsonSerializer.Serialize(arguments),
CreatedAtUtc = DateTime.UtcNow
};
_queuedActions.Add(action);
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
public async Task FlushQueuedActionsAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
}
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
public async Task ClearLocalGameDataAsync()
{
CurrentGameState = null;
CurrentPlayerHand = null;
PendingChallenge = null;
_queuedActions.Clear();
await _localStorage.ClearGameStateAsync();
await _localStorage.ClearPlayerHandAsync();
await _localStorage.ClearQueuedActionsAsync();
}
}

View File

@@ -0,0 +1,10 @@
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public interface IApiService
{
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
Task LogoutAsync();
}

View File

@@ -0,0 +1,7 @@
namespace SlipItIn.Services.Interfaces;
public interface IAppConfigurationService
{
string ApiBaseUrl { get; }
string HubUrl { get; }
}

View File

@@ -0,0 +1,14 @@
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public interface IAuthSessionService
{
string? AccessToken { get; }
AuthResponseDto? CurrentUser { get; }
bool IsAuthenticated { get; }
Task InitializeAsync();
Task SetSessionAsync(AuthResponseDto authResponse);
Task ClearSessionAsync();
}

View File

@@ -0,0 +1,17 @@
using SlipItIn.Models;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public interface IGameStateService
{
GameStateDto? CurrentGameState { get; }
PlayerHandDto? CurrentPlayerHand { get; }
SlipChallengeDto? PendingChallenge { get; }
Task InitializeAsync();
Task ResyncAsync();
Task EnqueueActionAsync(string method, params object[] arguments);
Task FlushQueuedActionsAsync();
Task ClearLocalGameDataAsync();
}

View File

@@ -0,0 +1,27 @@
using SlipItIn.Models;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public interface ILocalStorageService
{
Task SaveAuthTokenAsync(string token);
Task<string?> GetAuthTokenAsync();
Task ClearAuthTokenAsync();
Task SaveAuthUserAsync(AuthResponseDto authResponse);
Task<AuthResponseDto?> GetAuthUserAsync();
Task ClearAuthUserAsync();
Task SaveGameStateAsync(GameStateDto state);
Task<GameStateDto?> GetGameStateAsync();
Task ClearGameStateAsync();
Task SavePlayerHandAsync(PlayerHandDto hand);
Task<PlayerHandDto?> GetPlayerHandAsync();
Task ClearPlayerHandAsync();
Task SaveQueuedActionsAsync(IReadOnlyCollection<QueuedGameAction> actions);
Task<IReadOnlyCollection<QueuedGameAction>> GetQueuedActionsAsync();
Task ClearQueuedActionsAsync();
}

View File

@@ -0,0 +1,31 @@
using SlipItIn.Models;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services.Interfaces;
public interface ISignalRService
{
bool IsConnected { get; }
event EventHandler<(int GameId, string LobbyCode)>? LobbyCreated;
event EventHandler<GameStateDto>? GameStateUpdated;
event EventHandler<int>? GameStarted;
event EventHandler<PlayerHandDto>? PlayerHandUpdated;
event EventHandler<SlipChallengeDto>? ChallengeReceived;
event EventHandler<string>? ErrorReceived;
event EventHandler<bool>? ConnectionStateChanged;
Task<bool> ConnectAsync(string token, CancellationToken cancellationToken = default);
Task DisconnectAsync();
Task CreateLobbyAsync();
Task JoinLobbyAsync(string lobbyCode);
Task PlayerReadyAsync(int gameId, int playerId);
Task StartGameAsync(int gameId);
Task SubmitSlipAsync(int gameId, int playerId, int cardId);
Task ChallengeSlipAsync(int gameId, int challengingPlayerId, int targetCardId);
Task ResolveChallengeAsync(int challengeId, bool approved);
Task RequestGameStateAsync(int gameId);
Task SendQueuedActionAsync(QueuedGameAction action);
}

View File

@@ -0,0 +1,110 @@
using System.Text.Json;
using SlipItIn.Models;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class LocalStorageService : ILocalStorageService
{
private const string AuthTokenKey = "auth_token";
private const string AuthUserKey = "auth_user";
private const string GameStateKey = "game_state";
private const string PlayerHandKey = "player_hand";
private const string QueuedActionsKey = "queued_actions";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public Task SaveAuthTokenAsync(string token) => SecureStorage.SetAsync(AuthTokenKey, token);
public Task<string?> GetAuthTokenAsync() => SecureStorage.GetAsync(AuthTokenKey);
public Task ClearAuthTokenAsync()
{
SecureStorage.Remove(AuthTokenKey);
return Task.CompletedTask;
}
public Task SaveAuthUserAsync(AuthResponseDto authResponse)
{
var json = JsonSerializer.Serialize(authResponse, JsonOptions);
Preferences.Default.Set(AuthUserKey, json);
return Task.CompletedTask;
}
public Task<AuthResponseDto?> GetAuthUserAsync() => ReadFromPreferencesAsync<AuthResponseDto>(AuthUserKey);
public Task ClearAuthUserAsync()
{
Preferences.Default.Remove(AuthUserKey);
return Task.CompletedTask;
}
public Task SaveGameStateAsync(GameStateDto state)
{
var json = JsonSerializer.Serialize(state, JsonOptions);
Preferences.Default.Set(GameStateKey, json);
return Task.CompletedTask;
}
public Task<GameStateDto?> GetGameStateAsync() => ReadFromPreferencesAsync<GameStateDto>(GameStateKey);
public Task ClearGameStateAsync()
{
Preferences.Default.Remove(GameStateKey);
return Task.CompletedTask;
}
public Task SavePlayerHandAsync(PlayerHandDto hand)
{
var json = JsonSerializer.Serialize(hand, JsonOptions);
Preferences.Default.Set(PlayerHandKey, json);
return Task.CompletedTask;
}
public Task<PlayerHandDto?> GetPlayerHandAsync() => ReadFromPreferencesAsync<PlayerHandDto>(PlayerHandKey);
public Task ClearPlayerHandAsync()
{
Preferences.Default.Remove(PlayerHandKey);
return Task.CompletedTask;
}
public Task SaveQueuedActionsAsync(IReadOnlyCollection<QueuedGameAction> actions)
{
var json = JsonSerializer.Serialize(actions, JsonOptions);
Preferences.Default.Set(QueuedActionsKey, json);
return Task.CompletedTask;
}
public async Task<IReadOnlyCollection<QueuedGameAction>> GetQueuedActionsAsync()
{
var actions = await ReadFromPreferencesAsync<List<QueuedGameAction>>(QueuedActionsKey);
return actions ?? [];
}
public Task ClearQueuedActionsAsync()
{
Preferences.Default.Remove(QueuedActionsKey);
return Task.CompletedTask;
}
private static Task<T?> ReadFromPreferencesAsync<T>(string key)
{
if (!Preferences.Default.ContainsKey(key))
return Task.FromResult<T?>(default);
var json = Preferences.Default.Get(key, string.Empty);
if (string.IsNullOrWhiteSpace(json))
return Task.FromResult<T?>(default);
try
{
return Task.FromResult(JsonSerializer.Deserialize<T>(json, JsonOptions));
}
catch
{
return Task.FromResult<T?>(default);
}
}
}

View File

@@ -0,0 +1,178 @@
using System.Text.Json;
using Microsoft.AspNetCore.SignalR.Client;
using SlipItIn.Models;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.Services;
public class SignalRService : ISignalRService
{
private readonly IAppConfigurationService _configuration;
private readonly IAuthSessionService _authSession;
private HubConnection? _hubConnection;
public SignalRService(IAppConfigurationService configuration, IAuthSessionService authSession)
{
_configuration = configuration;
_authSession = authSession;
}
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
public event EventHandler<(int GameId, string LobbyCode)>? LobbyCreated;
public event EventHandler<GameStateDto>? GameStateUpdated;
public event EventHandler<int>? GameStarted;
public event EventHandler<PlayerHandDto>? PlayerHandUpdated;
public event EventHandler<SlipChallengeDto>? ChallengeReceived;
public event EventHandler<string>? ErrorReceived;
public event EventHandler<bool>? ConnectionStateChanged;
public async Task<bool> ConnectAsync(string token, CancellationToken cancellationToken = default)
{
if (IsConnected)
return true;
EnsureConnection(token);
if (_hubConnection is null)
return false;
try
{
await _hubConnection.StartAsync(cancellationToken);
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch (Exception ex)
{
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false;
}
}
public async Task DisconnectAsync()
{
if (_hubConnection is null)
return;
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false);
}
public Task CreateLobbyAsync() => InvokeAsync("CreateLobby");
public Task JoinLobbyAsync(string lobbyCode) => InvokeAsync("JoinLobby", lobbyCode);
public Task PlayerReadyAsync(int gameId, int playerId) => InvokeAsync("PlayerReady", gameId, playerId);
public Task StartGameAsync(int gameId) => InvokeAsync("StartGame", gameId);
public Task SubmitSlipAsync(int gameId, int playerId, int cardId)
=> InvokeAsync("SubmitSlip", gameId, playerId, cardId);
public Task ChallengeSlipAsync(int gameId, int challengingPlayerId, int targetCardId)
=> InvokeAsync("ChallengeSlip", gameId, challengingPlayerId, targetCardId);
public Task ResolveChallengeAsync(int challengeId, bool approved)
=> InvokeAsync("ResolveChallenge", challengeId, approved);
public Task RequestGameStateAsync(int gameId) => InvokeAsync("RequestGameState", gameId);
public async Task SendQueuedActionAsync(QueuedGameAction action)
{
if (_hubConnection is null)
return;
object?[] args;
try
{
var doc = JsonDocument.Parse(action.ArgumentsJson);
args = doc.RootElement.ValueKind == JsonValueKind.Array
? doc.RootElement.EnumerateArray().Select(ToObject).ToArray()
: [];
}
catch
{
args = [];
}
await InvokeAsync(action.Method, args);
}
private void EnsureConnection(string token)
{
if (_hubConnection is not null)
return;
_hubConnection = new HubConnectionBuilder()
.WithUrl(_configuration.HubUrl, options =>
{
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
})
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
.Build();
_hubConnection.Closed += _ =>
{
ConnectionStateChanged?.Invoke(this, false);
return Task.CompletedTask;
};
_hubConnection.Reconnected += _ =>
{
ConnectionStateChanged?.Invoke(this, true);
return Task.CompletedTask;
};
_hubConnection.On<JsonElement>("LobbyCreated", payload =>
{
var gameId = payload.TryGetProperty("gameId", out var gameIdElement) ? gameIdElement.GetInt32() : 0;
var lobbyCode = payload.TryGetProperty("lobbyCode", out var codeElement) ? codeElement.GetString() ?? string.Empty : string.Empty;
LobbyCreated?.Invoke(this, (gameId, lobbyCode));
});
_hubConnection.On<GameStateDto>("PlayerJoined", dto => GameStateUpdated?.Invoke(this, dto));
_hubConnection.On<GameStateDto>("GameStateUpdated", dto => GameStateUpdated?.Invoke(this, dto));
_hubConnection.On<JsonElement>("GameStarted", payload =>
{
var gameId = payload.TryGetProperty("gameId", out var gameIdElement) ? gameIdElement.GetInt32() : 0;
GameStarted?.Invoke(this, gameId);
});
_hubConnection.On<PlayerHandDto>("PlayerHandUpdated", dto => PlayerHandUpdated?.Invoke(this, dto));
_hubConnection.On<SlipChallengeDto>("ChallengeReceived", dto => ChallengeReceived?.Invoke(this, dto));
_hubConnection.On<JsonElement>("Error", payload =>
{
var message = payload.TryGetProperty("message", out var msgElement)
? msgElement.GetString() ?? "Unbekannter Fehler"
: "Unbekannter Fehler";
ErrorReceived?.Invoke(this, message);
});
}
private async Task InvokeAsync(string method, params object?[] args)
{
if (_hubConnection is null || _hubConnection.State != HubConnectionState.Connected)
throw new InvalidOperationException("Keine aktive SignalR-Verbindung.");
await _hubConnection.InvokeCoreAsync(method, args);
}
private static object? ToObject(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.String => element.GetString(),
JsonValueKind.Number when element.TryGetInt32(out var i) => i,
JsonValueKind.Number when element.TryGetInt64(out var l) => l,
JsonValueKind.Number when element.TryGetDouble(out var d) => d,
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => element.GetRawText()
};
}
}

View File

@@ -4,6 +4,7 @@
<TargetFrameworks>net10.0-android</TargetFrameworks>
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('linux'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
<LangVersion>preview</LangVersion>
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
@@ -13,7 +14,7 @@
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>SlipItIN</RootNamespace>
<RootNamespace>SlipItIn</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
@@ -100,6 +101,24 @@
<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="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SlipItIn.Shared\SlipItIn.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="Views\LoginPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
<MauiXaml Update="Views\RegisterPage.xaml">
<Generator>MSBuild:Compile</Generator>
</MauiXaml>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,33 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace SlipItIn.ViewModels;
public abstract partial class BaseViewModel : ObservableRecipient
{
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
protected async Task RunSafeAsync(Func<Task> action, string? busyMessage = null)
{
if (IsBusy)
return;
try
{
IsBusy = true;
StatusMessage = busyMessage ?? string.Empty;
await action();
}
catch (Exception ex)
{
StatusMessage = ex.Message;
}
finally
{
IsBusy = false;
}
}
}

View File

@@ -0,0 +1,177 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using SlipItIn.Messages;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.ViewModels;
public partial class GameBoardViewModel : BaseViewModel,
IRecipient<GameStateChangedMessage>,
IRecipient<PlayerHandChangedMessage>,
IRecipient<ChallengeReceivedMessage>,
IRecipient<ErrorOccurredMessage>
{
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
private readonly IAuthSessionService _authSession;
[ObservableProperty]
private int gameId;
[ObservableProperty]
private int currentRound;
[ObservableProperty]
private int roundTimeRemaining;
[ObservableProperty]
private bool isChallengeVisible;
[ObservableProperty]
private SlipChallengeDto? pendingChallenge;
public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
public GameBoardViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession)
{
_signalR = signalR;
_gameStateService = gameStateService;
_authSession = authSession;
WeakReferenceMessenger.Default.RegisterAll(this);
IsActive = true;
}
[RelayCommand]
private Task RefreshStateAsync()
{
if (GameId <= 0)
return Task.CompletedTask;
return _signalR.RequestGameStateAsync(GameId);
}
[RelayCommand]
private async Task SubmitSlipAsync(PlayerCardDto? card)
{
if (card is null)
return;
await RunSafeAsync(async () =>
{
var playerId = GetCurrentPlayerId();
if (playerId <= 0 || GameId <= 0)
throw new InvalidOperationException("Spielzustand nicht verfügbar.");
if (_signalR.IsConnected)
{
await _signalR.SubmitSlipAsync(GameId, playerId, card.CardId);
}
else
{
await _gameStateService.EnqueueActionAsync("SubmitSlip", GameId, playerId, card.CardId);
StatusMessage = "Offline: Aktion wurde zwischengespeichert.";
}
});
}
[RelayCommand]
private async Task ChallengeCardAsync(PlayerCardDto? card)
{
if (card is null)
return;
await RunSafeAsync(async () =>
{
var playerId = GetCurrentPlayerId();
if (playerId <= 0 || GameId <= 0)
throw new InvalidOperationException("Spielzustand nicht verfügbar.");
if (_signalR.IsConnected)
{
await _signalR.ChallengeSlipAsync(GameId, playerId, card.CardId);
}
else
{
await _gameStateService.EnqueueActionAsync("ChallengeSlip", GameId, playerId, card.CardId);
StatusMessage = "Offline: Aktion wurde zwischengespeichert.";
}
});
}
[RelayCommand]
private async Task ApproveChallengeAsync()
{
if (PendingChallenge is null)
return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, true);
PendingChallenge = null;
IsChallengeVisible = false;
}
[RelayCommand]
private async Task RejectChallengeAsync()
{
if (PendingChallenge is null)
return;
await _signalR.ResolveChallengeAsync(PendingChallenge.ChallengeId, false);
PendingChallenge = null;
IsChallengeVisible = false;
}
public void Receive(GameStateChangedMessage message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
GameId = message.Value.GameId;
CurrentRound = message.Value.CurrentRound;
RoundTimeRemaining = message.Value.RoundTimeRemaining;
var me = _authSession.CurrentUser?.Username;
var others = message.Value.Players
.Where(p => !string.Equals(p.Username, me, StringComparison.OrdinalIgnoreCase))
.ToList();
OtherPlayers.Clear();
foreach (var player in others)
OtherPlayers.Add(player);
});
}
public void Receive(PlayerHandChangedMessage message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
MyCards.Clear();
foreach (var card in message.Value.Cards)
MyCards.Add(card);
});
}
public void Receive(ChallengeReceivedMessage message)
{
PendingChallenge = message.Value;
IsChallengeVisible = true;
}
public void Receive(ErrorOccurredMessage message)
{
StatusMessage = message.Value;
}
private int GetCurrentPlayerId()
{
var username = _authSession.CurrentUser?.Username;
if (string.IsNullOrWhiteSpace(username))
return 0;
return _gameStateService.CurrentGameState?.Players
.FirstOrDefault(p => string.Equals(p.Username, username, StringComparison.OrdinalIgnoreCase))?.PlayerId ?? 0;
}
}

View File

@@ -0,0 +1,152 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using SlipItIn.Messages;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
namespace SlipItIn.ViewModels;
public partial class LobbyViewModel : BaseViewModel,
IRecipient<GameStateChangedMessage>,
IRecipient<LobbyCreatedMessage>,
IRecipient<GameStartedMessage>,
IRecipient<ErrorOccurredMessage>,
IRecipient<ConnectionStateChangedMessage>
{
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
private readonly IAuthSessionService _authSession;
private readonly IApiService _apiService;
[ObservableProperty]
private string lobbyCode = string.Empty;
[ObservableProperty]
private int gameId;
[ObservableProperty]
private bool isHost;
[ObservableProperty]
private bool isConnected;
public ObservableCollection<PlayerInfoDto> Players { get; } = [];
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
{
_signalR = signalR;
_gameStateService = gameStateService;
_authSession = authSession;
_apiService = apiService;
IsConnected = _signalR.IsConnected;
//WeakReferenceMessenger.Default.RegisterAll(this);
IsActive = true;
}
[RelayCommand]
private async Task CreateLobbyAsync()
{
await RunSafeAsync(async () =>
{
IsHost = true;
await _signalR.CreateLobbyAsync();
}, "Lobby wird erstellt...");
}
[RelayCommand]
private async Task JoinLobbyAsync()
{
await RunSafeAsync(async () =>
{
IsHost = false;
await _signalR.JoinLobbyAsync(LobbyCode);
}, "Lobby wird beigetreten...");
}
[RelayCommand]
private async Task SetReadyAsync()
{
await RunSafeAsync(async () =>
{
var currentPlayerId = GetCurrentPlayerId();
if (currentPlayerId <= 0 || GameId <= 0)
throw new InvalidOperationException("Spielstatus ist noch nicht verfügbar.");
await _signalR.PlayerReadyAsync(GameId, currentPlayerId);
});
}
[RelayCommand]
private async Task StartGameAsync()
{
await RunSafeAsync(async () =>
{
if (GameId <= 0)
throw new InvalidOperationException("Keine aktive Lobby gefunden.");
await _signalR.StartGameAsync(GameId);
});
}
[RelayCommand]
private async Task LogoutAsync()
{
await RunSafeAsync(async () =>
{
await _signalR.DisconnectAsync();
await _apiService.LogoutAsync();
await _gameStateService.ClearLocalGameDataAsync();
await Shell.Current.GoToAsync("//LoginPage");
});
}
public void Receive(GameStateChangedMessage message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
GameId = message.Value.GameId;
LobbyCode = message.Value.LobbyCode;
Players.Clear();
foreach (var player in message.Value.Players)
Players.Add(player);
});
}
public void Receive(LobbyCreatedMessage message)
{
MainThread.BeginInvokeOnMainThread(() =>
{
GameId = message.Value.GameId;
LobbyCode = message.Value.LobbyCode;
});
}
public async void Receive(GameStartedMessage message)
{
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync($"//{nameof(Views.GameBoardPage)}"));
}
public void Receive(ErrorOccurredMessage message)
{
StatusMessage = message.Value;
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
}
private int GetCurrentPlayerId()
{
var username = _authSession.CurrentUser?.Username;
if (string.IsNullOrWhiteSpace(username))
return 0;
return _gameStateService.CurrentGameState?.Players
.FirstOrDefault(p => string.Equals(p.Username, username, StringComparison.OrdinalIgnoreCase))?.PlayerId ?? 0;
}
}

View File

@@ -0,0 +1,61 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
using SlipItIn.Views;
namespace SlipItIn.ViewModels;
public partial class LoginViewModel : BaseViewModel
{
private readonly IApiService _apiService;
private readonly IAuthSessionService _authSession;
private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService;
[ObservableProperty]
public partial string Email { get; set; }
[ObservableProperty]
public partial string Password { get; set; }
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
{
_apiService = apiService;
_authSession = authSession;
_signalR = signalR;
_gameStateService = gameStateService;
}
[RelayCommand]
private Task GoToRegisterAsync() => Shell.Current.GoToAsync($"//{nameof(RegisterPage)}");
[RelayCommand]
private async Task LoginAsync()
{
await RunSafeAsync(async () =>
{
var response = await _apiService.LoginAsync(new LoginRequestDto
{
Email = Email,
Password = Password
});
await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token);
await _gameStateService.InitializeAsync();
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
}, "Anmeldung läuft...");
}
[RelayCommand]
public async Task InitializeAsync()
{
bool isAuthenticated = _authSession.IsAuthenticated;
if ( isAuthenticated)
{
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
}
}
}

View File

@@ -0,0 +1,51 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using SlipItIn.Services.Interfaces;
using SlipItIn.Shared.DTOs;
using SlipItIn.Views;
namespace SlipItIn.ViewModels;
public partial class RegisterViewModel : BaseViewModel
{
private readonly IApiService _apiService;
private readonly IAuthSessionService _authSession;
private readonly ISignalRService _signalR;
[ObservableProperty]
public partial string Username { get; set; } = string.Empty;
[ObservableProperty]
public partial string Email { get; set; } = string.Empty;
[ObservableProperty]
public partial string Password { get; set; } = string.Empty;
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
{
_apiService = apiService;
_authSession = authSession;
_signalR = signalR;
}
[RelayCommand]
private Task BackToLoginAsync() => Shell.Current.GoToAsync($"//{nameof(LoginPage)}");
[RelayCommand]
private async Task RegisterAsync()
{
await RunSafeAsync(async () =>
{
var response = await _apiService.RegisterAsync(new RegisterRequestDto
{
Username = Username,
Email = Email,
Password = Password
});
await _authSession.SetSessionAsync(response);
await _signalR.ConnectAsync(response.Token);
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
}, "Registrierung läuft...");
}
}

View File

@@ -0,0 +1,17 @@
<?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">
<Grid BackgroundColor="#88000000" IsVisible="{Binding IsChallengeVisible}" Padding="24">
<Frame VerticalOptions="Center" HorizontalOptions="Center">
<VerticalStackLayout Spacing="10">
<Label Text="Du wurdest beschuldigt" FontAttributes="Bold" />
<Label Text="{Binding PendingChallenge.ChallengeId, StringFormat='ChallengeId: {0}'}" />
<HorizontalStackLayout>
<Button Text="Bestätigen" Command="{Binding ApproveChallengeCommand}" />
<Button Text="Ablehnen" Command="{Binding RejectChallengeCommand}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</Grid>
</ContentView>

View File

@@ -0,0 +1,9 @@
namespace SlipItIn.Views;
public partial class ChallengeNotificationOverlay : ContentView
{
public ChallengeNotificationOverlay()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:SlipItIn.Views"
x:Class="SlipItIn.Views.GameBoardPage"
Title="Game Board">
<Grid>
<ScrollView>
<VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
<Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" />
<Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" />
<Label Text="Meine Phrasen" FontAttributes="Bold" />
<CollectionView ItemsSource="{Binding MyCards}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Margin="0,4" Padding="10">
<VerticalStackLayout>
<Label Text="{Binding Text}" />
<HorizontalStackLayout>
<Button Text="Slip" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.SubmitSlipCommand}" CommandParameter="{Binding .}" />
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
</HorizontalStackLayout>
</VerticalStackLayout>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Label Text="Andere Spieler" FontAttributes="Bold" />
<CollectionView ItemsSource="{Binding OtherPlayers}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Label Text="{Binding Username}" />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<ActivityIndicator IsVisible="{Binding IsBusy}" IsRunning="{Binding IsBusy}" />
<Label Text="{Binding StatusMessage}" TextColor="OrangeRed" />
</VerticalStackLayout>
</ScrollView>
<views:ChallengeNotificationOverlay />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,13 @@
using SlipItIn.Infrastructure;
using SlipItIn.ViewModels;
namespace SlipItIn.Views;
public partial class GameBoardPage : ContentPage
{
public GameBoardPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<GameBoardViewModel>();
}
}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="SlipItIn.Views.LobbyPage"
Title="Lobby">
<ScrollView>
<VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" />
<Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />
<HorizontalStackLayout Spacing="8">
<Entry Placeholder="Lobby Code" Text="{Binding LobbyCode}" HorizontalOptions="FillAndExpand" />
<Button Text="Beitreten" Command="{Binding JoinLobbyCommand}" />
</HorizontalStackLayout>
<Label Text="{Binding GameId, StringFormat='GameId: {0}'}" />
<Label Text="{Binding LobbyCode, StringFormat='Code: {0}'}" />
<Button Text="Bereit" Command="{Binding SetReadyCommand}" />
<Button Text="Spiel starten (Host)" Command="{Binding StartGameCommand}" IsVisible="{Binding IsHost}" />
<Label Text="Spieler" FontAttributes="Bold" />
<CollectionView ItemsSource="{Binding Players}">
<CollectionView.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="*,Auto,Auto" Padding="4">
<Label Grid.Column="0" Text="{Binding Username}" />
<Label Grid.Column="1" Text="Score:" />
<Label Grid.Column="2" Text="{Binding Score}" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<Button Text="Logout" Command="{Binding LogoutCommand}" />
<ActivityIndicator IsVisible="{Binding IsBusy}" IsRunning="{Binding IsBusy}" />
<Label Text="{Binding StatusMessage}" TextColor="OrangeRed" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,13 @@
using SlipItIn.Infrastructure;
using SlipItIn.ViewModels;
namespace SlipItIn.Views;
public partial class LobbyPage : ContentPage
{
public LobbyPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<LobbyViewModel>();
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:SlipItIn.ViewModels"
x:DataType="viewmodels:LoginViewModel"
x:Class="SlipItIn.Views.LoginPage"
Title="Login">
<ScrollView>
<VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Slip It In" FontSize="28" FontAttributes="Bold" />
<Label Text="Anmelden" FontSize="20" />
<Entry Placeholder="Email" Keyboard="Email" Text="{Binding Email}" />
<Entry Placeholder="Passwort" IsPassword="True" Text="{Binding Password}" />
<Button Text="Login" Command="{Binding LoginCommand}" />
<Button Text="Registrieren" Command="{Binding GoToRegisterCommand}" />
<ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
<Label Text="{Binding StatusMessage}" TextColor="OrangeRed" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,25 @@
using SlipItIn.Infrastructure;
using SlipItIn.ViewModels;
namespace SlipItIn.Views;
public partial class LoginPage : ContentPage
{
private readonly LoginViewModel _viewModel;
public LoginPage()
{
InitializeComponent();
BindingContext = _viewModel = ServiceHelper.GetRequiredService<LoginViewModel>();
}
protected override void OnNavigatedTo(NavigatedToEventArgs args)
{
base.OnNavigatedTo(args);
if (_viewModel.InitializeCommand.CanExecute(null))
{
_viewModel.InitializeCommand.Execute(null);
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:SlipItIn.ViewModels"
x:DataType="viewmodels:RegisterViewModel"
x:Class="SlipItIn.Views.RegisterPage"
Title="Registrieren">
<ScrollView>
<VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Neuen Account erstellen" FontSize="22" FontAttributes="Bold" />
<Entry Placeholder="Username" Text="{Binding Username}" />
<Entry Placeholder="Email" Keyboard="Email" Text="{Binding Email}" />
<Entry Placeholder="Passwort" IsPassword="True" Text="{Binding Password}" />
<Button Text="Registrieren" Command="{Binding RegisterCommand}" />
<Button Text="Zurück" Command="{Binding BackToLoginCommand}" />
<ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
<Label Text="{Binding StatusMessage}" TextColor="OrangeRed" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -0,0 +1,13 @@
using SlipItIn.Infrastructure;
using SlipItIn.ViewModels;
namespace SlipItIn.Views;
public partial class RegisterPage : ContentPage
{
public RegisterPage()
{
InitializeComponent();
BindingContext = ServiceHelper.GetRequiredService<RegisterViewModel>();
}
}