Synchronisierungs- und Verbindungsstatus verbessert

Neuer Nachrichtentyp SyncStateChangedMessage zur Übermittlung des Sync-Status über das MVVM-Messaging-System eingeführt. GameStateService sendet nach relevanten Aktionen den aktuellen Sync-Status. Methoden zur Synchronisierung sind jetzt thread-sicher und melden Statusänderungen laufend. Fehler beim Senden von Queue-Aktionen werden einzeln behandelt und führen zu Fehlermeldungen im Messenger. ViewModels aktualisieren neue Properties (IsSyncing, SyncStatusText, PendingSyncActions) bei Statusänderungen. XAML-Seiten zeigen Verbindungs- und Sync-Status sowie Offline-Aktionen an. SignalRService meldet Verbindungsstatus konsistenter, auch bei Fehlern und Disconnects.
This commit is contained in:
Tim Krampitz
2026-08-08 21:23:27 +02:00
parent 0f9cc1703a
commit 6f3c0eb716
7 changed files with 141 additions and 24 deletions

View File

@@ -0,0 +1,12 @@
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace SlipItIn.Messages;
public sealed record SyncStateInfo(bool IsSyncing, string StatusText, int PendingActions);
public class SyncStateChangedMessage : ValueChangedMessage<SyncStateInfo>
{
public SyncStateChangedMessage(SyncStateInfo value) : base(value)
{
}
}

View File

@@ -12,6 +12,7 @@ public class GameStateService : IGameStateService
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
private readonly ILocalStorageService _localStorage; private readonly ILocalStorageService _localStorage;
private readonly IMessenger _messenger; private readonly IMessenger _messenger;
private readonly SemaphoreSlim _syncLock = new(1, 1);
private readonly List<QueuedGameAction> _queuedActions = []; private readonly List<QueuedGameAction> _queuedActions = [];
@@ -53,16 +54,16 @@ public class GameStateService : IGameStateService
_signalR.ConnectionStateChanged += async (_, connected) => _signalR.ConnectionStateChanged += async (_, connected) =>
{ {
_messenger.Send(new ConnectionStateChangedMessage(connected)); _messenger.Send(new ConnectionStateChangedMessage(connected));
if (connected) if (!connected)
return;
try
{ {
try await ResyncAsync();
{ }
await ResyncAsync(); catch (Exception ex)
} {
catch (Exception ex) _messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
{
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
}
} }
}; };
} }
@@ -81,14 +82,32 @@ public class GameStateService : IGameStateService
if (CurrentPlayerHand is not null) if (CurrentPlayerHand is not null)
_messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand)); _messenger.Send(new PlayerHandChangedMessage(CurrentPlayerHand));
PublishSyncState(false, "Bereit", _queuedActions.Count);
} }
public async Task ResyncAsync() public async Task ResyncAsync()
{ {
if (CurrentGameState?.GameId > 0 && _signalR.IsConnected) await _syncLock.WaitAsync();
await _signalR.RequestGameStateAsync(CurrentGameState.GameId); try
{
PublishSyncState(true, "Syncing...", _queuedActions.Count);
await FlushQueuedActionsAsync(); if (CurrentGameState?.GameId > 0 && _signalR.IsConnected)
await _signalR.RequestGameStateAsync(CurrentGameState.GameId);
await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Synchronisiert", _queuedActions.Count);
}
catch
{
PublishSyncState(false, "Sync fehlgeschlagen", _queuedActions.Count);
throw;
}
finally
{
_syncLock.Release();
}
} }
public async Task EnqueueActionAsync(string method, params object[] arguments) public async Task EnqueueActionAsync(string method, params object[] arguments)
@@ -102,21 +121,22 @@ public class GameStateService : IGameStateService
_queuedActions.Add(action); _queuedActions.Add(action);
await _localStorage.SaveQueuedActionsAsync(_queuedActions); await _localStorage.SaveQueuedActionsAsync(_queuedActions);
PublishSyncState(false, "Offline-Aktion gespeichert", _queuedActions.Count);
} }
public async Task FlushQueuedActionsAsync() public async Task FlushQueuedActionsAsync()
{ {
if (!_signalR.IsConnected || _queuedActions.Count == 0) await _syncLock.WaitAsync();
return; try
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
{ {
await _signalR.SendQueuedActionAsync(action); PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
_queuedActions.Remove(action); await FlushQueuedActionsCoreAsync();
PublishSyncState(false, "Warteschlange synchronisiert", _queuedActions.Count);
}
finally
{
_syncLock.Release();
} }
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
} }
public async Task ClearLocalGameDataAsync() public async Task ClearLocalGameDataAsync()
@@ -129,5 +149,36 @@ public class GameStateService : IGameStateService
await _localStorage.ClearGameStateAsync(); await _localStorage.ClearGameStateAsync();
await _localStorage.ClearPlayerHandAsync(); await _localStorage.ClearPlayerHandAsync();
await _localStorage.ClearQueuedActionsAsync(); await _localStorage.ClearQueuedActionsAsync();
PublishSyncState(false, "Bereit", 0);
}
private async Task FlushQueuedActionsCoreAsync()
{
if (!_signalR.IsConnected || _queuedActions.Count == 0)
return;
var snapshot = _queuedActions.ToList();
foreach (var action in snapshot)
{
try
{
await _signalR.SendQueuedActionAsync(action);
_queuedActions.Remove(action);
PublishSyncState(true, "Warteschlange wird gesendet...", _queuedActions.Count);
}
catch (Exception ex)
{
_messenger.Send(new ErrorOccurredMessage($"Queue-Aktion fehlgeschlagen ({action.Method}): {ex.Message}"));
break;
}
}
await _localStorage.SaveQueuedActionsAsync(_queuedActions);
}
private void PublishSyncState(bool isSyncing, string statusText, int pendingActions)
{
_messenger.Send(new SyncStateChangedMessage(new SyncStateInfo(isSyncing, statusText, pendingActions)));
} }
} }

