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:
@@ -1,7 +1,8 @@
|
|||||||
var builder = DistributedApplication.CreateBuilder(args);
|
var builder = DistributedApplication.CreateBuilder(args);
|
||||||
|
|
||||||
var db = builder.AddPostgres("pgsql")
|
var db = builder.AddPostgres("pgsql")
|
||||||
.AddDatabase("postgresdb");
|
.AddDatabase("postgresdb")
|
||||||
|
;
|
||||||
|
|
||||||
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
|
var server = builder.AddProject<Projects.SlipItIn_Server>("server")
|
||||||
.WithReference(db)
|
.WithReference(db)
|
||||||
|
|||||||
@@ -29,11 +29,15 @@ public class GameService : IGameService
|
|||||||
Status = GameStatus.Lobby
|
Status = GameStatus.Lobby
|
||||||
};
|
};
|
||||||
|
|
||||||
context.Games.Add(game);
|
|
||||||
|
|
||||||
// Host wird automatisch als erster Spieler hinzugefügt
|
// Host wird automatisch als erster Spieler hinzugefügt
|
||||||
var hostPlayer = new Player { UserId = host.Id, GameId = game.Id, ConnectionId = connectionId };
|
var hostPlayer = new Player { UserId = host.Id, ConnectionId = connectionId };
|
||||||
context.Players.Add(hostPlayer);
|
|
||||||
|
// 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();
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using SlipItIn.Infrastructure;
|
||||||
|
using SlipItIn.Services.Interfaces;
|
||||||
|
|
||||||
namespace SlipItIn;
|
namespace SlipItIn;
|
||||||
|
|
||||||
@@ -7,10 +8,34 @@ public partial class App : Application
|
|||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
_ = InitializeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Window CreateWindow(IActivationState? activationState)
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,22 @@
|
|||||||
x:Class="SlipItIn.AppShell"
|
x:Class="SlipItIn.AppShell"
|
||||||
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
xmlns:local="clr-namespace:SlipItIn"
|
xmlns:views="clr-namespace:SlipItIn.Views">
|
||||||
Title="Slip It In">
|
|
||||||
|
|
||||||
<ShellContent
|
<ShellContent
|
||||||
Title="Home"
|
Route="LoginPage"
|
||||||
ContentTemplate="{DataTemplate local:MainPage}"
|
ContentTemplate="{DataTemplate views:LoginPage}" />
|
||||||
Route="MainPage" />
|
|
||||||
|
<ShellContent
|
||||||
|
Route="RegisterPage"
|
||||||
|
ContentTemplate="{DataTemplate views:RegisterPage}" />
|
||||||
|
|
||||||
|
<ShellContent
|
||||||
|
Route="LobbyPage"
|
||||||
|
ContentTemplate="{DataTemplate views:LobbyPage}" />
|
||||||
|
|
||||||
|
<ShellContent
|
||||||
|
Route="GameBoardPage"
|
||||||
|
ContentTemplate="{DataTemplate views:GameBoardPage}" />
|
||||||
|
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace SlipItIn;
|
using SlipItIn.Views;
|
||||||
|
|
||||||
|
namespace SlipItIn;
|
||||||
|
|
||||||
public partial class AppShell : Shell
|
public partial class AppShell : Shell
|
||||||
{
|
{
|
||||||
|
|||||||
16
SlipItIn/Infrastructure/ServiceHelper.cs
Normal file
16
SlipItIn/Infrastructure/ServiceHelper.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 .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>
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using SlipItIn.Infrastructure;
|
||||||
|
using SlipItIn.Services;
|
||||||
|
using SlipItIn.Services.Interfaces;
|
||||||
|
using SlipItIn.ViewModels;
|
||||||
|
|
||||||
namespace SlipItIn;
|
namespace SlipItIn;
|
||||||
|
|
||||||
@@ -15,10 +19,24 @@ public static class MauiProgram
|
|||||||
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
|
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
|
#if DEBUG
|
||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
return builder.Build();
|
var app = builder.Build();
|
||||||
|
ServiceHelper.Services = app.Services;
|
||||||
|
return app;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
SlipItIn/Messages/ChallengeReceivedMessage.cs
Normal file
11
SlipItIn/Messages/ChallengeReceivedMessage.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SlipItIn/Messages/ConnectionStateChangedMessage.cs
Normal file
10
SlipItIn/Messages/ConnectionStateChangedMessage.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
|
||||||
|
namespace SlipItIn.Messages;
|
||||||
|
|
||||||
|
public class ConnectionStateChangedMessage : ValueChangedMessage<bool>
|
||||||
|
{
|
||||||
|
public ConnectionStateChangedMessage(bool value) : base(value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SlipItIn/Messages/ErrorOccurredMessage.cs
Normal file
10
SlipItIn/Messages/ErrorOccurredMessage.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
|
||||||
|
namespace SlipItIn.Messages;
|
||||||
|
|
||||||
|
public class ErrorOccurredMessage : ValueChangedMessage<string>
|
||||||
|
{
|
||||||
|
public ErrorOccurredMessage(string value) : base(value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SlipItIn/Messages/GameStartedMessage.cs
Normal file
10
SlipItIn/Messages/GameStartedMessage.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||||
|
|
||||||
|
namespace SlipItIn.Messages;
|
||||||
|
|
||||||
|
public class GameStartedMessage : ValueChangedMessage<int>
|
||||||
|
{
|
||||||
|
public GameStartedMessage(int value) : base(value)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SlipItIn/Messages/GameStateChangedMessage.cs
Normal file
11
SlipItIn/Messages/GameStateChangedMessage.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SlipItIn/Messages/LobbyCreatedMessage.cs
Normal file
10
SlipItIn/Messages/LobbyCreatedMessage.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SlipItIn/Messages/PlayerHandChangedMessage.cs
Normal file
11
SlipItIn/Messages/PlayerHandChangedMessage.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SlipItIn/Models/QueuedGameAction.cs
Normal file
8
SlipItIn/Models/QueuedGameAction.cs
Normal 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;
|
||||||
|
}
|
||||||
84
SlipItIn/Services/ApiService.cs
Normal file
84
SlipItIn/Services/ApiService.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
SlipItIn/Services/AppConfigurationService.cs
Normal file
12
SlipItIn/Services/AppConfigurationService.cs
Normal 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";
|
||||||
|
}
|
||||||
42
SlipItIn/Services/AuthSessionService.cs
Normal file
42
SlipItIn/Services/AuthSessionService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
119
SlipItIn/Services/GameStateService.cs
Normal file
119
SlipItIn/Services/GameStateService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
SlipItIn/Services/Interfaces/IApiService.cs
Normal file
10
SlipItIn/Services/Interfaces/IApiService.cs
Normal 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();
|
||||||
|
}
|
||||||
7
SlipItIn/Services/Interfaces/IAppConfigurationService.cs
Normal file
7
SlipItIn/Services/Interfaces/IAppConfigurationService.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace SlipItIn.Services.Interfaces;
|
||||||
|
|
||||||
|
public interface IAppConfigurationService
|
||||||
|
{
|
||||||
|
string ApiBaseUrl { get; }
|
||||||
|
string HubUrl { get; }
|
||||||
|
}
|
||||||
14
SlipItIn/Services/Interfaces/IAuthSessionService.cs
Normal file
14
SlipItIn/Services/Interfaces/IAuthSessionService.cs
Normal 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();
|
||||||
|
}
|
||||||
17
SlipItIn/Services/Interfaces/IGameStateService.cs
Normal file
17
SlipItIn/Services/Interfaces/IGameStateService.cs
Normal 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();
|
||||||
|
}
|
||||||
27
SlipItIn/Services/Interfaces/ILocalStorageService.cs
Normal file
27
SlipItIn/Services/Interfaces/ILocalStorageService.cs
Normal 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();
|
||||||
|
}
|
||||||
31
SlipItIn/Services/Interfaces/ISignalRService.cs
Normal file
31
SlipItIn/Services/Interfaces/ISignalRService.cs
Normal 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);
|
||||||
|
}
|
||||||
110
SlipItIn/Services/LocalStorageService.cs
Normal file
110
SlipItIn/Services/LocalStorageService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
178
SlipItIn/Services/SignalRService.cs
Normal file
178
SlipItIn/Services/SignalRService.cs
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
<TargetFrameworks>net10.0-android</TargetFrameworks>
|
||||||
<TargetFrameworks Condition="!$([MSBuild]::IsOSPlatform('linux'))">$(TargetFrameworks);net10.0-ios;net10.0-maccatalyst</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>
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net10.0-windows10.0.19041.0</TargetFrameworks>
|
||||||
|
<LangVersion>preview</LangVersion>
|
||||||
|
|
||||||
<!-- Note for MacCatalyst:
|
<!-- Note for MacCatalyst:
|
||||||
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
|
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> -->
|
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
|
||||||
|
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<RootNamespace>SlipItIN</RootNamespace>
|
<RootNamespace>SlipItIn</RootNamespace>
|
||||||
<UseMaui>true</UseMaui>
|
<UseMaui>true</UseMaui>
|
||||||
<SingleProject>true</SingleProject>
|
<SingleProject>true</SingleProject>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
@@ -100,6 +101,24 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.90" />
|
<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="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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
33
SlipItIn/ViewModels/BaseViewModel.cs
Normal file
33
SlipItIn/ViewModels/BaseViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
SlipItIn/ViewModels/GameBoardViewModel.cs
Normal file
177
SlipItIn/ViewModels/GameBoardViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
SlipItIn/ViewModels/LobbyViewModel.cs
Normal file
152
SlipItIn/ViewModels/LobbyViewModel.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
SlipItIn/ViewModels/LoginViewModel.cs
Normal file
61
SlipItIn/ViewModels/LoginViewModel.cs
Normal 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)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
SlipItIn/ViewModels/RegisterViewModel.cs
Normal file
51
SlipItIn/ViewModels/RegisterViewModel.cs
Normal 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...");
|
||||||
|
}
|
||||||
|
}
|
||||||
17
SlipItIn/Views/ChallengeNotificationOverlay.xaml
Normal file
17
SlipItIn/Views/ChallengeNotificationOverlay.xaml
Normal 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>
|
||||||
9
SlipItIn/Views/ChallengeNotificationOverlay.xaml.cs
Normal file
9
SlipItIn/Views/ChallengeNotificationOverlay.xaml.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace SlipItIn.Views;
|
||||||
|
|
||||||
|
public partial class ChallengeNotificationOverlay : ContentView
|
||||||
|
{
|
||||||
|
public ChallengeNotificationOverlay()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
SlipItIn/Views/GameBoardPage.xaml
Normal file
49
SlipItIn/Views/GameBoardPage.xaml
Normal 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>
|
||||||
13
SlipItIn/Views/GameBoardPage.xaml.cs
Normal file
13
SlipItIn/Views/GameBoardPage.xaml.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
SlipItIn/Views/LobbyPage.xaml
Normal file
42
SlipItIn/Views/LobbyPage.xaml
Normal 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>
|
||||||
13
SlipItIn/Views/LobbyPage.xaml.cs
Normal file
13
SlipItIn/Views/LobbyPage.xaml.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SlipItIn/Views/LoginPage.xaml
Normal file
23
SlipItIn/Views/LoginPage.xaml
Normal 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>
|
||||||
25
SlipItIn/Views/LoginPage.xaml.cs
Normal file
25
SlipItIn/Views/LoginPage.xaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
SlipItIn/Views/RegisterPage.xaml
Normal file
23
SlipItIn/Views/RegisterPage.xaml
Normal 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>
|
||||||
13
SlipItIn/Views/RegisterPage.xaml.cs
Normal file
13
SlipItIn/Views/RegisterPage.xaml.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user