Compare commits
10 Commits
openwiki/u
...
dede059983
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dede059983 | ||
|
|
8078ac2212 | ||
|
|
540cda102f | ||
| bf306a6c15 | |||
|
|
3296ecdfb3 | ||
|
|
6f3c0eb716 | ||
|
|
0f9cc1703a | ||
|
|
936c8bde28 | ||
|
|
2ce9bcd4c4 | ||
|
|
18a9d1ab74 |
6
.github/workflows/openwiki-update.yml
vendored
6
.github/workflows/openwiki-update.yml
vendored
@@ -49,7 +49,7 @@ jobs:
|
|||||||
run: git checkout -- .github/workflows/openwiki-update.yml
|
run: git checkout -- .github/workflows/openwiki-update.yml
|
||||||
|
|
||||||
- name: Create OpenWiki update pull request
|
- name: Create OpenWiki update pull request
|
||||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
uses: infinilabs/gitea-pr@v1
|
||||||
with:
|
with:
|
||||||
add-paths: |
|
add-paths: |
|
||||||
openwiki
|
openwiki
|
||||||
@@ -58,6 +58,10 @@ jobs:
|
|||||||
.github/workflows/openwiki-update.yml
|
.github/workflows/openwiki-update.yml
|
||||||
branch: openwiki/update
|
branch: openwiki/update
|
||||||
commit-message: "docs: update OpenWiki"
|
commit-message: "docs: update OpenWiki"
|
||||||
|
url: ${{ secrets.URL }}
|
||||||
|
token: ${{ secrets.TOKEN }}
|
||||||
|
base_branch: master
|
||||||
|
head_branch: openwiki/update
|
||||||
title: "docs: update OpenWiki"
|
title: "docs: update OpenWiki"
|
||||||
body: |
|
body: |
|
||||||
Automated OpenWiki documentation update.
|
Automated OpenWiki documentation update.
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
# Slip It In – Architektur-Updates (Phase 2b & 3b)
|
|
||||||
|
|
||||||
Dieses Dokument fasst die Sicherheits- und Stabilitäts-Erweiterungen zusammen, die dem ProjectPlan.md hinzugefügt wurden.
|
|
||||||
|
|
||||||
## 🔒 Backend-Sicherheit (Phase 2b)
|
|
||||||
|
|
||||||
### 1. JWT-Claims Validation im GameHub
|
|
||||||
- **Problem**: Clients könnten falsche PlayerId-Claims senden (Spoofing)
|
|
||||||
- **Lösung**: `GetAuthenticatedUserId()` extrahiert User-ID aus JWT-Claims
|
|
||||||
- **Implementierung**: Jede Hub-Methode validiert `Context.User?.FindFirst(ClaimTypes.NameIdentifier)`
|
|
||||||
|
|
||||||
### 2. IDbContextFactory statt Scoped DbContext
|
|
||||||
- **Problem**: Bei parallelen SignalR-Aufrufen kann es zu Race Conditions kommen
|
|
||||||
- **Lösung**: `IDbContextFactory<SlipItInDbContext>` erzeugt isolierte Sessions pro Aufruf
|
|
||||||
- **Vorteil**: Phrase-Transfer ist thread-safe, keine Conflicts bei 2 gleichzeitigen Challenges
|
|
||||||
|
|
||||||
### 3. Serverseitige Validierung
|
|
||||||
- **Regel**: Clients senden nur "Ich möchte X machen", Server prüft ALLES
|
|
||||||
- **Beispiel**: `SubmitSlipAsync()` prüft auf dem Server:
|
|
||||||
- ✅ Gehört die Phrase dem Spieler?
|
|
||||||
- ✅ Ist die Runde aktiv?
|
|
||||||
- ✅ Ist der Spieler noch im Spiel?
|
|
||||||
|
|
||||||
### 4. Daten-Isolation (GameStateDto vs. PlayerHandDto)
|
|
||||||
- **GameStateDto**: Öffentliche Daten (Username, Score, CardCount) → für ALLE sichtbar
|
|
||||||
- **PlayerHandDto**: Private Daten (Text der eigenen Karten) → nur für den Spieler selbst
|
|
||||||
- **Ergebnis**: Gegner sehen nicht, welche Phrasen ich habe!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 Client-Stabilität (Phase 3b)
|
|
||||||
|
|
||||||
### 1. WeakReferenceMessenger für Event-Entkopplung
|
|
||||||
- **Problem**: Services rufen ViewModels direkt auf → Memory Leaks wenn VM disposed
|
|
||||||
- **Lösung**: Services senden Messages via `WeakReferenceMessenger.Default.Send()`
|
|
||||||
- **Vorteil**: ViewModels können sich abmelden ohne zirkuläre Abhängigkeiten
|
|
||||||
|
|
||||||
**Beispiel:**
|
|
||||||
```csharp
|
|
||||||
// Service sendet Event
|
|
||||||
WeakReferenceMessenger.Default.Send(new GameStateChangedMessage(newState));
|
|
||||||
|
|
||||||
// ViewModel empfängt (mit auto-cleanup beim Dispose)
|
|
||||||
WeakReferenceMessenger.Default.Register<GameStateChangedMessage>(this, (r, m) =>
|
|
||||||
{
|
|
||||||
CurrentGameState = m.Value;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Automatischer Reconnect
|
|
||||||
- **Strategie**: Exponential Backoff (0s → 2s → 10s → 30s)
|
|
||||||
- **Pufferung**: Offline-Aktionen werden in lokale Queue geschrieben
|
|
||||||
- **Resync**: Nach Reconnect wird `ResyncAsync()` aufgerufen
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. SignalR-Verbindung getrennt
|
|
||||||
2. Client speichert "Slip zu Phrase X" in lokale Queue
|
|
||||||
3. Nach 2s Automatischer Reconnect-Versuch
|
|
||||||
4. Nach erfolgreichem Reconnect: Queued Actions werden abgesendet
|
|
||||||
|
|
||||||
### 3. State Restoration
|
|
||||||
- **App-Pause**: `GameStateService` speichert aktuellen State in `SecureStorage`
|
|
||||||
- **App-Resume**: `ResyncAsync()` wird aufgerufen
|
|
||||||
- ✅ Holt aktuellen GameState vom Server
|
|
||||||
- ✅ Sendet gepufferte Offline-Aktionen erneut
|
|
||||||
- ✅ UI zeigt "Syncing..." bis fertig
|
|
||||||
|
|
||||||
### 4. Dual-Layer Storage
|
|
||||||
- **In-Memory Layer**: `GameStateService` halten aktuellen State
|
|
||||||
- **Persistent Layer**: `SecureStorage`/`Preferences` speichern Backup
|
|
||||||
- **Fehler-Handling**: Nach App-Crash kann State aus Persistent Layer wiederhergestellt werden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Schnellreferenz: Neue Components
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
```
|
|
||||||
GameHub
|
|
||||||
├─ GetAuthenticatedUserId() → JWT-Validierung
|
|
||||||
├─ ValidatePlayerAccessAsync() → Authorization Check
|
|
||||||
└─ Alle Methoden nutzen IDbContextFactory
|
|
||||||
|
|
||||||
Program.cs
|
|
||||||
├─ AddDbContextFactory<SlipItInDbContext>()
|
|
||||||
├─ AddAuthentication(JwtBearerDefaults)
|
|
||||||
└─ AddSignalR() mit JWT-Filter
|
|
||||||
|
|
||||||
DTOs
|
|
||||||
├─ GameStateDto (öffentlich)
|
|
||||||
├─ PlayerHandDto (privat)
|
|
||||||
└─ Keine CardText in GameStateDto!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Client
|
|
||||||
```
|
|
||||||
GameStateService
|
|
||||||
├─ CurrentGameState (in-memory)
|
|
||||||
├─ CurrentPlayerHand (in-memory)
|
|
||||||
├─ UpdateGameStateAsync() → speichert + sendet Message
|
|
||||||
└─ ResyncAsync() → bei App-Resume
|
|
||||||
|
|
||||||
SignalRService
|
|
||||||
├─ ConnectAsync(token) mit Auto-Reconnect
|
|
||||||
├─ RequestGameStateAsync()
|
|
||||||
└─ On<> Handler senden Messages
|
|
||||||
|
|
||||||
Messages (WeakReferenceMessenger)
|
|
||||||
├─ GameStateChangedMessage
|
|
||||||
├─ PlayerHandChangedMessage
|
|
||||||
└─ ChallengeReceivedMessage
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Wichtigste Tests
|
|
||||||
|
|
||||||
| Test | Ziel |
|
|
||||||
|------|------|
|
|
||||||
| **JWT-Auth** | Hub lehnt Requests ohne gültiges Token ab |
|
|
||||||
| **Player-Access** | User A kann nicht auf Players von User B zugreifen |
|
|
||||||
| **Race Condition** | Phrase-Transfer ist thread-safe bei 2 gleichzeitigen Challenges |
|
|
||||||
| **Offline-Queue** | Aktionen während Netzwerk-Ausfall werden gepuffert und später gesendet |
|
|
||||||
| **Memory Leaks** | Keine Leaks wenn ViewModels disposed werden (WeakReference) |
|
|
||||||
| **State-Sync** | App-Resume synct korrekt mit Server-State |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Implementierungs-Reihenfolge
|
|
||||||
|
|
||||||
1. **Backend zuerst**: JWT-Auth + IDbContextFactory konfigurieren
|
|
||||||
2. **GameService erweitern**: Serverseitige Validierung in alle Methoden
|
|
||||||
3. **Client-Services**: GameStateService + SignalRService implementieren
|
|
||||||
4. **Messaging**: WeakReferenceMessenger in ViewModels integrieren
|
|
||||||
5. **Tests**: Integrationstest-Suite schreiben
|
|
||||||
6. **UI**: Views an neue Message-Events binden
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,7 @@
|
|||||||
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)
|
||||||
|
|||||||
89
SlipItIn.Server/Data/DbSeeder.cs
Normal file
89
SlipItIn.Server/Data/DbSeeder.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SlipItIn.Shared.Models;
|
||||||
|
|
||||||
|
namespace SlipItIn.Server.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Befüllt die Datenbank mit Testdaten (2 Testuser, einige Dutzend Phrasen),
|
||||||
|
/// wenn sie leer ist. Dient der Erleichterung manueller Tests.
|
||||||
|
/// </summary>
|
||||||
|
public static class DbSeeder
|
||||||
|
{
|
||||||
|
public static async Task SeedAsync(SlipItInDbContext context)
|
||||||
|
{
|
||||||
|
// Nur seeden, wenn die Datenbank leer ist
|
||||||
|
if (await context.Users.AnyAsync() || await context.Phrases.AnyAsync())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var alice = new User
|
||||||
|
{
|
||||||
|
Username = "alice",
|
||||||
|
Email = "alice@test.local",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
|
||||||
|
};
|
||||||
|
|
||||||
|
var bob = new User
|
||||||
|
{
|
||||||
|
Username = "bob",
|
||||||
|
Email = "bob@test.local",
|
||||||
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword("Test123!")
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Users.AddRange(alice, bob);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
|
||||||
|
var phrases = new[]
|
||||||
|
{
|
||||||
|
"Das hat mir meine Oma schon immer gesagt.",
|
||||||
|
"Ich hab da mal eine Doku gesehen.",
|
||||||
|
"Das ist wie damals in der Grundschule.",
|
||||||
|
"Das muss man sich mal auf der Zunge zergehen lassen.",
|
||||||
|
"Da kommt mir direkt ein Lied in den Sinn.",
|
||||||
|
"Das ist wie beim Autofahren: Bremsen kann man immer noch.",
|
||||||
|
"Ich habe das Gefühl, wir reden aneinander vorbei.",
|
||||||
|
"Das würde ich an deiner Stelle nochmal überdenken.",
|
||||||
|
"Ich glaube, da steckt mehr dahinter.",
|
||||||
|
"Also ich finde, das riecht verdächtig nach Montag.",
|
||||||
|
"Das schmeckt nach mehr.",
|
||||||
|
"Da ist der Wurm drin.",
|
||||||
|
"Das hat hand und Fuß.",
|
||||||
|
"Das ist ein zweischneidiges Schwert.",
|
||||||
|
"Da liegt der Hase im Pfeffer.",
|
||||||
|
"Das ist wie ein guter Wein – wird mit den Jahren besser.",
|
||||||
|
"Ich würde sagen: Glück im Unglück.",
|
||||||
|
"Das ist schon fast eine Wissenschaft für sich.",
|
||||||
|
"Man muss das Kind beim Namen nennen.",
|
||||||
|
"Das ist wie Weihnachten und Geburtstag zusammen.",
|
||||||
|
"Da geht mir das Herz auf.",
|
||||||
|
"Ich würde das jetzt nicht überbewerten.",
|
||||||
|
"Das kommt mir spanisch vor.",
|
||||||
|
"Das ist das Beste seit geschnittenem Brot.",
|
||||||
|
"Da bleibt kein Auge trocken.",
|
||||||
|
"Das ist wie Nägel mit Köpfen machen.",
|
||||||
|
"Ich habe da so eine Vermutung.",
|
||||||
|
"Das ist wie ein roter Faden.",
|
||||||
|
"Da platzt mir gleich der Kragen.",
|
||||||
|
"Ich bin da ganz bei dir.",
|
||||||
|
"Das muss man erstmal sacken lassen.",
|
||||||
|
"Das ist wie ein offenes Buch.",
|
||||||
|
"Da würde ich nicht drauf wetten.",
|
||||||
|
"Das ist wie eine Achterbahn der Gefühle.",
|
||||||
|
"Ich hab da ein ganz mieses Gefühl bei.",
|
||||||
|
"Das ist wie ein Dorn im Auge."
|
||||||
|
};
|
||||||
|
|
||||||
|
var random = new Random(42);
|
||||||
|
var creators = new[] { alice, bob };
|
||||||
|
|
||||||
|
foreach (var text in phrases)
|
||||||
|
{
|
||||||
|
context.Phrases.Add(new Phrase
|
||||||
|
{
|
||||||
|
Text = text,
|
||||||
|
CreatorId = creators[random.Next(creators.Length)].Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using SlipItIn.Server.Data;
|
using SlipItIn.Server.Data;
|
||||||
using SlipItIn.Server.Hubs;
|
using SlipItIn.Server.Hubs;
|
||||||
using SlipItIn.Server.Services;
|
using SlipItIn.Server.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -50,6 +51,24 @@ builder.Services
|
|||||||
context.Token = accessToken;
|
context.Token = accessToken;
|
||||||
}
|
}
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
OnTokenValidated = async context =>
|
||||||
|
{
|
||||||
|
var userIdClaim = context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
if (!int.TryParse(userIdClaim, out var userId))
|
||||||
|
{
|
||||||
|
context.Fail("Invalid user claim.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbFactory = context.HttpContext.RequestServices.GetRequiredService<IDbContextFactory<SlipItInDbContext>>();
|
||||||
|
await using var db = await dbFactory.CreateDbContextAsync();
|
||||||
|
|
||||||
|
var userExists = await db.Users.AnyAsync(u => u.Id == userId && u.IsActive);
|
||||||
|
if (!userExists)
|
||||||
|
{
|
||||||
|
context.Fail("User no longer exists or is inactive.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -79,21 +98,28 @@ var app = builder.Build();
|
|||||||
// Aspire Default Endpoints (Health Checks)
|
// Aspire Default Endpoints (Health Checks)
|
||||||
app.MapDefaultEndpoints();
|
app.MapDefaultEndpoints();
|
||||||
|
|
||||||
// Migrations anwenden
|
// Migrations anwenden und Testdaten seeden (nur wenn die Datenbank leer ist)
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<SlipItInDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
|
await DbSeeder.SeedAsync(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (app.Environment.IsDevelopment())
|
if (app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.UseCors("AllowAll");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseCors("AllowAll");
|
||||||
|
|
||||||
|
// Beim Emulator/geräteübergreifenden Zugriff würde die HTTPS-Umleitung
|
||||||
|
// den Client auf eine nicht vertrauenswürdige Dev-Zertifikats-URL schicken.
|
||||||
|
if (!app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
}
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -8,34 +8,57 @@ public partial class App : Application
|
|||||||
public App()
|
public App()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_ = InitializeAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Window CreateWindow(IActivationState? activationState)
|
protected override Window CreateWindow(IActivationState? activationState)
|
||||||
{
|
{
|
||||||
var window = new Window(new AppShell());
|
var window = new Window(new AppShell());
|
||||||
|
|
||||||
|
window.Created += async (_, _) =>
|
||||||
|
{
|
||||||
|
await InitializeSafeAsync();
|
||||||
|
};
|
||||||
|
|
||||||
window.Resumed += async (_, _) =>
|
window.Resumed += async (_, _) =>
|
||||||
{
|
{
|
||||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||||
await gameStateService.ResyncAsync();
|
await gameStateService.ResyncAsync();
|
||||||
};
|
};
|
||||||
|
|
||||||
return window;
|
return window;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task InitializeAsync()
|
private static async Task InitializeSafeAsync()
|
||||||
{
|
{
|
||||||
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
|
try
|
||||||
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
|
|
||||||
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
|
||||||
|
|
||||||
await authSession.InitializeAsync();
|
|
||||||
await gameStateService.InitializeAsync();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(authSession.AccessToken))
|
|
||||||
{
|
{
|
||||||
|
var authSession = ServiceHelper.GetRequiredService<IAuthSessionService>();
|
||||||
|
var signalR = ServiceHelper.GetRequiredService<ISignalRService>();
|
||||||
|
var gameStateService = ServiceHelper.GetRequiredService<IGameStateService>();
|
||||||
|
var apiService = ServiceHelper.GetRequiredService<IApiService>();
|
||||||
|
|
||||||
|
await authSession.InitializeAsync();
|
||||||
|
await gameStateService.InitializeAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(authSession.AccessToken))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var isSessionValid = await apiService.ValidateSessionAsync();
|
||||||
|
if (!isSessionValid)
|
||||||
|
{
|
||||||
|
await authSession.ClearSessionAsync();
|
||||||
|
await gameStateService.ClearLocalGameDataAsync();
|
||||||
|
await signalR.DisconnectAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var connected = await signalR.ConnectAsync(authSession.AccessToken);
|
var connected = await signalR.ConnectAsync(authSession.AccessToken);
|
||||||
if (connected)
|
if (connected)
|
||||||
await gameStateService.ResyncAsync();
|
await gameStateService.ResyncAsync();
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Start darf nicht abstürzen, wenn Session/Netzwerk fehlschlägt.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,12 @@
|
|||||||
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:views="clr-namespace:SlipItIn.Views">
|
xmlns:views="clr-namespace:SlipItIn.Views"
|
||||||
|
FlyoutBehavior="Disabled">
|
||||||
|
|
||||||
<ShellContent
|
<ShellContent
|
||||||
|
Title="Login"
|
||||||
Route="LoginPage"
|
Route="LoginPage"
|
||||||
ContentTemplate="{DataTemplate views: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>
|
</Shell>
|
||||||
|
|||||||
@@ -7,5 +7,8 @@ public partial class AppShell : Shell
|
|||||||
public AppShell()
|
public AppShell()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
|
Routing.RegisterRoute(nameof(RegisterPage), typeof(RegisterPage));
|
||||||
|
Routing.RegisterRoute(nameof(LobbyPage), typeof(LobbyPage));
|
||||||
|
Routing.RegisterRoute(nameof(GameBoardPage), typeof(GameBoardPage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
SlipItIn/Messages/SyncStateChangedMessage.cs
Normal file
12
SlipItIn/Messages/SyncStateChangedMessage.cs
Normal 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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
11
SlipItIn/Platforms/Android/AndroidManifest.Debug.xml
Normal file
11
SlipItIn/Platforms/Android/AndroidManifest.Debug.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/appicon"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:label="Slip It In"
|
||||||
|
android:usesCleartextTraffic="true" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
11
SlipItIn/Platforms/Android/AndroidManifest.Release.xml
Normal file
11
SlipItIn/Platforms/Android/AndroidManifest.Release.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/appicon"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:label="Slip It In">
|
||||||
|
</application>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
</manifest>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="de.krampitz.slipitin">
|
|
||||||
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:supportsRtl="true" android:label="Slip It In"></application>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
</manifest>
|
|
||||||
@@ -28,6 +28,23 @@ public class ApiService : IApiService
|
|||||||
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
|
public Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default)
|
||||||
=> PostAuthAsync("api/auth/login", request, cancellationToken);
|
=> PostAuthAsync("api/auth/login", request, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_authSession.AccessToken))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
AttachAuthHeader();
|
||||||
|
var response = await _httpClient.GetAsync("api/auth/me", cancellationToken);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Task LogoutAsync() => _authSession.ClearSessionAsync();
|
public Task LogoutAsync() => _authSession.ClearSessionAsync();
|
||||||
|
|
||||||
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)
|
private async Task<AuthResponseDto> PostAuthAsync<TRequest>(string endpoint, TRequest request, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ namespace SlipItIn.Services;
|
|||||||
|
|
||||||
public class AppConfigurationService : IAppConfigurationService
|
public class AppConfigurationService : IAppConfigurationService
|
||||||
{
|
{
|
||||||
|
private const string ApiBaseUrlPreferenceKey = "api_base_url";
|
||||||
private const string DefaultApiBaseUrl = "https://localhost:7274";
|
private const string DefaultApiBaseUrl = "https://localhost:7274";
|
||||||
|
private const string AndroidEmulatorApiBaseUrl = "http://10.0.2.2:5234";
|
||||||
|
|
||||||
public string ApiBaseUrl => DefaultApiBaseUrl;
|
public string ApiBaseUrl => Preferences.Default
|
||||||
|
.Get(ApiBaseUrlPreferenceKey, GetPlatformDefaultApiBaseUrl())
|
||||||
|
.TrimEnd('/');
|
||||||
|
|
||||||
public string HubUrl => $"{DefaultApiBaseUrl.TrimEnd('/')}/hubs/game";
|
public string HubUrl => $"{ApiBaseUrl}/hubs/game";
|
||||||
|
|
||||||
|
private static string GetPlatformDefaultApiBaseUrl()
|
||||||
|
{
|
||||||
|
return DeviceInfo.Platform == DevicePlatform.Android
|
||||||
|
? AndroidEmulatorApiBaseUrl
|
||||||
|
: DefaultApiBaseUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|
||||||
@@ -50,7 +51,21 @@ public class GameStateService : IGameStateService
|
|||||||
};
|
};
|
||||||
|
|
||||||
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
|
_signalR.ErrorReceived += (_, error) => _messenger.Send(new ErrorOccurredMessage(error));
|
||||||
_signalR.ConnectionStateChanged += (_, connected) => _messenger.Send(new ConnectionStateChangedMessage(connected));
|
_signalR.ConnectionStateChanged += async (_, connected) =>
|
||||||
|
{
|
||||||
|
_messenger.Send(new ConnectionStateChangedMessage(connected));
|
||||||
|
if (!connected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ResyncAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_messenger.Send(new ErrorOccurredMessage($"Resync fehlgeschlagen: {ex.Message}"));
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
@@ -67,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)
|
||||||
@@ -88,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()
|
||||||
@@ -115,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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ public interface IApiService
|
|||||||
{
|
{
|
||||||
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
Task<AuthResponseDto> RegisterAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
||||||
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
Task<AuthResponseDto> LoginAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> ValidateSessionAsync(CancellationToken cancellationToken = default);
|
||||||
Task LogoutAsync();
|
Task LogoutAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,11 +113,17 @@ public class SignalRService : ISignalRService
|
|||||||
_hubConnection = new HubConnectionBuilder()
|
_hubConnection = new HubConnectionBuilder()
|
||||||
.WithUrl(_configuration.HubUrl, options =>
|
.WithUrl(_configuration.HubUrl, options =>
|
||||||
{
|
{
|
||||||
options.AccessTokenProvider = () => Task.FromResult<string?>(token);
|
options.AccessTokenProvider = () => Task.FromResult<string?>(_authSession.AccessToken ?? token);
|
||||||
})
|
})
|
||||||
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
|
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)])
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
_hubConnection.Reconnecting += _ =>
|
||||||
|
{
|
||||||
|
ConnectionStateChanged?.Invoke(this, false);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
_hubConnection.Closed += _ =>
|
_hubConnection.Closed += _ =>
|
||||||
{
|
{
|
||||||
ConnectionStateChanged?.Invoke(this, false);
|
ConnectionStateChanged?.Invoke(this, false);
|
||||||
|
|||||||
@@ -110,6 +110,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<MauiXaml Update="MainPage.xaml">
|
||||||
|
<Generator>MSBuild:Compile</Generator>
|
||||||
|
</MauiXaml>
|
||||||
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
|
<MauiXaml Update="Views\ChallengeNotificationOverlay.xaml">
|
||||||
<Generator>MSBuild:Compile</Generator>
|
<Generator>MSBuild:Compile</Generator>
|
||||||
</MauiXaml>
|
</MauiXaml>
|
||||||
@@ -121,4 +124,12 @@
|
|||||||
</MauiXaml>
|
</MauiXaml>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Debug|net10.0-android'">
|
||||||
|
<AndroidManifest>Platforms\Android\AndroidManifest.Debug.xml</AndroidManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Release|net10.0-android'">
|
||||||
|
<AndroidManifest>Platforms\Android\AndroidManifest.Release.xml</AndroidManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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,7 +56,7 @@ public partial class GameBoardViewModel : BaseViewModel,
|
|||||||
_gameStateService = gameStateService;
|
_gameStateService = gameStateService;
|
||||||
_authSession = authSession;
|
_authSession = authSession;
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.RegisterAll(this);
|
IsConnected = _signalR.IsConnected;
|
||||||
IsActive = true;
|
IsActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,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;
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -42,7 +52,6 @@ public partial class LobbyViewModel : BaseViewModel,
|
|||||||
_apiService = apiService;
|
_apiService = apiService;
|
||||||
|
|
||||||
IsConnected = _signalR.IsConnected;
|
IsConnected = _signalR.IsConnected;
|
||||||
//WeakReferenceMessenger.Default.RegisterAll(this);
|
|
||||||
IsActive = true;
|
IsActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +136,7 @@ public partial class LobbyViewModel : BaseViewModel,
|
|||||||
|
|
||||||
public async void Receive(GameStartedMessage message)
|
public async void Receive(GameStartedMessage message)
|
||||||
{
|
{
|
||||||
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync($"//{nameof(Views.GameBoardPage)}"));
|
await MainThread.InvokeOnMainThreadAsync(() => Shell.Current.GoToAsync(nameof(Views.GameBoardPage)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Receive(ErrorOccurredMessage message)
|
public void Receive(ErrorOccurredMessage message)
|
||||||
@@ -140,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;
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ public partial class LoginViewModel : BaseViewModel
|
|||||||
private readonly IGameStateService _gameStateService;
|
private readonly IGameStateService _gameStateService;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Email { get; set; }
|
private string email = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Password { get; set; }
|
private string password = string.Empty;
|
||||||
|
|
||||||
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
|
public LoginViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR, IGameStateService gameStateService)
|
||||||
{
|
{
|
||||||
@@ -28,7 +28,7 @@ public partial class LoginViewModel : BaseViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private Task GoToRegisterAsync() => Shell.Current.GoToAsync($"//{nameof(RegisterPage)}");
|
private Task GoToRegisterAsync() => Shell.Current.GoToAsync(nameof(RegisterPage));
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LoginAsync()
|
private async Task LoginAsync()
|
||||||
@@ -42,20 +42,37 @@ public partial class LoginViewModel : BaseViewModel
|
|||||||
});
|
});
|
||||||
|
|
||||||
await _authSession.SetSessionAsync(response);
|
await _authSession.SetSessionAsync(response);
|
||||||
await _signalR.ConnectAsync(response.Token);
|
|
||||||
|
var connected = await _signalR.ConnectAsync(response.Token);
|
||||||
|
if (!connected)
|
||||||
|
throw new InvalidOperationException("Verbindung zum Spielserver fehlgeschlagen.");
|
||||||
|
|
||||||
await _gameStateService.InitializeAsync();
|
await _gameStateService.InitializeAsync();
|
||||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||||
}, "Anmeldung läuft...");
|
}, "Anmeldung läuft...");
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
bool isAuthenticated = _authSession.IsAuthenticated;
|
if (!_authSession.IsAuthenticated)
|
||||||
|
return;
|
||||||
|
|
||||||
if ( isAuthenticated)
|
var isSessionValid = await _apiService.ValidateSessionAsync();
|
||||||
|
if (!isSessionValid)
|
||||||
{
|
{
|
||||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
await _authSession.ClearSessionAsync();
|
||||||
|
await _gameStateService.ClearLocalGameDataAsync();
|
||||||
|
await _signalR.DisconnectAsync();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var token = _authSession.AccessToken;
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var connected = await _signalR.ConnectAsync(token);
|
||||||
|
if (connected)
|
||||||
|
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ public partial class RegisterViewModel : BaseViewModel
|
|||||||
private readonly ISignalRService _signalR;
|
private readonly ISignalRService _signalR;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Username { get; set; } = string.Empty;
|
private string username = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Email { get; set; } = string.Empty;
|
private string email = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string Password { get; set; } = string.Empty;
|
private string password = string.Empty;
|
||||||
|
|
||||||
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
|
public RegisterViewModel(IApiService apiService, IAuthSessionService authSession, ISignalRService signalR)
|
||||||
{
|
{
|
||||||
@@ -45,7 +45,7 @@ public partial class RegisterViewModel : BaseViewModel
|
|||||||
|
|
||||||
await _authSession.SetSessionAsync(response);
|
await _authSession.SetSessionAsync(response);
|
||||||
await _signalR.ConnectAsync(response.Token);
|
await _signalR.ConnectAsync(response.Token);
|
||||||
await Shell.Current.GoToAsync($"//{nameof(LobbyPage)}");
|
await Shell.Current.GoToAsync(nameof(LobbyPage));
|
||||||
}, "Registrierung läuft...");
|
}, "Registrierung läuft...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}" />
|
||||||
|
|
||||||
@@ -17,7 +20,7 @@
|
|||||||
<CollectionView ItemsSource="{Binding MyCards}">
|
<CollectionView ItemsSource="{Binding MyCards}">
|
||||||
<CollectionView.ItemTemplate>
|
<CollectionView.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Frame Margin="0,4" Padding="10">
|
<Border Margin="0,4" Padding="10" Stroke="LightGray" StrokeThickness="1" StrokeShape="RoundRectangle 10" BackgroundColor="White">
|
||||||
<VerticalStackLayout>
|
<VerticalStackLayout>
|
||||||
<Label Text="{Binding Text}" />
|
<Label Text="{Binding Text}" />
|
||||||
<HorizontalStackLayout>
|
<HorizontalStackLayout>
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
|
<Button Text="Beschuldigen" Command="{Binding Source={RelativeSource AncestorType={x:Type ContentPage}}, Path=BindingContext.ChallengeCardCommand}" CommandParameter="{Binding .}" />
|
||||||
</HorizontalStackLayout>
|
</HorizontalStackLayout>
|
||||||
</VerticalStackLayout>
|
</VerticalStackLayout>
|
||||||
</Frame>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</CollectionView.ItemTemplate>
|
</CollectionView.ItemTemplate>
|
||||||
</CollectionView>
|
</CollectionView>
|
||||||
|
|||||||
@@ -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}" />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user