View File

@@ -38,6 +38,8 @@ public class SignalRService : ISignalRService
if (_hubConnection is null) if (_hubConnection is null)
return false; return false;
ConnectionStateChanged?.Invoke(this, false);
try try
{ {
await _hubConnection.StartAsync(cancellationToken); await _hubConnection.StartAsync(cancellationToken);
@@ -46,6 +48,7 @@ public class SignalRService : ISignalRService
} }
catch (Exception ex) catch (Exception ex)
{ {
ConnectionStateChanged?.Invoke(this, false);
ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}"); ErrorReceived?.Invoke(this, $"SignalR-Verbindung fehlgeschlagen: {ex.Message}");
return false; return false;
} }
@@ -56,7 +59,9 @@ public class SignalRService : ISignalRService
if (_hubConnection is null) if (_hubConnection is null)
return; return;
await _hubConnection.StopAsync(); if (_hubConnection.State is HubConnectionState.Connected or HubConnectionState.Connecting or HubConnectionState.Reconnecting)
await _hubConnection.StopAsync();
ConnectionStateChanged?.Invoke(this, false); ConnectionStateChanged?.Invoke(this, false);
} }

View File

@@ -12,7 +12,9 @@ public partial class GameBoardViewModel : BaseViewModel,
IRecipient<GameStateChangedMessage>, IRecipient<GameStateChangedMessage>,
IRecipient<PlayerHandChangedMessage>, IRecipient<PlayerHandChangedMessage>,
IRecipient<ChallengeReceivedMessage>, IRecipient<ChallengeReceivedMessage>,
IRecipient<ErrorOccurredMessage> IRecipient<ErrorOccurredMessage>,
IRecipient<SyncStateChangedMessage>,
IRecipient<ConnectionStateChangedMessage>
{ {
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
@@ -33,6 +35,18 @@ public partial class GameBoardViewModel : BaseViewModel,
[ObservableProperty] [ObservableProperty]
private SlipChallengeDto? pendingChallenge; private SlipChallengeDto? pendingChallenge;
[ObservableProperty]
private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerCardDto> MyCards { get; } = []; public ObservableCollection<PlayerCardDto> MyCards { get; } = [];
public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = []; public ObservableCollection<PlayerInfoDto> OtherPlayers { get; } = [];
@@ -42,6 +56,7 @@ public partial class GameBoardViewModel : BaseViewModel,
_gameStateService = gameStateService; _gameStateService = gameStateService;
_authSession = authSession; _authSession = authSession;
IsConnected = _signalR.IsConnected;
IsActive = true; IsActive = true;
} }
@@ -164,6 +179,18 @@ public partial class GameBoardViewModel : BaseViewModel,
StatusMessage = message.Value; StatusMessage = message.Value;
} }
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
}
public void Receive(ConnectionStateChangedMessage message)
{
IsConnected = message.Value;
}
private int GetCurrentPlayerId() private int GetCurrentPlayerId()
{ {
var username = _authSession.CurrentUser?.Username; var username = _authSession.CurrentUser?.Username;

View File

@@ -13,7 +13,8 @@ public partial class LobbyViewModel : BaseViewModel,
IRecipient<LobbyCreatedMessage>, IRecipient<LobbyCreatedMessage>,
IRecipient<GameStartedMessage>, IRecipient<GameStartedMessage>,
IRecipient<ErrorOccurredMessage>, IRecipient<ErrorOccurredMessage>,
IRecipient<ConnectionStateChangedMessage> IRecipient<ConnectionStateChangedMessage>,
IRecipient<SyncStateChangedMessage>
{ {
private readonly ISignalRService _signalR; private readonly ISignalRService _signalR;
private readonly IGameStateService _gameStateService; private readonly IGameStateService _gameStateService;
@@ -32,6 +33,15 @@ public partial class LobbyViewModel : BaseViewModel,
[ObservableProperty] [ObservableProperty]
private bool isConnected; private bool isConnected;
[ObservableProperty]
private bool isSyncing;
[ObservableProperty]
private string syncStatusText = "Bereit";
[ObservableProperty]
private int pendingSyncActions;
public ObservableCollection<PlayerInfoDto> Players { get; } = []; public ObservableCollection<PlayerInfoDto> Players { get; } = [];
public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService) public LobbyViewModel(ISignalRService signalR, IGameStateService gameStateService, IAuthSessionService authSession, IApiService apiService)
@@ -139,6 +149,13 @@ public partial class LobbyViewModel : BaseViewModel,
IsConnected = message.Value; IsConnected = message.Value;
} }
public void Receive(SyncStateChangedMessage message)
{
IsSyncing = message.Value.IsSyncing;
SyncStatusText = message.Value.StatusText;
PendingSyncActions = message.Value.PendingActions;
}
private int GetCurrentPlayerId() private int GetCurrentPlayerId()
{ {
var username = _authSession.CurrentUser?.Username; var username = _authSession.CurrentUser?.Username;

View File

@@ -10,6 +10,9 @@
<Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" /> <Label Text="Spielbrett" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" /> <Label Text="{Binding CurrentRound, StringFormat='Runde: {0}'}" />
<Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" /> <Label Text="{Binding RoundTimeRemaining, StringFormat='Verbleibende Zeit: {0}s'}" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" TextColor="Gray" />
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
<Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" /> <Button Text="Status aktualisieren" Command="{Binding RefreshStateCommand}" />

View File

@@ -7,6 +7,8 @@
<VerticalStackLayout Padding="24" Spacing="12"> <VerticalStackLayout Padding="24" Spacing="12">
<Label Text="Lobby" FontSize="24" FontAttributes="Bold" /> <Label Text="Lobby" FontSize="24" FontAttributes="Bold" />
<Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" /> <Label Text="{Binding IsConnected, StringFormat='Verbindung: {0}'}" />
<Label Text="{Binding SyncStatusText}" TextColor="DodgerBlue" IsVisible="{Binding IsSyncing}" />
<Label Text="{Binding PendingSyncActions, StringFormat='Offline-Aktionen: {0}'}" TextColor="Gray" />
<Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" /> <Button Text="Lobby erstellen" Command="{Binding CreateLobbyCommand}" />