init 1.0.0

This commit is contained in:
2026-06-19 00:42:00 +02:00
commit 5ca960746b
67 changed files with 3261 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Vorlage als .env kopieren und Werte eintragen.
# Die echte .env kommt NICHT ins Repo.
# Mindestens 32 zufällige Zeichen z.B. generiert mit:
# openssl rand -base64 32
JWT_SECRET=hier-einen-langen-zufaelligen-wert-eintragen
+77
View File
@@ -0,0 +1,77 @@
# ── Build-Ausgaben ────────────────────────────────────────────────────────────
bin/
obj/
out/
# ── .NET ──────────────────────────────────────────────────────────────────────
*.user
*.suo
*.userosscache
*.sln.docstates
.vs/
*.rsuser
# NuGet
*.nupkg
*.snupkg
project.lock.json
project.fragment.lock.json
artifacts/
packages/
# Publish-Ausgaben
publish/
**/Properties/PublishProfiles/
# ── Sensible Laufzeit-Daten (NIEMALS ins Repo!) ───────────────────────────────
# Sync-Schlüssel und Geräte-IDs
*.key
device.id
auth.token
server.txt
# Lokale Datenbanken
*.db
*.db-journal
*.db-wal
*.db-shm
# Backups der lokalen DB
*.db.backup-*
# ── API / Docker ──────────────────────────────────────────────────────────────
# Lokale Datenhaltung des Servers
LehrerApp.Api/data/
docker/data/
# Umgebungsvariablen .env.example ins Repo, .env nicht
.env
!.env.example
# ── VS Code ───────────────────────────────────────────────────────────────────
.vscode/settings.json
# launch.json und tasks.json BLEIBEN im Repo (für alle Entwickler)
!.vscode/launch.json
!.vscode/tasks.json
# ── macOS ─────────────────────────────────────────────────────────────────────
.DS_Store
.AppleDouble
.LSOverride
._*
.Spotlight-V100
.Trashes
# ── Windows ───────────────────────────────────────────────────────────────────
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# ── Rider / JetBrains ─────────────────────────────────────────────────────────
.idea/
*.sln.iml
# ── Logs ──────────────────────────────────────────────────────────────────────
*.log
logs/
+52
View File
@@ -0,0 +1,52 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Desktop App starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "Desktop bauen",
"program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll",
"args": [],
"cwd": "${workspaceFolder}/LehrerApp.Desktop",
"stopAtEntry": false,
"console": "internalConsole",
"env": {
"DOTNET_ENVIRONMENT": "Development"
}
},
{
"name": "API Server starten",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "API bauen",
"program": "${workspaceFolder}/LehrerApp.Api/bin/Debug/net10.0/LehrerApp.Api.dll",
"args": [],
"cwd": "${workspaceFolder}/LehrerApp.Api",
"stopAtEntry": false,
"console": "internalConsole",
"env": {
"DOTNET_ENVIRONMENT": "Development",
"JWT_SECRET": "dev-secret-mindestens-32-zeichen-lang!!",
"Api__Port": "5000",
"Api__DataPath": "./data"
}
},
{
"name": "Desktop + API (gleichzeitig)",
"configurations": [
"Desktop App starten",
"API Server starten"
]
}
],
"compounds": [
{
"name": "Desktop + API (gleichzeitig)",
"configurations": [
"Desktop App starten",
"API Server starten"
]
}
]
}
+57
View File
@@ -0,0 +1,57 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Desktop bauen",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LehrerApp.Desktop/LehrerApp.Desktop.csproj",
"--configuration", "Debug",
"--verbosity", "minimal"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "API bauen",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LehrerApp.Api/LehrerApp.Api.csproj",
"--configuration", "Debug",
"--verbosity", "minimal"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "Solution bauen",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/LehrerApp.sln",
"--configuration", "Debug",
"--verbosity", "minimal"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "NuGet restore",
"command": "dotnet",
"type": "process",
"args": [
"restore",
"${workspaceFolder}/LehrerApp.sln"
],
"problemMatcher": []
}
]
}
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<!-- CS8618: falsch-positiv durch [ObservableProperty] Source Generator -->
<NoWarn>CS8618</NoWarn>
</PropertyGroup>
</Project>
+29
View File
@@ -0,0 +1,29 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Avalonia 12.0.4 aktuell stabil (April 2026) -->
<PackageVersion Include="Avalonia" Version="12.0.4" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.4" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<!-- MVVM & DI .NET 10 kompatibel -->
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<!-- Datenbank -->
<PackageVersion Include="LiteDB" Version="5.0.21" />
<!-- API -->
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.0" />
<!-- Tests -->
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
</ItemGroup>
</Project>
+133
View File
@@ -0,0 +1,133 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using LehrerApp.Core.Models;
using LehrerApp.Sync.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
namespace LehrerApp.Api;
public static class Endpoints
{
// ── Auth ──────────────────────────────────────────────────────────────────
public static void MapAuthEndpoints(this WebApplication app, string secret)
{
app.MapPost("/api/auth/login", (LoginRequest req) =>
{
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
return Results.Unauthorized();
// TODO: Passwort gegen DB prüfen
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
});
app.MapPost("/api/auth/register", (RegisterRequest req) =>
{
if (req.Password.Length < 12)
return Results.BadRequest("Passwort mind. 12 Zeichen.");
// TODO: User anlegen, Passwort hashen (BCrypt)
return Results.Ok(new { token = Jwt(req.Username, secret), userId = req.Username });
});
}
// ── Sync ──────────────────────────────────────────────────────────────────
public static void MapSyncEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/sync").RequireAuthorization();
g.MapPost("/push", ([FromBody] List<SyncEvent> events,
ClaimsPrincipal user, EventStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(store.Push(uid, events));
});
g.MapGet("/pull", ([FromQuery] long since, [FromQuery] string deviceId,
ClaimsPrincipal user, EventStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(store.Pull(uid, since, deviceId));
});
g.MapGet("/status", (ClaimsPrincipal user) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(new { userId = uid, timestamp = DateTime.UtcNow });
});
}
// ── Snapshot (Device-Pairing) ─────────────────────────────────────────────
public static void MapSnapshotEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/snapshot").RequireAuthorization();
g.MapPost("/upload", ([FromBody] SnapshotUploadRequest req,
ClaimsPrincipal user, SnapshotStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(store.Store(uid, req));
});
g.MapGet("/{code}", (string code, ClaimsPrincipal user, SnapshotStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var result = store.Retrieve(uid, code);
return result is null
? Results.NotFound("Snapshot nicht gefunden, abgelaufen oder bereits verwendet.")
: Results.Ok(result);
});
}
// ── Readable Snapshot (WebApp) ────────────────────────────────────────────
public static void MapReadableSnapshotEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/snapshot/readable").RequireAuthorization();
g.MapPost("/", ([FromBody] ReadableSnapshot snap,
ClaimsPrincipal user, ReadableSnapshotStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
snap.ExportedAt = DateTime.UtcNow;
store.Store(uid, snap);
return Results.Ok(new { exportedAt = snap.ExportedAt,
studentCount = snap.Meta.StudentCount });
});
g.MapGet("/", (ClaimsPrincipal user, ReadableSnapshotStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (uid is null) return Results.Unauthorized();
var snap = store.Load(uid);
return snap is null ? Results.NotFound("Kein Snapshot vorhanden.") : Results.Ok(snap);
});
}
// ── Plain Sync (WebApp schreibt Events) ───────────────────────────────────
public static void MapPlainSyncEndpoints(this WebApplication app)
{
var g = app.MapGroup("/api/sync/plain").RequireAuthorization();
g.MapPost("/push", ([FromBody] List<PlainSyncEvent> events,
ClaimsPrincipal user, PlainEventStore store) =>
{
var uid = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return uid is null ? Results.Unauthorized() : Results.Ok(store.Push(uid, events));
});
}
// ── JWT ───────────────────────────────────────────────────────────────────
private static string Jwt(string userId, string secret)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
claims: [new(ClaimTypes.NameIdentifier, userId),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())],
expires: DateTime.UtcNow.AddDays(30),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string DisplayName);
+84
View File
@@ -0,0 +1,84 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
/// <summary>
/// Append-only Event-Log pro User. Server versteht Payload nicht.
/// </summary>
public class EventStore(string dataPath) : IDisposable
{
private readonly Dictionary<string, LiteDatabase> _dbs = new();
private readonly Lock _lock = new();
public PushResponse Push(string userId, List<SyncEvent> events)
{
var col = GetCol(userId);
var seq = LastSeq(col);
var rejects = new List<Guid>();
foreach (var e in events.OrderBy(e => e.Timestamp))
{
var recent = col.FindOne(x =>
x.EntityType == e.EntityType && x.EntityId == e.EntityId &&
x.DeviceId != e.DeviceId && x.Timestamp > e.Timestamp.AddSeconds(-30));
if (recent is not null) { rejects.Add(e.EventId); continue; }
col.Insert(new ServerEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
ClientSeq = e.SequenceNr, ServerSeq = ++seq,
EntityType = e.EntityType, EntityId = e.EntityId,
Operation = e.Operation, Payload = e.Payload });
}
return new() { Success = true, ServerSequenceNr = seq, ConflictingEventIds = rejects };
}
public PullResponse Pull(string userId, long since, string requestingDeviceId)
{
var col = GetCol(userId);
var events = col.Find(e => e.ServerSeq > since && e.DeviceId != requestingDeviceId)
.OrderBy(e => e.ServerSeq).Take(500)
.Select(e => new SyncEvent { EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = e.DeviceType, Timestamp = e.Timestamp,
SequenceNr = e.ServerSeq, EntityType = e.EntityType,
EntityId = e.EntityId, Operation = e.Operation, Payload = e.Payload })
.ToList();
return new() { Events = events, ServerSequenceNr = LastSeq(col) };
}
private ILiteCollection<ServerEvent> GetCol(string userId)
{
lock (_lock)
{
if (!_dbs.TryGetValue(userId, out var db))
{
var safe = string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-'));
db = new LiteDatabase(Path.Combine(dataPath, $"{safe}.db"));
_dbs[userId] = db;
}
var col = db.GetCollection<ServerEvent>("events");
col.EnsureIndex(x => x.ServerSeq);
return col;
}
}
private static long LastSeq(ILiteCollection<ServerEvent> col)
{
var last = col.FindOne(Query.All(nameof(ServerEvent.ServerSeq), Query.Descending));
return last?.ServerSeq ?? 0;
}
public void Dispose() { foreach (var db in _dbs.Values) db.Dispose(); }
}
internal class ServerEvent
{
public Guid EventId { get; set; }
public string DeviceId { get; set; } = "";
public DeviceType DeviceType { get; set; }
public DateTime Timestamp { get; set; }
public long ClientSeq { get; set; }
public long ServerSeq { get; set; }
public string EntityType { get; set; } = "";
public string EntityId { get; set; } = "";
public string Operation { get; set; } = "";
public string Payload { get; set; } = "";
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="LiteDB" />
<PackageReference Include="System.Text.Json" />
</ItemGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
public class PlainEventStore(EventStore eventStore)
{
private static readonly HashSet<string> Allowed =
["Grade", "ExamResult", "WorkTask", "Lesson"];
public PlainPushResponse Push(string userId, List<PlainSyncEvent> events)
{
var permitted = events.Where(e => Allowed.Contains(e.EntityType)).ToList();
var rejected = events.Where(e => !Allowed.Contains(e.EntityType))
.Select(e => e.EventId).ToList();
if (permitted.Count == 0) return new() { Success = true, RejectedEventIds = rejected };
var syncEvents = permitted.Select(e => new SyncEvent
{
EventId = e.EventId, DeviceId = e.DeviceId,
DeviceType = DeviceType.Companion,
Timestamp = e.Timestamp, SequenceNr = 0,
EntityType = e.EntityType, EntityId = e.EntityId,
Operation = e.Operation, Payload = e.Payload,
}).ToList();
var result = eventStore.Push(userId, syncEvents);
return new()
{
Success = result.Success,
ServerSequenceNr = result.ServerSequenceNr,
RejectedEventIds = [.. result.ConflictingEventIds, .. rejected],
};
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text;
using LehrerApp.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseKestrel(o =>
{
var port = builder.Configuration.GetValue<int>("Api:Port", 5000);
o.ListenAnyIP(port);
});
var secret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert.");
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)),
ValidateIssuer = false, ValidateAudience = false,
ClockSkew = TimeSpan.FromMinutes(5),
});
builder.Services.AddAuthorization();
var data = builder.Configuration["Api:DataPath"] ?? "./data";
builder.Services.AddSingleton<EventStore>(_ => new EventStore(data));
builder.Services.AddSingleton<SnapshotStore>(_ => new SnapshotStore(data));
builder.Services.AddSingleton<ReadableSnapshotStore>(_ => new ReadableSnapshotStore(data));
builder.Services.AddSingleton<PlainEventStore>(sp =>
new PlainEventStore(sp.GetRequiredService<EventStore>()));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthEndpoints(secret);
app.MapSyncEndpoints();
app.MapSnapshotEndpoints();
app.MapReadableSnapshotEndpoints();
app.MapPlainSyncEndpoints();
app.Run();
+18
View File
@@ -0,0 +1,18 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public class ReadableSnapshot
{
public DateTime ExportedAt { get; set; }
public ReadableSnapshotMeta Meta { get; set; } = new();
public List<LearningGroup> Groups { get; set; } = [];
public List<Student> Students { get; set; } = [];
public List<Enrollment> Enrollments { get; set; } = [];
}
public class ReadableSnapshotMeta
{
public int StudentCount { get; set; }
public int GroupCount { get; set; }
}
+23
View File
@@ -0,0 +1,23 @@
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Api;
public class ReadableSnapshotStore(string dataPath)
{
private readonly string _path = Path.Combine(dataPath, "readable");
private static readonly JsonSerializerOptions _opts = new() { WriteIndented = false, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
public void Store(string userId, ReadableSnapshot snap)
{
Directory.CreateDirectory(_path);
File.WriteAllText(FilePath(userId), JsonSerializer.Serialize(snap, _opts));
}
public ReadableSnapshot? Load(string userId)
{
var p = FilePath(userId);
return File.Exists(p) ? JsonSerializer.Deserialize<ReadableSnapshot>(File.ReadAllText(p), _opts) : null;
}
private string FilePath(string userId) =>
Path.Combine(_path, $"{string.Concat(userId.Where(c => char.IsLetterOrDigit(c) || c == '-'))}.json");
}
+64
View File
@@ -0,0 +1,64 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Api;
public class SnapshotStore(string dataPath) : IDisposable
{
private readonly LiteDatabase _db =
new(Path.Combine(dataPath, "snapshots.db"));
private readonly Timer _cleanup;
public SnapshotStore(string dataPath, bool unused = false) : this(dataPath)
{
Directory.CreateDirectory(dataPath);
_cleanup = new(_ => Clean(), null, TimeSpan.FromHours(1), TimeSpan.FromHours(1));
}
private ILiteCollection<SnapshotEntry> Col => _db.GetCollection<SnapshotEntry>("snap");
public SnapshotUploadResponse Store(string userId, SnapshotUploadRequest req)
{
Col.DeleteMany(e => e.UserId == userId);
var code = NewCode();
var entry = new SnapshotEntry { Code = code, UserId = userId,
EncryptedPayload = req.EncryptedPayload,
EncryptedSyncKey = req.EncryptedSyncKey,
SourceDeviceType = req.DeviceType,
CreatedAt = DateTime.UtcNow, ExpiresAt = DateTime.UtcNow.AddHours(24) };
Col.Insert(entry);
return new() { Code = code, ExpiresAt = entry.ExpiresAt };
}
public SnapshotDownloadResponse? Retrieve(string userId, string code)
{
var e = Col.FindOne(x => x.UserId == userId && x.Code == code.ToUpperInvariant());
if (e is null || e.ExpiresAt < DateTime.UtcNow) { if (e is not null) Col.Delete(e.Id); return null; }
Col.Delete(e.Id);
return new() { EncryptedPayload = e.EncryptedPayload, EncryptedSyncKey = e.EncryptedSyncKey,
CreatedAt = e.CreatedAt, SourceDeviceType = e.SourceDeviceType };
}
private void Clean() => Col.DeleteMany(e => e.ExpiresAt < DateTime.UtcNow);
private static string NewCode()
{
string[] animals = ["TIGER","ADLER","DACHS","LUCHS","FALKE","IGEL","ELCH","FUCHS","RABE","WOLF","BISON","LAMM","EULE","BIBER","STORCH"];
string[] colors = ["BLAU","GRUEN","ROT","GOLD","GRAU","CYAN","ROSA","LILA","SAND","MINT"];
return $"{animals[Random.Shared.Next(animals.Length)]}-{Random.Shared.Next(10,99)}-{colors[Random.Shared.Next(colors.Length)]}";
}
public void Dispose() { _cleanup?.Dispose(); _db.Dispose(); }
}
internal class SnapshotEntry
{
public ObjectId Id { get; set; } = ObjectId.NewObjectId();
public string Code { get; set; } = "";
public string UserId { get; set; } = "";
public string EncryptedPayload { get; set; } = "";
public string EncryptedSyncKey { get; set; } = "";
public DeviceType SourceDeviceType { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
}
@@ -0,0 +1,87 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Interfaces;
public interface IStudentRepository
{
Student? GetById(Guid id);
List<Student> GetAll(bool includeInactive = false);
List<Student> GetByGroup(Guid groupId, string schoolYear);
void Save(Student student);
void Delete(Guid id);
}
public interface IGroupRepository
{
LearningGroup? GetById(Guid id);
List<LearningGroup> GetAll();
List<LearningGroup> GetBySchoolYear(string schoolYear);
void Save(LearningGroup group);
void Delete(Guid id);
}
public interface IEnrollmentRepository
{
List<Enrollment> GetByStudent(Guid studentId);
List<Enrollment> GetByGroup(Guid groupId);
List<Enrollment> GetByGroupAndYear(Guid groupId, string schoolYear);
void Save(Enrollment enrollment);
void Delete(Guid id);
}
public interface IExamRepository
{
Exam? GetById(Guid id);
List<Exam> GetByGroup(Guid groupId);
void Save(Exam exam);
void Delete(Guid id);
}
public interface IExamResultRepository
{
List<ExamResult> GetByExam(Guid examId);
List<ExamResult> GetByStudent(Guid studentId);
ExamResult? GetByExamAndStudent(Guid examId, Guid studentId);
void Save(ExamResult result);
void SaveMany(List<ExamResult> results);
}
public interface IGradeRepository
{
List<Grade> GetByStudentAndGroup(Guid studentId, Guid groupId);
List<Grade> GetByGroup(Guid groupId);
void Save(Grade grade);
void Delete(Guid id);
}
public interface IUnitRepository
{
Unit? GetById(Guid id);
List<Unit> GetByGroup(Guid groupId);
void Save(Unit unit);
void Delete(Guid id);
}
public interface ILessonRepository
{
List<Lesson> GetByUnit(Guid unitId);
List<Lesson> GetByGroupAndDate(Guid groupId, DateOnly date);
List<Lesson> GetByGroupAndRange(Guid groupId, DateOnly from, DateOnly to);
void Save(Lesson lesson);
void Delete(Guid id);
}
public interface IDocumentationRepository
{
List<Documentation> GetByStudent(Guid studentId);
List<Documentation> GetByStudentAndType(Guid studentId, DocumentationType type);
void Save(Documentation doc);
void Delete(Guid id);
}
public interface IWorkTaskRepository
{
List<WorkTask> GetByStatus(WorkTaskStatus status);
List<WorkTask> GetAll();
void Save(WorkTask task);
void Delete(Guid id);
}
public interface ITimeEntryRepository
{
List<TimeEntry> GetByDate(DateOnly date);
List<TimeEntry> GetByDateRange(DateOnly from, DateOnly to);
List<TimeEntry> GetByTask(Guid taskId);
void Save(TimeEntry entry);
void Delete(Guid id);
}
+5
View File
@@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
</Project>
+42
View File
@@ -0,0 +1,42 @@
namespace LehrerApp.Core.Models;
public class Exam
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public string Title { get; set; } = "";
public DateOnly Date { get; set; }
public string Subject { get; set; } = "";
public int? ExamNumber { get; set; }
public List<ExamTask> Tasks { get; set; } = [];
public List<GradingKeyEntry> GradingKey { get; set; } = [];
public ExamStatus Status { get; set; } = ExamStatus.Planned;
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class ExamTask
{
public int Nr { get; set; }
public string? Title { get; set; }
public double MaxPoints { get; set; }
public double Weight { get; set; } = 1.0;
}
public class GradingKeyEntry
{
public string Grade { get; set; } = "";
public double MinPercent { get; set; }
}
public class ExamResult
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ExamId { get; set; }
public Guid StudentId { get; set; }
public List<double> Points { get; set; } = [];
public double TotalPoints { get; set; }
public string? Grade { get; set; }
public bool Absent { get; set; }
public string? Comment { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public enum ExamStatus { Planned, Conducted, Graded, Returned }
+26
View File
@@ -0,0 +1,26 @@
namespace LehrerApp.Core.Models;
public class LearningGroup
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public GroupType Type { get; set; }
public string? Subject { get; set; }
public string SchoolYear { get; set; } = "";
public int GradeLevel { get; set; }
public GradingSystem GradingSystem { get; set; }
public int? HoursPerWeek { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Enrollment
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
public Guid GroupId { get; set; }
public string SchoolYear { get; set; } = "";
public DateOnly EnrolledAt { get; set; } = DateOnly.FromDateTime(DateTime.Today);
public DateOnly? LeftAt { get; set; }
}
public enum GroupType { Class, Course }
public enum GradingSystem { Grades1To6, Points0To15 }
+50
View File
@@ -0,0 +1,50 @@
namespace LehrerApp.Core.Models;
public class Grade
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
public Guid GroupId { get; set; }
public string SchoolYear { get; set; } = "";
public GradeCategory Category { get; set; }
public string Value { get; set; } = "";
public DateOnly Date { get; set; }
public double Weight { get; set; } = 1.0;
public string? Note { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum GradeCategory { Oral, Homework, Participation, Project, Other }
public class Unit
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public string Title { get; set; } = "";
public string Subject { get; set; } = "";
public string SchoolYear { get; set; } = "";
public DateOnly? StartDate { get; set; }
public DateOnly? EndDate { get; set; }
public List<string> Competencies { get; set; } = [];
public UnitStatus Status { get; set; } = UnitStatus.Planned;
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Lesson
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UnitId { get; set; }
public Guid GroupId { get; set; }
public DateOnly Date { get; set; }
public int? LessonNumber { get; set; }
public string Topic { get; set; } = "";
public string? Phase { get; set; }
public List<string> Methods { get; set; } = [];
public List<string> Materials { get; set; } = [];
public string? Homework { get; set; }
public string? Reflection { get; set; }
public LessonStatus Status { get; set; } = LessonStatus.Planned;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public enum UnitStatus { Planned, Active, Completed }
public enum LessonStatus { Planned, Conducted }
+24
View File
@@ -0,0 +1,24 @@
namespace LehrerApp.Core.Models;
public class Student
{
public Guid Id { get; set; } = Guid.NewGuid();
public string FirstName { get; set; } = "";
public string LastName { get; set; } = "";
public string FullName => $"{LastName}, {FirstName}";
public DateOnly? DateOfBirth { get; set; }
public Gender? Gender { get; set; }
public List<Contact> Contacts { get; set; } = [];
public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class Contact
{
public string Name { get; set; } = "";
public string Relation { get; set; } = "";
public string? Phone { get; set; }
public string? Email { get; set; }
}
public enum Gender { M, W, D }
+61
View File
@@ -0,0 +1,61 @@
namespace LehrerApp.Core.Models;
public class Documentation
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid StudentId { get; set; }
public Guid? GroupId { get; set; }
public DocumentationType Type { get; set; }
public DateOnly Date { get; set; }
public string Title { get; set; } = "";
public string Content { get; set; } = "";
public List<string> Participants { get; set; } = [];
public AbsenceData? AbsenceData { get; set; }
public SupportData? SupportData { get; set; }
public bool IsConfidential { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class AbsenceData
{
public int LessonCount { get; set; }
public bool Excused { get; set; }
public string? Reason { get; set; }
}
public class SupportData
{
public List<string> Measures { get; set; } = [];
public DateOnly? ReviewDate { get; set; }
public SupportStatus Status { get; set; } = SupportStatus.Active;
}
public enum DocumentationType { Conversation, Incident, SupportPlan, Absence }
public enum SupportStatus { Active, Completed, Paused }
public class WorkTask
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Title { get; set; } = "";
public TaskCategory Category { get; set; }
public Guid? GroupId { get; set; }
public DateOnly? DueDate { get; set; }
public int? EstimatedMinutes { get; set; }
public WorkTaskStatus Status { get; set; } = WorkTaskStatus.Open;
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class TimeEntry
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid? TaskId { get; set; }
public string Category { get; set; } = "";
public Guid? GroupId { get; set; }
public DateOnly Date { get; set; }
public TimeOnly? StartTime { get; set; }
public TimeOnly? EndTime { get; set; }
public int DurationMinutes { get; set; }
public string? Description { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public enum TaskCategory { Correction, Preparation, Admin, Meeting, Other }
public enum WorkTaskStatus { Open, InProgress, Done }
+52
View File
@@ -0,0 +1,52 @@
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Services;
public class GradingService
{
public string CalculateGrade(double achieved, double maximum, List<GradingKeyEntry> key)
{
if (maximum <= 0) return "-";
var percent = achieved / maximum * 100.0;
return key.OrderByDescending(k => k.MinPercent)
.FirstOrDefault(k => percent >= k.MinPercent)
?.Grade ?? key.OrderBy(k => k.MinPercent).First().Grade;
}
public static List<GradingKeyEntry> DefaultKey1To6() =>
[
new() { Grade = "1", MinPercent = 87.5 },
new() { Grade = "2", MinPercent = 75.0 },
new() { Grade = "3", MinPercent = 62.5 },
new() { Grade = "4", MinPercent = 50.0 },
new() { Grade = "5", MinPercent = 25.0 },
new() { Grade = "6", MinPercent = 0.0 },
];
public static List<GradingKeyEntry> DefaultKey0To15() =>
[
new() { Grade = "15", MinPercent = 95.0 },
new() { Grade = "14", MinPercent = 90.0 },
new() { Grade = "13", MinPercent = 85.0 },
new() { Grade = "12", MinPercent = 80.0 },
new() { Grade = "11", MinPercent = 75.0 },
new() { Grade = "10", MinPercent = 70.0 },
new() { Grade = "9", MinPercent = 65.0 },
new() { Grade = "8", MinPercent = 60.0 },
new() { Grade = "7", MinPercent = 55.0 },
new() { Grade = "6", MinPercent = 50.0 },
new() { Grade = "5", MinPercent = 45.0 },
new() { Grade = "4", MinPercent = 40.0 },
new() { Grade = "3", MinPercent = 33.0 },
new() { Grade = "2", MinPercent = 27.0 },
new() { Grade = "1", MinPercent = 20.0 },
new() { Grade = "0", MinPercent = 0.0 },
];
public double WeightedAverage(List<(string Grade, double Weight)> grades)
{
var numeric = grades
.Select(g => (Value: int.TryParse(g.Grade, out var n) ? (int?)n : null, g.Weight))
.Where(g => g.Value.HasValue).ToList();
if (numeric.Count == 0) return 0;
var total = numeric.Sum(g => g.Weight);
return total == 0 ? 0 : numeric.Sum(g => g.Value!.Value * g.Weight) / total;
}
}
@@ -0,0 +1,22 @@
namespace LehrerApp.Core.Services;
public class SchoolYearService
{
public string CurrentSchoolYear()
{
var now = DateTime.Today;
return FormatSchoolYear(now.Month >= 8 ? now.Year : now.Year - 1);
}
public string FormatSchoolYear(int startYear) =>
$"{startYear}/{(startYear + 1) % 100:D2}";
public DateOnly SchoolYearStart(string sy) =>
new(int.Parse(sy.Split('/')[0]), 8, 1);
public DateOnly SchoolYearEnd(string sy) =>
new(int.Parse(sy.Split('/')[0]) + 1, 7, 31);
public List<string> RecentSchoolYears(int count = 5)
{
var now = DateTime.Today;
var cur = now.Month >= 8 ? now.Year : now.Year - 1;
return Enumerable.Range(0, count).Select(i => FormatSchoolYear(cur - i)).ToList();
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LiteDB" />
</ItemGroup>
</Project>
+60
View File
@@ -0,0 +1,60 @@
using LiteDB;
using LehrerApp.Core.Models;
namespace LehrerApp.Data;
/// <summary>
/// Zentrale LiteDB-Verbindung. Singleton eine Datei = ein Nutzer.
/// </summary>
public class LiteDbContext : IDisposable
{
private readonly LiteDatabase _db;
public LiteDbContext(string databasePath)
{
_db = new LiteDatabase(new ConnectionString(databasePath)
{
Connection = ConnectionType.Shared,
});
EnsureIndexes();
}
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<Enrollment> Enrollments => _db.GetCollection<Enrollment>("enrollments");
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
public ILiteCollection<Grade> Grades => _db.GetCollection<Grade>("grades");
public ILiteCollection<Unit> Units => _db.GetCollection<Unit>("units");
public ILiteCollection<Lesson> Lessons => _db.GetCollection<Lesson>("lessons");
public ILiteCollection<Documentation> Documentation => _db.GetCollection<Documentation>("documentation");
public ILiteCollection<WorkTask> Tasks => _db.GetCollection<WorkTask>("tasks");
public ILiteCollection<TimeEntry> TimeEntries => _db.GetCollection<TimeEntry>("time_entries");
public void Checkpoint() => _db.Checkpoint();
private void EnsureIndexes()
{
Students.EnsureIndex(x => x.LastName);
Students.EnsureIndex(x => x.IsActive);
Groups.EnsureIndex(x => x.SchoolYear);
Enrollments.EnsureIndex(x => x.StudentId);
Enrollments.EnsureIndex(x => x.GroupId);
Enrollments.EnsureIndex(x => x.SchoolYear);
Exams.EnsureIndex(x => x.GroupId);
Exams.EnsureIndex(x => x.Status);
ExamResults.EnsureIndex(x => x.ExamId);
ExamResults.EnsureIndex(x => x.StudentId);
Grades.EnsureIndex(x => x.StudentId);
Grades.EnsureIndex(x => x.GroupId);
Units.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.UnitId);
Lessons.EnsureIndex(x => x.GroupId);
Lessons.EnsureIndex(x => x.Date);
Documentation.EnsureIndex(x => x.StudentId);
Tasks.EnsureIndex(x => x.Status);
TimeEntries.EnsureIndex(x => x.Date);
}
public void Dispose() => _db.Dispose();
}
@@ -0,0 +1,135 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Data.Repositories;
public class StudentRepository(LiteDbContext db) : IStudentRepository
{
public Student? GetById(Guid id) => db.Students.FindById(id);
public List<Student> GetAll(bool includeInactive = false) =>
(includeInactive ? db.Students.FindAll() : db.Students.Find(s => s.IsActive))
.OrderBy(s => s.LastName).ToList();
public List<Student> GetByGroup(Guid groupId, string schoolYear)
{
var ids = db.Enrollments
.Find(e => e.GroupId == groupId && e.SchoolYear == schoolYear)
.Select(e => e.StudentId).ToHashSet();
return db.Students.Find(s => ids.Contains(s.Id)).OrderBy(s => s.LastName).ToList();
}
public void Save(Student s) { s.UpdatedAt = DateTime.UtcNow; db.Students.Upsert(s); }
public void Delete(Guid id) => db.Students.Delete(id);
}
public class GroupRepository(LiteDbContext db) : IGroupRepository
{
public LearningGroup? GetById(Guid id) => db.Groups.FindById(id);
public List<LearningGroup> GetAll() =>
db.Groups.FindAll().OrderBy(g => g.SchoolYear).ThenBy(g => g.Name).ToList();
public List<LearningGroup> GetBySchoolYear(string schoolYear) =>
db.Groups.Find(g => g.SchoolYear == schoolYear).OrderBy(g => g.Name).ToList();
public void Save(LearningGroup g) { g.UpdatedAt = DateTime.UtcNow; db.Groups.Upsert(g); }
public void Delete(Guid id) => db.Groups.Delete(id);
}
public class EnrollmentRepository(LiteDbContext db) : IEnrollmentRepository
{
public List<Enrollment> GetByStudent(Guid id) =>
db.Enrollments.Find(e => e.StudentId == id).ToList();
public List<Enrollment> GetByGroup(Guid id) =>
db.Enrollments.Find(e => e.GroupId == id).ToList();
public List<Enrollment> GetByGroupAndYear(Guid groupId, string sy) =>
db.Enrollments.Find(e => e.GroupId == groupId && e.SchoolYear == sy).ToList();
public void Save(Enrollment e) => db.Enrollments.Upsert(e);
public void Delete(Guid id) => db.Enrollments.Delete(id);
}
public class ExamRepository(LiteDbContext db) : IExamRepository
{
public Exam? GetById(Guid id) => db.Exams.FindById(id);
public List<Exam> GetByGroup(Guid groupId) =>
db.Exams.Find(e => e.GroupId == groupId).OrderByDescending(e => e.Date).ToList();
public void Save(Exam e) { e.UpdatedAt = DateTime.UtcNow; db.Exams.Upsert(e); }
public void Delete(Guid id) => db.Exams.Delete(id);
}
public class ExamResultRepository(LiteDbContext db) : IExamResultRepository
{
public List<ExamResult> GetByExam(Guid id) =>
db.ExamResults.Find(r => r.ExamId == id).ToList();
public List<ExamResult> GetByStudent(Guid id) =>
db.ExamResults.Find(r => r.StudentId == id).ToList();
public ExamResult? GetByExamAndStudent(Guid examId, Guid studentId) =>
db.ExamResults.FindOne(r => r.ExamId == examId && r.StudentId == studentId);
public void Save(ExamResult r) { r.UpdatedAt = DateTime.UtcNow; db.ExamResults.Upsert(r); }
public void SaveMany(List<ExamResult> results)
{
var now = DateTime.UtcNow;
foreach (var r in results) r.UpdatedAt = now;
db.ExamResults.Upsert(results);
}
}
public class GradeRepository(LiteDbContext db) : IGradeRepository
{
public List<Grade> GetByStudentAndGroup(Guid sid, Guid gid) =>
db.Grades.Find(g => g.StudentId == sid && g.GroupId == gid).OrderBy(g => g.Date).ToList();
public List<Grade> GetByGroup(Guid id) =>
db.Grades.Find(g => g.GroupId == id).OrderBy(g => g.Date).ToList();
public void Save(Grade g) => db.Grades.Upsert(g);
public void Delete(Guid id) => db.Grades.Delete(id);
}
public class UnitRepository(LiteDbContext db) : IUnitRepository
{
public Unit? GetById(Guid id) => db.Units.FindById(id);
public List<Unit> GetByGroup(Guid id) =>
db.Units.Find(u => u.GroupId == id).OrderBy(u => u.StartDate).ToList();
public void Save(Unit u) { u.UpdatedAt = DateTime.UtcNow; db.Units.Upsert(u); }
public void Delete(Guid id) => db.Units.Delete(id);
}
public class LessonRepository(LiteDbContext db) : ILessonRepository
{
public List<Lesson> GetByUnit(Guid id) =>
db.Lessons.Find(l => l.UnitId == id).OrderBy(l => l.Date).ToList();
public List<Lesson> GetByGroupAndDate(Guid gid, DateOnly date) =>
db.Lessons.Find(l => l.GroupId == gid && l.Date == date).ToList();
public List<Lesson> GetByGroupAndRange(Guid gid, DateOnly from, DateOnly to) =>
db.Lessons.Find(l => l.GroupId == gid && l.Date >= from && l.Date <= to)
.OrderBy(l => l.Date).ToList();
public void Save(Lesson l) { l.UpdatedAt = DateTime.UtcNow; db.Lessons.Upsert(l); }
public void Delete(Guid id) => db.Lessons.Delete(id);
}
public class DocumentationRepository(LiteDbContext db) : IDocumentationRepository
{
public List<Documentation> GetByStudent(Guid id) =>
db.Documentation.Find(d => d.StudentId == id).OrderByDescending(d => d.Date).ToList();
public List<Documentation> GetByStudentAndType(Guid sid, DocumentationType type) =>
db.Documentation.Find(d => d.StudentId == sid && d.Type == type)
.OrderByDescending(d => d.Date).ToList();
public void Save(Documentation d) { d.UpdatedAt = DateTime.UtcNow; db.Documentation.Upsert(d); }
public void Delete(Guid id) => db.Documentation.Delete(id);
}
public class WorkTaskRepository(LiteDbContext db) : IWorkTaskRepository
{
public List<WorkTask> GetByStatus(WorkTaskStatus s) =>
db.Tasks.Find(t => t.Status == s).OrderBy(t => t.DueDate).ToList();
public List<WorkTask> GetAll() =>
db.Tasks.FindAll().OrderBy(t => t.Status).ThenBy(t => t.DueDate).ToList();
public void Save(WorkTask t) { t.UpdatedAt = DateTime.UtcNow; db.Tasks.Upsert(t); }
public void Delete(Guid id) => db.Tasks.Delete(id);
}
public class TimeEntryRepository(LiteDbContext db) : ITimeEntryRepository
{
public List<TimeEntry> GetByDate(DateOnly date) =>
db.TimeEntries.Find(e => e.Date == date).OrderBy(e => e.StartTime).ToList();
public List<TimeEntry> GetByDateRange(DateOnly from, DateOnly to) =>
db.TimeEntries.Find(e => e.Date >= from && e.Date <= to).OrderBy(e => e.Date).ToList();
public List<TimeEntry> GetByTask(Guid id) =>
db.TimeEntries.Find(e => e.TaskId == id).ToList();
public void Save(TimeEntry e) => db.TimeEntries.Upsert(e);
public void Delete(Guid id) => db.TimeEntries.Delete(id);
}
+9
View File
@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>
+61
View File
@@ -0,0 +1,61 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Views;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop;
public class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
{
Services = AppBootstrapper.BuildServices();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
WireCallbacks(mainVm);
desktop.MainWindow = new MainWindow { DataContext = mainVm };
}
base.OnFrameworkInitializationCompleted();
}
private static void WireCallbacks(MainWindowViewModel main)
{
// GroupList → GroupDetail
var gl = Services.GetRequiredService<GroupListViewModel>();
gl.OnNavigateToDetail = id => main.NavigateToGroupDetail(id);
gl.OnAddGroup = () => ShowAddGroupDialog(gl);
// Dashboard → GroupDetail (Chips)
var dash = Services.GetRequiredService<DashboardViewModel>();
dash.OnNavigateToGroup = id => main.NavigateToGroupDetail(id);
// StudentList → StudentDetail
var sl = Services.GetRequiredService<StudentListViewModel>();
sl.OnNavigateToDetail = id => main.NavigateToStudent(id);
}
private static async void ShowAddGroupDialog(GroupListViewModel groupList)
{
var vm = new ViewModels.Groups.AddGroupDialogViewModel(
Services.GetRequiredService<Core.Interfaces.IGroupRepository>(),
Services.GetRequiredService<Core.Services.SchoolYearService>());
var dialog = new Views.Groups.AddGroupDialog { DataContext = vm };
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime { MainWindow: { } owner })
{
var ok = await dialog.ShowDialog<bool>(owner);
if (ok) groupList.LoadGroups();
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop;
/// <summary>
/// Konfiguriert den DI-Container für den Desktop-Client.
///
/// WICHTIG: Alle Repositories sind Singleton eine LiteDB-Datei pro Nutzer.
/// Sync-Services werden nur registriert wenn eine Server-URL konfiguriert ist.
/// </summary>
public static class AppBootstrapper
{
public static string DbPath { get; private set; } = "";
public static string AppDataPath { get; private set; } = "";
public static IServiceProvider BuildServices()
{
var services = new ServiceCollection();
// ── Pfade ─────────────────────────────────────────────────────────────
var appData = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp");
Directory.CreateDirectory(appData);
AppDataPath = appData;
DbPath = Path.Combine(appData, "lehrerapp.db");
var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key");
// ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(DbPath));
// ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<IEnrollmentRepository, EnrollmentRepository>();
services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<SchoolYearService>();
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
services.AddSingleton(sp => new ConflictResolver(sp.GetRequiredService<EventQueue>()));
services.AddSingleton<byte[]>(_ =>
{
var key = SyncCrypto.LoadKey(keyPath) ?? SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
});
var serverUrl = LoadServerUrl(appData);
var deviceId = LoadOrCreateDeviceId(appData);
if (!string.IsNullOrEmpty(serverUrl))
{
services.AddSingleton<SyncEngine>(sp => new SyncEngine(
sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(),
BuildHttp(serverUrl, appData),
new SyncConfig
{
ServerUrl = serverUrl,
DeviceId = deviceId,
DeviceType = DeviceType.Desktop,
AutoSyncIntervalMinutes = 5,
}));
services.AddSingleton<SnapshotService>(sp => new SnapshotService(
BuildHttp(serverUrl, appData),
sp.GetRequiredService<LiteDbContext>(),
sp.GetRequiredService<byte[]>(),
DeviceType.Desktop, DbPath, keyPath));
}
// ── ViewModels ────────────────────────────────────────────────────────
// Singleton: einmal erstellt, überall dieselbe Instanz
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddSingleton<SyncStatusViewModel>();
services.AddSingleton<GroupListViewModel>();
services.AddSingleton<StudentListViewModel>();
// Transient: neue Instanz pro Navigation (für Detailseiten)
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentDetailViewModel>();
return services.BuildServiceProvider();
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static HttpClient BuildHttp(string url, string appData)
{
var http = new HttpClient { BaseAddress = new Uri(url) };
var tokenPath = Path.Combine(appData, "auth.token");
if (File.Exists(tokenPath))
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", File.ReadAllText(tokenPath).Trim());
return http;
}
public static string LoadServerUrl(string? path = null) =>
File.Exists(Path.Combine(path ?? AppDataPath, "server.txt"))
? File.ReadAllText(Path.Combine(path ?? AppDataPath, "server.txt")).Trim()
: "";
public static void SaveServerUrl(string url) =>
File.WriteAllText(Path.Combine(AppDataPath, "server.txt"), url);
private static string LoadOrCreateDeviceId(string appData)
{
var p = Path.Combine(appData, "device.id");
if (File.Exists(p)) return File.ReadAllText(p).Trim();
var id = Guid.NewGuid().ToString();
File.WriteAllText(p, id);
return id;
}
}
+1
View File
@@ -0,0 +1 @@
LehrerApp Assets Icons und Bilder hier ablegen.
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
using Avalonia;
namespace LehrerApp.Desktop;
class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
@@ -0,0 +1,72 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels;
public partial class DashboardViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly ILessonRepository _lessons;
private readonly IWorkTaskRepository _tasks;
private readonly SchoolYearService _sy;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = "";
public ObservableCollection<LessonItem> TodaysLessons { get; } = [];
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
// Navigation-Callback wird von App.axaml.cs verdrahtet
public Action<Guid>? OnNavigateToGroup { get; set; }
public DashboardViewModel(IGroupRepository groups, ILessonRepository lessons,
IWorkTaskRepository tasks, SchoolYearService sy)
{
_groups = groups; _lessons = lessons; _tasks = tasks; _sy = sy;
Load();
}
private void Load()
{
var now = DateTime.Now;
var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy", new CultureInfo("de-DE"));
CurrentSchoolYear = _sy.CurrentSchoolYear();
Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend";
TodaysLessons.Clear();
var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id);
foreach (var l in groups.Keys.SelectMany(gid => _lessons.GetByGroupAndDate(gid, today))
.OrderBy(l => l.LessonNumber))
{
if (groups.TryGetValue(l.GroupId, out var g))
TodaysLessons.Add(new() { GroupName = g.Name, Topic = l.Topic });
}
OpenTasks.Clear();
foreach (var t in _tasks.GetByStatus(WorkTaskStatus.Open)
.Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress))
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue).Take(5))
OpenTasks.Add(new() { Title = t.Title,
DueDate = t.DueDate?.ToString("dd.MM.") ?? "",
IsOverdue = t.DueDate.HasValue && t.DueDate < today });
CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name))
CurrentGroups.Add(new() { GroupId = g.Id, Name = g.Name, Subject = g.Subject ?? "" });
}
[RelayCommand] private void OpenGroup(GroupChip? c) { if (c is not null) OnNavigateToGroup?.Invoke(c.GroupId); }
[RelayCommand] private void Refresh() => Load();
}
public class LessonItem { public string GroupName { get; set; } = ""; public string Topic { get; set; } = ""; }
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } }
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
@@ -0,0 +1,196 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Gruppenliste ──────────────────────────────────────────────────────────────
public partial class GroupListViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly SchoolYearService _sy;
public Action<Guid>? OnNavigateToDetail { get; set; }
public Action? OnAddGroup { get; set; }
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private GroupListItem? _selectedGroup;
public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = [];
public GroupListViewModel(IGroupRepository groups, SchoolYearService sy)
{
_groups = groups; _sy = sy;
foreach (var y in sy.RecentSchoolYears()) SchoolYears.Add(y);
SelectedSchoolYear = sy.CurrentSchoolYear();
}
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
partial void OnSearchTextChanged(string value) => LoadGroups();
partial void OnSelectedGroupChanged(GroupListItem? value)
{
if (value is not null) OnNavigateToDetail?.Invoke(value.Id);
}
public void LoadGroups()
{
Groups.Clear();
var all = _groups.GetBySchoolYear(SelectedSchoolYear);
var filtered = string.IsNullOrWhiteSpace(SearchText) ? all
: all.Where(g => g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| (g.Subject?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false));
foreach (var g in filtered.OrderBy(g => g.Name))
Groups.Add(new GroupListItem(g));
}
[RelayCommand] private void AddGroup() => OnAddGroup?.Invoke();
[RelayCommand] private void Refresh() => LoadGroups();
}
public class GroupListItem
{
public Guid Id { get; }
public string Name { get; }
public string Subject { get; }
public string DisplayName { get; }
public string TypeLabel { get; }
public string GradingLabel { get; }
public GroupListItem(LearningGroup g)
{
Id = g.Id;
Name = g.Name;
Subject = g.Subject ?? "";
TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs";
GradingLabel = g.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015";
DisplayName = string.IsNullOrEmpty(Subject) ? Name : $"{Name} · {Subject}";
}
}
// ── Gruppendetail ─────────────────────────────────────────────────────────────
public partial class GroupDetailViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly IStudentRepository _students;
private readonly IExamRepository _exams;
private readonly IGradeRepository _grades;
[ObservableProperty] private LearningGroup? _group;
[ObservableProperty] private string _groupTitle = "";
[ObservableProperty] private string _groupSubtitle = "";
[ObservableProperty] private int _studentCount;
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public GroupDetailViewModel(IGroupRepository groups, IStudentRepository students,
IExamRepository exams, IGradeRepository grades)
{
_groups = groups; _students = students; _exams = exams; _grades = grades;
}
public void LoadGroup(Guid id)
{
Group = _groups.GetById(id);
if (Group is null) return;
GroupTitle = Group.Name;
GroupSubtitle = $"{Group.SchoolYear} · " +
$"{(Group.Type == GroupType.Class ? "Klasse" : "Kurs")} · " +
$"Stufe {Group.GradeLevel} · Noten {(Group.GradingSystem == GradingSystem.Grades1To6 ? "16" : "015")}";
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear);
StudentCount = enrolled.Count;
foreach (var s in enrolled) Students.Add(new StudentSummary(s));
Exams.Clear();
foreach (var e in _exams.GetByGroup(Group.Id)) Exams.Add(new ExamSummary(e));
}
[RelayCommand] private void AddStudent() { /* TODO */ }
[RelayCommand] private void AddExam() { /* TODO */ }
[RelayCommand] private void Refresh() { if (Group is not null) LoadGroup(Group.Id); }
}
public class StudentSummary
{
public Guid Id { get; }
public string FullName { get; }
public StudentSummary(Core.Models.Student s) { Id = s.Id; FullName = s.FullName; }
}
public class ExamSummary
{
public Guid Id { get; }
public string Title { get; }
public string Date { get; }
public string StatusLabel { get; }
public ExamSummary(Core.Models.Exam e)
{
Id = e.Id; Title = e.Title; Date = e.Date.ToString("dd.MM.yyyy");
StatusLabel = e.Status switch
{
ExamStatus.Planned => "Geplant",
ExamStatus.Conducted => "Durchgeführt",
ExamStatus.Graded => "Korrigiert",
ExamStatus.Returned => "Zurückgegeben",
_ => "",
};
}
}
// ── Dialog: Neue Lerngruppe anlegen ──────────────────────────────────────────
public partial class AddGroupDialogViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly SchoolYearService _sy;
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _subject = "";
[ObservableProperty] private int _gradeLevel = 10;
[ObservableProperty] private GradingSystem _gradingSystem = GradingSystem.Grades1To6;
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _validationMessage = "";
public List<string> SchoolYears { get; }
public LearningGroup? Result { get; private set; }
public AddGroupDialogViewModel(IGroupRepository groups, SchoolYearService sy)
{
_groups = groups; _sy = sy;
SchoolYears = sy.RecentSchoolYears(3);
SelectedSchoolYear = sy.CurrentSchoolYear();
// Notensystem automatisch nach Klassenstufe
PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GradeLevel))
GradingSystem = GradeLevel >= 11 ? GradingSystem.Points0To15
: GradingSystem.Grades1To6;
};
}
[RelayCommand]
private void Save()
{
if (string.IsNullOrWhiteSpace(Name)) { ValidationMessage = "Name erforderlich."; return; }
if (GradeLevel is < 1 or > 13) { ValidationMessage = "Klassenstufe 113."; return; }
Result = new LearningGroup
{
Name = Name.Trim(),
Subject = string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim(),
Type = GroupType.Course,
GradeLevel = GradeLevel,
GradingSystem = GradingSystem,
SchoolYear = SelectedSchoolYear,
};
_groups.Save(Result);
}
}
@@ -0,0 +1,66 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.ViewModels;
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty] private ObservableObject? _currentPage;
[ObservableProperty] private NavItem _activeNavItem = NavItem.Dashboard;
[ObservableProperty] private string _currentSchoolYear = "";
public MainWindowViewModel(IServiceProvider services,
DashboardViewModel dashboard, SchoolYearService sy)
{
_services = services;
CurrentSchoolYear = sy.CurrentSchoolYear();
CurrentPage = dashboard;
}
[RelayCommand]
private void NavigateTo(NavItem item)
{
ActiveNavItem = item;
CurrentPage = item switch
{
NavItem.Dashboard => _services.GetRequiredService<DashboardViewModel>(),
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
NavItem.Exams => new PlaceholderViewModel { Title = "Klausuren", Icon = "📝" },
NavItem.Planner => new PlaceholderViewModel { Title = "Unterrichtsplanung", Icon = "📅" },
NavItem.Workload => new PlaceholderViewModel { Title = "Arbeitszeit", Icon = "⏱" },
NavItem.Settings => new PlaceholderViewModel { Title = "Einstellungen", Icon = "⚙️" },
_ => CurrentPage,
};
}
public void NavigateToGroupDetail(Guid groupId)
{
ActiveNavItem = NavItem.Groups;
var vm = _services.GetRequiredService<GroupDetailViewModel>();
vm.LoadGroup(groupId);
CurrentPage = vm;
}
public void NavigateToStudent(Guid studentId)
{
ActiveNavItem = NavItem.Students;
var vm = _services.GetRequiredService<StudentDetailViewModel>();
vm.LoadStudent(studentId);
CurrentPage = vm;
}
}
public enum NavItem { Dashboard, Groups, Students, Exams, Planner, Workload, Settings }
public partial class PlaceholderViewModel : ObservableObject
{
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _icon = "";
}
@@ -0,0 +1,131 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
public partial class StudentListViewModel : ObservableObject
{
private readonly IStudentRepository _students;
public Action<Guid>? OnNavigateToDetail { get; set; }
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private bool _showInactive;
[ObservableProperty] private StudentListItem? _selectedStudent;
public ObservableCollection<StudentListItem> Students { get; } = [];
public StudentListViewModel(IStudentRepository students)
{
_students = students;
LoadStudents();
}
partial void OnSearchTextChanged(string value) => LoadStudents();
partial void OnShowInactiveChanged(bool value) => LoadStudents();
partial void OnSelectedStudentChanged(StudentListItem? value)
{
if (value is not null) OnNavigateToDetail?.Invoke(value.Id);
}
public void LoadStudents()
{
Students.Clear();
var all = _students.GetAll(ShowInactive);
var f = string.IsNullOrWhiteSpace(SearchText) ? all
: all.Where(s => s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase)
|| s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
foreach (var s in f) Students.Add(new StudentListItem(s));
}
[RelayCommand] private void AddStudent() { /* TODO */ }
[RelayCommand] private void Refresh() => LoadStudents();
}
public class StudentListItem
{
public Guid Id { get; }
public string FullName { get; }
public string DateOfBirth { get; }
public StudentListItem(Student s)
{
Id = s.Id; FullName = s.FullName;
DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? "";
}
}
public partial class StudentDetailViewModel : ObservableObject
{
private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments;
private readonly IGroupRepository _groups;
private readonly IDocumentationRepository _docs;
[ObservableProperty] private Student? _student;
[ObservableProperty] private string _studentTitle = "";
[ObservableProperty] private bool _isEditing;
[ObservableProperty] private string _editFirstName = "";
[ObservableProperty] private string _editLastName = "";
public ObservableCollection<EnrollmentEntry> Enrollments { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = [];
public StudentDetailViewModel(IStudentRepository students,
IEnrollmentRepository enrollments, IGroupRepository groups,
IDocumentationRepository docs)
{
_students = students; _enrollments = enrollments;
_groups = groups; _docs = docs;
}
public void LoadStudent(Guid id)
{
Student = _students.GetById(id);
if (Student is null) return;
StudentTitle = Student.FullName;
EditFirstName = Student.FirstName;
EditLastName = Student.LastName;
Enrollments.Clear();
foreach (var e in _enrollments.GetByStudent(Student.Id))
{
var g = _groups.GetById(e.GroupId);
if (g is null) continue;
Enrollments.Add(new() { SchoolYear = e.SchoolYear, GroupName = g.Name, Subject = g.Subject ?? "" });
}
Documentation.Clear();
foreach (var d in _docs.GetByStudent(Student.Id))
Documentation.Add(new() { Date = d.Date.ToString("dd.MM.yyyy"), Title = d.Title,
TypeLabel = d.Type switch
{
DocumentationType.Conversation => "Gespräch",
DocumentationType.Incident => "Vorkommnis",
DocumentationType.SupportPlan => "Förderplan",
DocumentationType.Absence => "Fehlzeit",
_ => "",
},
IsConfidential = d.IsConfidential });
}
[RelayCommand] private void StartEdit() => IsEditing = true;
[RelayCommand] private void CancelEdit()
{
if (Student is null) return;
EditFirstName = Student.FirstName; EditLastName = Student.LastName;
IsEditing = false;
}
[RelayCommand] private void SaveEdit()
{
if (Student is null) return;
Student.FirstName = EditFirstName; Student.LastName = EditLastName;
_students.Save(Student);
StudentTitle = Student.FullName;
IsEditing = false;
}
}
public class EnrollmentEntry { public string SchoolYear { get; set; } = ""; public string GroupName { get; set; } = ""; public string Subject { get; set; } = ""; }
public class DocEntry { public string Date { get; set; } = ""; public string Title { get; set; } = ""; public string TypeLabel { get; set; } = ""; public bool IsConfidential { get; set; } }
@@ -0,0 +1,43 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
namespace LehrerApp.Desktop.ViewModels;
public partial class SyncStatusViewModel : ObservableObject
{
private readonly SyncEngine? _engine;
[ObservableProperty] private string _statusText = "Kein Server konfiguriert";
[ObservableProperty] private string _lastSyncText = "";
[ObservableProperty] private bool _isSyncing;
[ObservableProperty] private bool _isServerConfigured;
[ObservableProperty] private int _pendingCount;
public SyncStatusViewModel(SyncEngine? engine)
{
_engine = engine;
IsServerConfigured = engine is not null;
if (_engine is not null) _engine.StatusChanged += OnStatus;
}
private void OnStatus(SyncStatus s)
{
IsSyncing = s.State == SyncState.Syncing;
PendingCount = s.PendingEvents;
StatusText = s.State switch
{
SyncState.Idle => PendingCount > 0 ? $"{PendingCount} ausstehend" : "Synchronisiert",
SyncState.Syncing => "Synchronisiere…",
SyncState.Offline => "Offline",
SyncState.Error => $"Fehler: {s.ErrorMessage}",
_ => "",
};
LastSyncText = s.LastSyncAt.HasValue ? $"Zuletzt: {s.LastSyncAt:HH:mm}" : "Noch nie";
}
[RelayCommand(CanExecute = nameof(CanSync))]
private async Task SyncNow() { if (_engine is not null) await _engine.SyncNowAsync(); }
private bool CanSync() => _engine is not null && !IsSyncing;
}
@@ -0,0 +1,106 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.Dashboard.DashboardView"
x:DataType="vm:DashboardViewModel">
<ScrollViewer Padding="24">
<StackPanel Spacing="20">
<!-- Begrüßung -->
<StackPanel>
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6"/>
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold"/>
</StackPanel>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto">
<!-- Heutige Stunden -->
<Border Grid.Column="0" Grid.Row="0" Margin="0,0,8,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding TodaysLessons}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LessonItem">
<Grid ColumnDefinitions="4,*" Margin="0,4">
<Border Grid.Column="0" Width="4" CornerRadius="2"
Background="{DynamicResource SystemAccentColor}"
Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding GroupName}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Topic}" FontSize="12" Opacity="0.7"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Stunden heute" Opacity="0.4" FontSize="13"
IsVisible="{Binding !TodaysLessons.Count}"/>
</StackPanel>
</Border>
<!-- Offene Aufgaben -->
<Border Grid.Column="1" Grid.Row="0" Margin="8,0,0,8"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding OpenTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding Title}"
FontSize="13" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding DueDate}"
FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Aufgaben" Opacity="0.4" FontSize="13"
IsVisible="{Binding !OpenTasks.Count}"/>
</StackPanel>
</Border>
<!-- Meine Lerngruppen -->
<Border Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding CurrentGroups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:GroupChip">
<Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding}"
Background="{DynamicResource SystemAccentColorLight2}"
CornerRadius="6" Padding="12,6" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Lerngruppen." Opacity="0.4" FontSize="13"
IsVisible="{Binding !CurrentGroups.Count}"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,3 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Dashboard;
public partial class DashboardView : UserControl { public DashboardView() => InitializeComponent(); }
@@ -0,0 +1,41 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.AddGroupDialog"
x:DataType="vm:AddGroupDialogViewModel"
Title="Neue Lerngruppe"
Width="420" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="14">
<TextBlock Text="Neue Lerngruppe anlegen" FontSize="18" FontWeight="SemiBold"/>
<StackPanel Spacing="4">
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Name}" Watermark="z.B. 10E, Q1 Chemie, 5a"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Fach" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Subject}" Watermark="z.B. Chemie, Mathematik"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding GradeLevel}" Minimum="1" Maximum="13" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Schuljahr" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding SchoolYears}"
SelectedItem="{Binding SelectedSchoolYear}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Anlegen" HorizontalAlignment="Stretch" Click="OnSave"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,17 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class AddGroupDialog : Window
{
public AddGroupDialog() => InitializeComponent();
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is AddGroupDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,111 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
x:DataType="vm:GroupDetailViewModel">
<Grid RowDefinitions="Auto,*">
<!-- Header -->
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding GroupTitle}" FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding GroupSubtitle}" FontSize="12" Opacity="0.5"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<TextBlock VerticalAlignment="Center" Opacity="0.6" FontSize="13">
<Run Text="{Binding StudentCount}"/>
<Run Text=" Schüler"/>
</TextBlock>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}"/>
<Button Content=" Klausur" Command="{Binding AddExamCommand}"/>
</StackPanel>
</Grid>
</Border>
<!--
Avalonia 12: TabbedPage ersetzt manuelles Tab-System.
Kein ActiveTab-Property, kein IsVisible-Binding, keine eigenen Tab-Buttons.
TabPlacement="Top" → Tabs oben (wie Browser-Tabs).
-->
<TabbedPage Grid.Row="1" TabPlacement="Top">
<!-- Tab: Übersicht -->
<ContentPage Header="Übersicht">
<ScrollViewer Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="{Binding GroupSubtitle}" Opacity="0.7" FontSize="14"/>
<TextBlock Text="Hier erscheint später eine Zusammenfassung der Lerngruppe."
Opacity="0.4"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Schüler -->
<ContentPage Header="Schüler">
<DataGrid ItemsSource="{Binding Students}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
Margin="0">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding FullName}"
Width="*"/>
</DataGrid.Columns>
</DataGrid>
</ContentPage>
<!-- Tab: Klausuren -->
<ContentPage Header="Klausuren">
<DataGrid ItemsSource="{Binding Exams}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Datum" Binding="{Binding Date}" Width="110"/>
<DataGridTextColumn Header="Titel" Binding="{Binding Title}" Width="*"/>
<DataGridTextColumn Header="Status" Binding="{Binding StatusLabel}" Width="130"/>
</DataGrid.Columns>
</DataGrid>
</ContentPage>
<!-- Tab: Noten -->
<ContentPage Header="Noten">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage>
<!-- Tab: Planung -->
<ContentPage Header="Planung">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Unterrichtseinheiten" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage>
<!-- Tab: Dokumentation -->
<ContentPage Header="Dokumentation">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Schülerdokumentation" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,3 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupDetailView : UserControl { public GroupDetailView() => InitializeComponent(); }
@@ -0,0 +1,71 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
x:DataType="vm:GroupListViewModel">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Lerngruppen" FontSize="22" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding Groups.Count}"/>
<Run Text=" Gruppen · "/>
<Run Text="{Binding SelectedSchoolYear}"/>
</TextBlock>
</StackPanel>
<ComboBox Grid.Column="1" ItemsSource="{Binding SchoolYears}"
SelectedItem="{Binding SelectedSchoolYear}"
Width="100" Margin="0,0,8,0" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content=" Neue Gruppe"
Command="{Binding AddGroupCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<Grid Grid.Row="1" ColumnDefinitions="260,*">
<Border Grid.Column="0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0">
<DockPanel>
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
PlaceholderText="Suchen…" Margin="12,8"/>
<ListBox ItemsSource="{Binding Groups}"
SelectedItem="{Binding SelectedGroup}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:GroupListItem">
<Grid ColumnDefinitions="4,*" Margin="2,4">
<Border Grid.Column="0" Width="4" CornerRadius="2"
Background="{DynamicResource SystemAccentColor}"
Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Grid.Column="1" Text="{Binding TypeLabel}"
FontSize="11" Opacity="0.5"/>
</Grid>
<TextBlock Text="{Binding Subject}" FontSize="12" Opacity="0.65"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding GradingLabel}" FontSize="11" Opacity="0.4"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<StackPanel Grid.Column="1" HorizontalAlignment="Center"
VerticalAlignment="Center" Spacing="8"
IsVisible="{Binding !SelectedGroup}">
<TextBlock Text="Gruppe auswählen" FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="oder Neue Gruppe anlegen" FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,3 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupListView : UserControl { public GroupListView() => InitializeComponent(); }
+119
View File
@@ -0,0 +1,119 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vms="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views"
xmlns:vd="clr-namespace:LehrerApp.Desktop.Views.Dashboard"
xmlns:vg="clr-namespace:LehrerApp.Desktop.Views.Groups"
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="LehrerApp"
Width="1280" Height="800"
MinWidth="900" MinHeight="600">
<!--
Avalonia 12: DrawerPage ersetzt die selbstgebaute Sidebar.
DrawerBreakpointWidth="900" → Sidebar ab 900px dauerhaft sichtbar,
darunter als Overlay-Drawer mit Hamburger-Button (automatisch).
Kein eigener Code nötig.
-->
<DrawerPage DrawerLength="220"
DrawerBehavior="Auto">
<DrawerPage.Drawer>
<DockPanel>
<Border DockPanel.Dock="Top" Padding="16,20,16,14"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock Text="LehrerApp" FontSize="20" FontWeight="SemiBold"/>
<TextBlock Text="{Binding CurrentSchoolYear}"
FontSize="12" Opacity="0.55" Margin="0,2,0,0"/>
</StackPanel>
</Border>
<Border DockPanel.Dock="Bottom" Padding="12,8"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,1,0,0">
<views:SyncStatusBar/>
</Border>
<ScrollViewer>
<StackPanel Margin="8,12,8,0" Spacing="2">
<Button Content="📊 Dashboard" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Dashboard}"/>
<TextBlock Text="UNTERRICHT" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="10,14,0,4"/>
<Button Content="🏫 Lerngruppen" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Groups}"/>
<Button Content="👤 Schüler" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Students}"/>
<Button Content="📝 Klausuren" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Exams}"/>
<Button Content="📅 Planung" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Planner}"/>
<TextBlock Text="VERWALTUNG" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="10,14,0,4"/>
<Button Content="⏱ Arbeitszeit" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Workload}"/>
<Button Content="⚙️ Einstellungen" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left" Background="Transparent"
Padding="10,8" CornerRadius="6"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Settings}"/>
</StackPanel>
</ScrollViewer>
</DockPanel>
</DrawerPage.Drawer>
<!-- DataTemplates verbinden ViewModels mit ihren Views -->
<ContentControl Content="{Binding CurrentPage}">
<ContentControl.DataTemplates>
<DataTemplate DataType="vm:DashboardViewModel">
<vd:DashboardView/>
</DataTemplate>
<DataTemplate DataType="vmg:GroupListViewModel">
<vg:GroupListView/>
</DataTemplate>
<DataTemplate DataType="vmg:GroupDetailViewModel">
<vg:GroupDetailView/>
</DataTemplate>
<DataTemplate DataType="vms:StudentListViewModel">
<vs:StudentListView/>
</DataTemplate>
<DataTemplate DataType="vms:StudentDetailViewModel">
<vs:StudentDetailView/>
</DataTemplate>
<DataTemplate DataType="vm:PlaceholderViewModel">
<views:PlaceholderView/>
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</DrawerPage>
</Window>
@@ -0,0 +1,8 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views;
public partial class MainWindow : Window
{
public MainWindow() => InitializeComponent();
}
@@ -0,0 +1,13 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.PlaceholderView"
x:DataType="vm:PlaceholderViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12">
<TextBlock Text="{Binding Icon}" FontSize="48" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Title}" FontSize="24" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird in einer späteren Version implementiert."
Opacity="0.5" HorizontalAlignment="Center"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,3 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views;
public partial class PlaceholderView : UserControl { public PlaceholderView() => InitializeComponent(); }
@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentDetailView"
x:DataType="vm:StudentDetailViewModel">
<Grid RowDefinitions="Auto,*">
<!-- Header -->
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" IsVisible="{Binding !IsEditing}">
<TextBlock Text="{Binding StudentTitle}" FontSize="22" FontWeight="SemiBold"/>
</StackPanel>
<StackPanel Grid.Column="0" Spacing="6" IsVisible="{Binding IsEditing}">
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding EditFirstName}" PlaceholderText="Vorname"/>
<TextBox Grid.Column="2" Text="{Binding EditLastName}" PlaceholderText="Nachname"/>
</Grid>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Top">
<Button Content="Bearbeiten" Command="{Binding StartEditCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Speichern" Command="{Binding SaveEditCommand}"
IsVisible="{Binding IsEditing}"/>
<Button Content="Abbrechen" Command="{Binding CancelEditCommand}"
IsVisible="{Binding IsEditing}"/>
</StackPanel>
</Grid>
</Border>
<!--
Avalonia 12: TabbedPage für Schüler-Tabs.
Kein manuelles Tab-Management nötig.
-->
<TabbedPage Grid.Row="1" TabPlacement="Top">
<ContentPage Header="Übersicht">
<ScrollViewer Padding="20">
<StackPanel Spacing="12">
<TextBlock Text="Lerngruppen" FontSize="15" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding Enrollments}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:EnrollmentEntry">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,8" Margin="0,0,0,6">
<Grid ColumnDefinitions="70,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding SchoolYear}" FontWeight="SemiBold"/>
<TextBlock Grid.Column="1" Text="{Binding GroupName}" Margin="8,0"/>
<TextBlock Grid.Column="2" Text="{Binding Subject}" Opacity="0.5" FontSize="12"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Einschreibungen vorhanden." Opacity="0.4"
IsVisible="{Binding !Enrollments.Count}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<ContentPage Header="Noten">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Text="Notenübersicht" FontSize="16" Opacity="0.4" HorizontalAlignment="Center"/>
<TextBlock Text="Wird implementiert." FontSize="12" Opacity="0.3" HorizontalAlignment="Center"/>
</StackPanel>
</ContentPage>
<ContentPage Header="Dokumentation">
<ScrollViewer Padding="20">
<StackPanel>
<ItemsControl ItemsSource="{Binding Documentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:DocEntry">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
<Grid ColumnDefinitions="80,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Date}" Opacity="0.5" FontSize="12"/>
<StackPanel Grid.Column="1" Margin="8,0">
<TextBlock Text="{Binding Title}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding TypeLabel}" FontSize="11" Opacity="0.5"/>
</StackPanel>
<TextBlock Grid.Column="2" Text="🔒" FontSize="14"
IsVisible="{Binding IsConfidential}"
ToolTip.Tip="Vertraulich"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Dokumentation vorhanden." Opacity="0.4"
IsVisible="{Binding !Documentation.Count}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -0,0 +1,3 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentDetailView : UserControl { public StudentDetailView() => InitializeComponent(); }
@@ -0,0 +1,40 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentListView"
x:DataType="vm:StudentListViewModel">
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Schüler" FontSize="22" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding Students.Count}"/>
<Run Text=" Schüler gesamt"/>
</TextBlock>
</StackPanel>
<CheckBox Grid.Column="1" Content="Inaktive anzeigen"
IsChecked="{Binding ShowInactive}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
<Button Grid.Column="2" Content=" Neuer Schüler"
Command="{Binding AddStudentCommand}" VerticalAlignment="Center"/>
</Grid>
</Border>
<DockPanel Grid.Row="1">
<TextBox DockPanel.Dock="Top" Text="{Binding SearchText}"
PlaceholderText="Name suchen…" Margin="16,10,16,4"/>
<DataGrid ItemsSource="{Binding Students}"
SelectedItem="{Binding SelectedStudent}"
AutoGenerateColumns="False" IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False" Margin="16,4">
<DataGrid.Columns>
<DataGridTextColumn Header="Name" Binding="{Binding FullName}" Width="*"/>
<DataGridTextColumn Header="Geburtsdatum" Binding="{Binding DateOfBirth}" Width="130"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</UserControl>
@@ -0,0 +1,3 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentListView : UserControl { public StudentListView() => InitializeComponent(); }
@@ -0,0 +1,18 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
x:DataType="vm:SyncStatusViewModel">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StatusText}" FontSize="12" Opacity="0.7"
TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding LastSyncText}" FontSize="10" Opacity="0.4"/>
</StackPanel>
<Button Grid.Column="1" Content="↻" FontSize="14"
Command="{Binding SyncNowCommand}"
IsVisible="{Binding IsServerConfigured}"
Background="Transparent" Padding="6,4"
ToolTip.Tip="Jetzt synchronisieren"/>
</Grid>
</UserControl>
@@ -0,0 +1,16 @@
using Avalonia.Controls;
using LehrerApp.Desktop.ViewModels;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views;
public partial class SyncStatusBar : UserControl
{
public SyncStatusBar()
{
InitializeComponent();
Loaded += (_, _) =>
{
if (DataContext is null)
DataContext = App.Services.GetRequiredService<SyncStatusViewModel>();
};
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LehrerApp.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
+35
View File
@@ -0,0 +1,35 @@
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Desktop gewinnt gegen Companion.
/// Bei Gleichstand: späterer Timestamp gewinnt.
/// </summary>
public class ConflictResolver(EventQueue queue)
{
public ConflictEntry? TryResolve(SyncEvent remote, string localDeviceId)
{
var local = queue.GetPending()
.FirstOrDefault(e => e.EntityType == remote.EntityType
&& e.EntityId == remote.EntityId
&& e.DeviceId != remote.DeviceId);
if (local is null) return null;
var winner = (local.DeviceType, remote.DeviceType) switch
{
(DeviceType.Desktop, DeviceType.Companion) => local,
(DeviceType.Companion, DeviceType.Desktop) => remote,
_ => local.Timestamp >= remote.Timestamp ? local : remote,
};
if (winner == remote) queue.Acknowledge([local.EventId]);
return new ConflictEntry
{
LocalEvent = local,
RemoteEvent = remote,
Resolution = winner == local ? "LocalWon" : "RemoteWon",
};
}
}
+96
View File
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace LehrerApp.Sync.Crypto;
/// <summary>
/// AES-256-GCM Verschlüsselung + PBKDF2 Schlüsselableitung.
///
/// Pairing-Flow:
/// Sender: EncryptedSyncKey = AES(syncKey, PBKDF2(code))
/// Empfänger: syncKey = AES_Decrypt(EncryptedSyncKey, PBKDF2(code))
/// → Schlüssel verlässt nie den Server, nur der Code wird geteilt.
/// </summary>
public static class SyncCrypto
{
private const int KeySize = 32; // 256 bit
private const int NonceSize = 12; // 96 bit GCM Standard
private const int TagSize = 16; // 128 bit Auth-Tag
private const int Pbkdf2Iter = 100_000;
// ── Schlüssel ──────────────────────────────────────────────────────────────
public static byte[] GenerateKey()
{
var k = new byte[KeySize];
RandomNumberGenerator.Fill(k);
return k;
}
public static string KeyToBase64(byte[] key) => Convert.ToBase64String(key);
public static byte[] KeyFromBase64(string b64) => Convert.FromBase64String(b64);
/// <summary>Leitet Schlüssel aus Einmal-Code ab. PBKDF2 erschwert Brute-Force.</summary>
public static byte[] DeriveKeyFromCode(string code)
{
var bytes = Encoding.UTF8.GetBytes(code.Trim().ToUpperInvariant());
var salt = Encoding.UTF8.GetBytes("LehrerApp-PairingCode-v1");
return Rfc2898DeriveBytes.Pbkdf2(bytes, salt, Pbkdf2Iter, HashAlgorithmName.SHA256, 32);
}
public static string EncryptKeyWithCode(byte[] syncKey, string code) =>
Convert.ToBase64String(Encrypt(syncKey, DeriveKeyFromCode(code)));
public static byte[] DecryptKeyWithCode(string encryptedB64, string code) =>
Decrypt(Convert.FromBase64String(encryptedB64), DeriveKeyFromCode(code));
// ── Ver-/Entschlüsselung ───────────────────────────────────────────────────
/// <summary>Format: [Nonce 12B][Ciphertext][Tag 16B]</summary>
public static byte[] Encrypt(byte[] plaintext, byte[] key)
{
var nonce = new byte[NonceSize];
RandomNumberGenerator.Fill(nonce);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[TagSize];
using var aes = new AesGcm(key, TagSize);
aes.Encrypt(nonce, plaintext, ciphertext, tag);
var result = new byte[NonceSize + ciphertext.Length + TagSize];
nonce.CopyTo(result, 0);
ciphertext.CopyTo(result, NonceSize);
tag.CopyTo(result, NonceSize + ciphertext.Length);
return result;
}
public static byte[] Decrypt(byte[] encrypted, byte[] key)
{
if (encrypted.Length < NonceSize + TagSize)
throw new CryptographicException("Ungültiges Datenformat.");
var nonce = encrypted[..NonceSize];
var tag = encrypted[^TagSize..];
var ciphertext = encrypted[NonceSize..^TagSize];
var plaintext = new byte[ciphertext.Length];
using var aes = new AesGcm(key, TagSize);
aes.Decrypt(nonce, ciphertext, tag, plaintext);
return plaintext;
}
// ── JSON-Komfort ───────────────────────────────────────────────────────────
public static string EncryptObject<T>(T obj, byte[] key) =>
Convert.ToBase64String(Encrypt(JsonSerializer.SerializeToUtf8Bytes(obj), key));
public static T? DecryptObject<T>(string b64, byte[] key) =>
JsonSerializer.Deserialize<T>(Decrypt(Convert.FromBase64String(b64), key));
// ── Schlüsselspeicherung ───────────────────────────────────────────────────
public static void SaveKey(byte[] key, string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, KeyToBase64(key));
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
public static byte[]? LoadKey(string path) =>
File.Exists(path) ? KeyFromBase64(File.ReadAllText(path).Trim()) : null;
}
+77
View File
@@ -0,0 +1,77 @@
using LiteDB;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Lokale Event-Queue in LiteDB. Puffert Events bis sie
/// erfolgreich zum Server gepusht wurden.
/// </summary>
public class EventQueue : IDisposable
{
private readonly LiteDatabase _db;
private readonly ILiteCollection<SyncEvent> _queue;
private readonly ILiteCollection<SyncMeta> _meta;
private readonly ILiteCollection<ConflictEntry> _conflicts;
private long _currentSeq;
public EventQueue(string path)
{
_db = new LiteDatabase(path);
_queue = _db.GetCollection<SyncEvent>("queue");
_meta = _db.GetCollection<SyncMeta>("meta");
_conflicts = _db.GetCollection<ConflictEntry>("conflicts");
_queue.EnsureIndex(x => x.SequenceNr);
_currentSeq = _meta.FindById("seq")?.Value ?? 0;
}
public SyncEvent Enqueue(string deviceId, DeviceType deviceType,
string entityType, string entityId, string operation, string payload)
{
var evt = new SyncEvent
{
DeviceId = deviceId,
DeviceType = deviceType,
Timestamp = DateTime.UtcNow,
SequenceNr = ++_currentSeq,
EntityType = entityType,
EntityId = entityId,
Operation = operation,
Payload = payload,
};
_queue.Insert(evt);
_meta.Upsert(new SyncMeta { Id = "seq", Value = _currentSeq });
return evt;
}
public List<SyncEvent> GetPending(int max = 200) =>
_queue.Find(Query.All(nameof(SyncEvent.SequenceNr))).Take(max).ToList();
public int PendingCount() => _queue.Count();
public void Acknowledge(IEnumerable<Guid> ids) { foreach (var id in ids) _queue.Delete(id); }
public long GetLastServerSeq() => _meta.FindById("serverSeq")?.Value ?? 0;
public void SetLastServerSeq(long nr) => _meta.Upsert(new SyncMeta { Id = "serverSeq", Value = nr });
public DateTime? GetLastSyncAt() => _meta.FindById("lastSync")?.Timestamp;
public void SetLastSyncAt(DateTime dt) =>
_meta.Upsert(new SyncMeta { Id = "lastSync", Value = 0, Timestamp = dt });
public void AddConflict(ConflictEntry c) => _conflicts.Insert(c);
public List<ConflictEntry> GetUnreviewed() => _conflicts.Find(c => !c.Reviewed).ToList();
public int ConflictCount() => _conflicts.Count(c => !c.Reviewed);
public void Dispose() => _db.Dispose();
}
public class ConflictEntry
{
public Guid Id { get; init; } = Guid.NewGuid();
public DateTime DetectedAt { get; init; } = DateTime.UtcNow;
public SyncEvent LocalEvent { get; init; } = null!;
public SyncEvent RemoteEvent { get; init; } = null!;
public string Resolution { get; init; } = "";
public bool Reviewed { get; set; }
}
internal class SyncMeta
{
public string Id { get; set; } = "";
public long Value { get; set; }
public DateTime? Timestamp { get; set; }
}
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="LiteDB" />
</ItemGroup>
</Project>
+95
View File
@@ -0,0 +1,95 @@
namespace LehrerApp.Sync.Models;
// ── Events ────────────────────────────────────────────────────────────────────
/// <summary>Desktop-Event: Payload ist AES-256-GCM verschlüsselt.</summary>
public class SyncEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public string DeviceId { get; init; } = "";
public DeviceType DeviceType { get; init; }
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
public long SequenceNr { get; init; }
public string EntityType { get; init; } = "";
public string EntityId { get; init; } = "";
public string Operation { get; init; } = "";
/// <summary>Verschlüsselt (Desktop) oder Klartext (Companion/WebApp).</summary>
public string Payload { get; init; } = "";
}
/// <summary>WebApp/Companion-Event: Payload ist Klartext-JSON.</summary>
public class PlainSyncEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public string DeviceId { get; init; } = "";
public DeviceType DeviceType { get; init; } = DeviceType.Companion;
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
public string EntityType { get; init; } = "";
public string EntityId { get; init; } = "";
public string Operation { get; init; } = "";
public string Payload { get; init; } = "";
}
// ── Sync API Responses ────────────────────────────────────────────────────────
public class PushResponse
{
public bool Success { get; init; }
public long ServerSequenceNr { get; init; }
public List<Guid> ConflictingEventIds { get; init; } = [];
}
public class PullResponse
{
public List<SyncEvent> Events { get; init; } = [];
public long ServerSequenceNr { get; init; }
}
public class PlainPushResponse
{
public bool Success { get; init; }
public long ServerSequenceNr { get; init; }
public List<Guid> RejectedEventIds { get; init; } = [];
}
// ── Snapshot Modelle ──────────────────────────────────────────────────────────
/// <summary>
/// Upload-Request für Device-Pairing.
/// EncryptedPayload: LiteDB verschlüsselt mit Sync-Key.
/// EncryptedSyncKey: Sync-Key verschlüsselt mit PBKDF2(Code).
/// → Empfänger braucht nur den Code um beides zu entschlüsseln.
/// </summary>
public class SnapshotUploadRequest
{
public string EncryptedPayload { get; init; } = "";
public string EncryptedSyncKey { get; init; } = "";
public DeviceType DeviceType { get; init; }
}
public class SnapshotUploadResponse
{
/// <summary>Format: WORT-ZZ-WORT, z.B. "TIGER-42-BLAU". 24h gültig, einmalig.</summary>
public string Code { get; init; } = "";
public DateTime ExpiresAt { get; init; }
}
public class SnapshotDownloadResponse
{
public string EncryptedPayload { get; init; } = "";
public string EncryptedSyncKey { get; init; } = "";
public DateTime CreatedAt { get; init; }
public DeviceType SourceDeviceType { get; init; }
}
// ── Status ────────────────────────────────────────────────────────────────────
public class SyncStatus
{
public SyncState State { get; set; } = SyncState.Idle;
public DateTime? LastSyncAt { get; set; }
public int PendingEvents { get; set; }
public int ConflictCount { get; set; }
public string? ErrorMessage { get; set; }
}
// ── Enums ─────────────────────────────────────────────────────────────────────
public enum DeviceType { Desktop, Companion }
public enum SyncState { Idle, Syncing, Error, Offline }
+100
View File
@@ -0,0 +1,100 @@
using System.Net.Http.Json;
using LehrerApp.Data;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Device-Pairing via verschlüsseltem Snapshot + Einmal-Code.
///
/// Sender: CreateAndUploadAsync() → zeigt Code dem Nutzer
/// Empfänger: RestoreFromCodeAsync(code) → entschlüsselt Schlüssel + DB
/// </summary>
public class SnapshotService(
HttpClient http, LiteDbContext db, byte[] syncKey,
DeviceType deviceType, string dbPath, string keyPath)
{
public event Action<SnapshotProgress>? ProgressChanged;
public async Task<SnapshotUploadResponse> CreateAndUploadAsync(CancellationToken ct = default)
{
Report(SnapshotStep.Checkpointing, "Datenbank wird gesichert…");
db.Checkpoint();
Report(SnapshotStep.Reading, "Datenbank wird gelesen…");
var dbBytes = await File.ReadAllBytesAsync(dbPath, ct);
Report(SnapshotStep.Encrypting, "Verschlüsselung läuft…");
var encPayload = Convert.ToBase64String(SyncCrypto.Encrypt(dbBytes, syncKey));
// Schritt 1: Upload ohne Key → Code erhalten
Report(SnapshotStep.Uploading, "Code wird angefordert…");
var r1 = await http.PostAsJsonAsync("/api/snapshot/upload",
new SnapshotUploadRequest { EncryptedPayload = encPayload, DeviceType = deviceType }, ct);
r1.EnsureSuccessStatusCode();
var init = await r1.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
// Schritt 2: Key mit Code verschlüsseln + erneut hochladen
Report(SnapshotStep.Uploading, "Schlüssel wird verschlüsselt…");
var encKey = SyncCrypto.EncryptKeyWithCode(syncKey, init.Code);
var r2 = await http.PostAsJsonAsync("/api/snapshot/upload",
new SnapshotUploadRequest { EncryptedPayload = encPayload,
EncryptedSyncKey = encKey, DeviceType = deviceType }, ct);
r2.EnsureSuccessStatusCode();
var result = await r2.Content.ReadFromJsonAsync<SnapshotUploadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
Report(SnapshotStep.Done, $"Bereit Code: {result.Code}");
return result;
}
public async Task<byte[]> RestoreFromCodeAsync(string code, string targetDbPath,
CancellationToken ct = default)
{
var sanitized = code.Trim().ToUpperInvariant();
Report(SnapshotStep.Downloading, "Snapshot wird geladen…");
var resp = await http.GetAsync($"/api/snapshot/{sanitized}", ct);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
throw new SnapshotNotFoundException($"Code '{sanitized}' nicht gefunden oder abgelaufen.");
resp.EnsureSuccessStatusCode();
var dl = await resp.Content.ReadFromJsonAsync<SnapshotDownloadResponse>(ct)
?? throw new InvalidOperationException("Leere Server-Antwort.");
Report(SnapshotStep.Decrypting, "Schlüssel wird entschlüsselt…");
byte[] restoredKey;
try { restoredKey = SyncCrypto.DecryptKeyWithCode(dl.EncryptedSyncKey, sanitized); }
catch (System.Security.Cryptography.CryptographicException)
{ throw new InvalidOperationException("Schlüssel-Entschlüsselung fehlgeschlagen Code korrekt?"); }
SyncCrypto.SaveKey(restoredKey, keyPath);
Report(SnapshotStep.Decrypting, "Datenbank wird entschlüsselt…");
var dbBytes = SyncCrypto.Decrypt(Convert.FromBase64String(dl.EncryptedPayload), restoredKey);
Report(SnapshotStep.Writing, "Datenbank wird geschrieben…");
Directory.CreateDirectory(Path.GetDirectoryName(targetDbPath)!);
if (File.Exists(targetDbPath))
File.Move(targetDbPath, $"{targetDbPath}.backup-{DateTime.Now:yyyyMMdd-HHmmss}");
await File.WriteAllBytesAsync(targetDbPath, dbBytes, ct);
Report(SnapshotStep.Done, "Abgeschlossen bitte App neu starten.");
return restoredKey;
}
private void Report(SnapshotStep step, string msg) =>
ProgressChanged?.Invoke(new(step, msg));
}
public record SnapshotProgress(SnapshotStep Step, string Message)
{
public int Percent => Step switch
{
SnapshotStep.Checkpointing => 10, SnapshotStep.Reading => 20,
SnapshotStep.Encrypting => 35, SnapshotStep.Uploading => 60,
SnapshotStep.Downloading => 35, SnapshotStep.Decrypting => 65,
SnapshotStep.Writing => 85, SnapshotStep.Done => 100,
_ => 0,
};
public bool IsComplete => Step == SnapshotStep.Done;
}
public enum SnapshotStep { Idle, Checkpointing, Reading, Encrypting,
Uploading, Downloading, Decrypting, Writing, Done }
public class SnapshotNotFoundException(string msg) : Exception(msg);
+116
View File
@@ -0,0 +1,116 @@
using System.Net.Http.Json;
using LehrerApp.Sync.Models;
namespace LehrerApp.Sync;
/// <summary>
/// Push/Pull Orchestrierung.
/// Automatisch alle N Minuten + manuell per SyncNowAsync().
/// </summary>
public class SyncEngine : IDisposable
{
private readonly EventQueue _queue;
private readonly ConflictResolver _resolver;
private readonly HttpClient _http;
private readonly SyncConfig _config;
private readonly Timer _timer;
public SyncStatus Status { get; private set; } = new();
public event Action<SyncStatus>? StatusChanged;
public SyncEngine(EventQueue queue, ConflictResolver resolver,
HttpClient http, SyncConfig config)
{
_queue = queue;
_resolver = resolver;
_http = http;
_config = config;
_timer = new Timer(
async _ => await SyncNowAsync(true), null,
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes),
TimeSpan.FromMinutes(config.AutoSyncIntervalMinutes));
UpdateStatus();
}
public async Task<SyncResult> SyncNowAsync(bool isAutomatic = false)
{
if (Status.State == SyncState.Syncing)
return new() { Skipped = true, Reason = "Sync bereits aktiv" };
SetState(SyncState.Syncing);
try
{
var (pushed, _) = await PushAsync();
var (pulled, conflicts) = await PullAsync();
_queue.SetLastSyncAt(DateTime.UtcNow);
SetState(SyncState.Idle);
return new() { Success = true, EventsPushed = pushed, EventsPulled = pulled, Conflicts = conflicts };
}
catch (HttpRequestException) { SetState(SyncState.Offline); return new() { Reason = "Server nicht erreichbar" }; }
catch (Exception ex) { SetState(SyncState.Error, ex.Message); return new() { Reason = ex.Message }; }
}
private async Task<(int Pushed, int Conflicts)> PushAsync()
{
var pending = _queue.GetPending();
if (pending.Count == 0) return (0, 0);
var resp = await _http.PostAsJsonAsync("/api/sync/push", pending);
resp.EnsureSuccessStatusCode();
var result = await resp.Content.ReadFromJsonAsync<PushResponse>();
if (result is null) return (0, 0);
_queue.Acknowledge(pending
.Where(e => !result.ConflictingEventIds.Contains(e.EventId))
.Select(e => e.EventId));
_queue.SetLastServerSeq(result.ServerSequenceNr);
return (pending.Count - result.ConflictingEventIds.Count,
result.ConflictingEventIds.Count);
}
private async Task<(int Pulled, int Conflicts)> PullAsync()
{
var since = _queue.GetLastServerSeq();
var resp = await _http.GetFromJsonAsync<PullResponse>(
$"/api/sync/pull?since={since}&deviceId={_config.DeviceId}");
if (resp is null || resp.Events.Count == 0) return (0, 0);
var conflicts = 0;
foreach (var evt in resp.Events)
{
var c = _resolver.TryResolve(evt, _config.DeviceId);
if (c is not null) { _queue.AddConflict(c); conflicts++; }
}
_queue.SetLastServerSeq(resp.ServerSequenceNr);
return (resp.Events.Count, conflicts);
}
private void SetState(SyncState state, string? error = null)
{
Status = new SyncStatus
{
State = state,
LastSyncAt = _queue.GetLastSyncAt(),
PendingEvents = _queue.PendingCount(),
ConflictCount = _queue.ConflictCount(),
ErrorMessage = error,
};
StatusChanged?.Invoke(Status);
}
private void UpdateStatus() => SetState(Status.State);
public void Dispose() { _timer.Dispose(); _queue.Dispose(); }
}
public class SyncConfig
{
public string ServerUrl { get; set; } = "";
public string DeviceId { get; set; } = "";
public DeviceType DeviceType { get; set; } = DeviceType.Desktop;
public int AutoSyncIntervalMinutes { get; set; } = 5;
}
public class SyncResult
{
public bool Success { get; set; }
public bool Skipped { get; set; }
public string? Reason { get; set; }
public int EventsPushed { get; set; }
public int EventsPulled { get; set; }
public int Conflicts { get; set; }
}
+30
View File
@@ -0,0 +1,30 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Core", "LehrerApp.Core\LehrerApp.Core.csproj", "{A1000001-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Data", "LehrerApp.Data\LehrerApp.Data.csproj", "{A1000002-0000-0000-0000-000000000002}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Sync", "LehrerApp.Sync\LehrerApp.Sync.csproj", "{A1000003-0000-0000-0000-000000000003}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Api", "LehrerApp.Api\LehrerApp.Api.csproj", "{A1000004-0000-0000-0000-000000000004}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LehrerApp.Desktop", "LehrerApp.Desktop\LehrerApp.Desktop.csproj", "{A1000005-0000-0000-0000-000000000005}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000003-0000-0000-0000-000000000003}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000004-0000-0000-0000-000000000004}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1000005-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
EndGlobal
+19
View File
@@ -0,0 +1,19 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["LehrerApp.Api/LehrerApp.Api.csproj", "LehrerApp.Api/"]
COPY ["LehrerApp.Sync/LehrerApp.Sync.csproj", "LehrerApp.Sync/"]
COPY ["LehrerApp.Core/LehrerApp.Core.csproj", "LehrerApp.Core/"]
COPY ["LehrerApp.Data/LehrerApp.Data.csproj", "LehrerApp.Data/"]
COPY ["Directory.Build.props", "."]
COPY ["Directory.Packages.props", "."]
RUN dotnet restore "LehrerApp.Api/LehrerApp.Api.csproj"
COPY . .
RUN dotnet publish "LehrerApp.Api/LehrerApp.Api.csproj" -c Release -o /app/publish
FROM base AS final
COPY --from=build /app/publish .
RUN mkdir -p /app/data
ENTRYPOINT ["dotnet", "LehrerApp.Api.dll"]
+13
View File
@@ -0,0 +1,13 @@
services:
api:
build:
context: ..
dockerfile: docker/Dockerfile.api
ports:
- "5000:5000"
volumes:
- ./data:/app/data
environment:
- JWT_SECRET=${JWT_SECRET}
- ASPNETCORE_ENVIRONMENT=Production
restart: unless-stopped
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.301",
"rollForward": "latestPatch"
}
}