Add animated classroom grouping with saved editable plans
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
admin committed 2026-09-29 22:17:49 +02:00
1 parent d4b1419f9a
commit f0462bd2a1
27 files changed
+774 -45

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
<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" />
<PackageVersion Include="Avalonia.Labs.Lottie" Version="12.0.2" />
<!-- MVVM & DI – .NET 10 kompatibel -->
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
@@ -29,6 +29,14 @@ public interface ISeatingPlanRepository
/// wieder her (14.3). Tut nichts, falls der Papierkorb-Eintrag nicht (mehr) existiert.</summary>
void Restore(Guid trashId);
}
public interface IStudentGroupPlanRepository
{
StudentGroupPlan? GetById(Guid id);
List<StudentGroupPlan> GetByGroup(Guid groupId);
List<StudentGroupPlan> GetByStudent(Guid studentId);
void Save(StudentGroupPlan plan);
void Delete(Guid id);
}
public interface IGroupMembershipRepository
{
List<GroupMembership> GetByStudent(Guid studentId);
+3 -2
View File
@@ -52,9 +52,10 @@ public sealed record StudentReferenceSummary(
int Grades,
int ReportGrades,
int ParticipationEntries,
int DocumentationEntries)
int DocumentationEntries,
int StudentGroupPlans = 0)
{
public int Total => Memberships + ExamResults + Grades + ReportGrades
+ ParticipationEntries + DocumentationEntries;
+ ParticipationEntries + DocumentationEntries + StudentGroupPlans;
public bool HasReferences => Total > 0;
}
+17
View File
@@ -0,0 +1,17 @@
namespace LehrerApp.Core.Models;
/// <summary>Eine wiederverwendbare Einteilung innerhalb einer Lerngruppe.</summary>
public class StudentGroupPlan
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public List<StudentGroupPlanGroup> Groups { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public class StudentGroupPlanGroup
{
public List<Guid> StudentIds { get; set; } = [];
}
@@ -22,7 +22,8 @@ public class PersonalDataExportService(
IExamResultRepository examResults,
IParticipationRepository participation,
IDocumentationRepository documentation,
ISeatingPlanRepository seatingPlans)
ISeatingPlanRepository seatingPlans,
IStudentGroupPlanRepository? studentGroupPlans = null)
{
public string ExportAsJson(Guid studentId)
{
@@ -56,6 +57,16 @@ public class PersonalDataExportService(
Platz = a.Column + 1,
}))
.ToList(),
GespeicherteGruppeneinteilungen = (studentGroupPlans?.GetByStudent(studentId) ?? [])
.SelectMany(plan => plan.Groups.Select((subgroup, index) => new
{
Einteilung = plan.Name,
Gruppe = index + 1,
Enthalten = subgroup.StudentIds.Contains(studentId),
}))
.Where(entry => entry.Enthalten)
.Select(entry => new { entry.Einteilung, entry.Gruppe })
.ToList(),
};
return JsonSerializer.Serialize(dto, new JsonSerializerOptions
+66
View File
@@ -8,6 +8,72 @@ public sealed class RepositoryTests
{
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
[Fact]
public void StudentGroupPlanRepository_SpeichertEinteilungUndLoeschtSieMitDerLerngruppe()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a" };
new GroupRepository(db).Save(group);
var studentIds = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };
var plan = new StudentGroupPlan
{
GroupId = group.Id,
Name = " Projektwoche ",
Groups =
[
new StudentGroupPlanGroup { StudentIds = [studentIds[0], studentIds[1]] },
new StudentGroupPlanGroup { StudentIds = [studentIds[2]] },
],
};
var changes = new List<(string Type, string Operation)>();
db.OnChange = (type, _, operation, _) => changes.Add((type, operation));
var repo = new StudentGroupPlanRepository(db);
repo.Save(plan);
Assert.Equal("Projektwoche", repo.GetById(plan.Id)?.Name);
Assert.Equal(studentIds[2], repo.GetByGroup(group.Id).Single().Groups[1].StudentIds.Single());
Assert.Equal(plan.Id, Assert.Single(repo.GetByStudent(studentIds[0])).Id);
Assert.Contains((nameof(StudentGroupPlan), "Save"), changes);
var student = new Student { Id = studentIds[0], FirstName = "Anna", LastName = "Beispiel" };
var students = new StudentRepository(db);
students.Save(student);
Assert.Throws<InvalidOperationException>(() => students.Delete(student.Id));
new GroupRepository(db).Delete(group.Id);
Assert.Empty(repo.GetByGroup(group.Id));
}
[Fact]
public void StudentGroupPlanRepository_VerhindertDoppelteSchuelerUndNamen()
{
using var db = NewInMemoryContext();
var group = new LearningGroup { Name = "8a" };
new GroupRepository(db).Save(group);
var repo = new StudentGroupPlanRepository(db);
var studentId = Guid.NewGuid();
Assert.Throws<InvalidOperationException>(() => repo.Save(new StudentGroupPlan
{
GroupId = group.Id, Name = "Doppelt",
Groups =
[
new StudentGroupPlanGroup { StudentIds = [studentId] },
new StudentGroupPlanGroup { StudentIds = [studentId] },
],
}));
repo.Save(new StudentGroupPlan
{
GroupId = group.Id, Name = "Projektwoche",
Groups = [new StudentGroupPlanGroup { StudentIds = [studentId] }],
});
Assert.Throws<InvalidOperationException>(() => repo.Save(new StudentGroupPlan
{
GroupId = group.Id, Name = "projektwoche",
Groups = [new StudentGroupPlanGroup()],
}));
}
// ── StudentRepository ─────────────────────────────────────────────────────
[Fact]
+4
View File
@@ -41,6 +41,7 @@ public class LiteDbContext : IDisposable
public ILiteCollection<Student> Students => _db.GetCollection<Student>("students");
public ILiteCollection<LearningGroup> Groups => _db.GetCollection<LearningGroup>("groups");
public ILiteCollection<SeatingPlan> SeatingPlans => _db.GetCollection<SeatingPlan>("seating_plans");
public ILiteCollection<StudentGroupPlan> StudentGroupPlans => _db.GetCollection<StudentGroupPlan>("student_group_plans");
public ILiteCollection<GroupMembership> Memberships => _db.GetCollection<GroupMembership>("group_memberships");
public ILiteCollection<Exam> Exams => _db.GetCollection<Exam>("exams");
public ILiteCollection<ExamResult> ExamResults => _db.GetCollection<ExamResult>("exam_results");
@@ -141,6 +142,8 @@ public class LiteDbContext : IDisposable
foreach (var plan in SeatingPlans.Find(p => p.GroupId == id).ToList())
SeatingPlans.Delete(plan.Id);
foreach (var plan in StudentGroupPlans.Find(p => p.GroupId == id).ToList())
StudentGroupPlans.Delete(plan.Id);
// Dokumentation und Arbeitszeit sind historische Nachweise. Sie bleiben erhalten,
// werden aber von der nicht mehr existierenden Lerngruppe entkoppelt.
@@ -659,6 +662,7 @@ public class LiteDbContext : IDisposable
Groups.EnsureIndex(x => x.SchoolYear);
Groups.EnsureIndex(x => x.IsActive);
SeatingPlans.EnsureIndex(x => x.GroupId);
StudentGroupPlans.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex(x => x.StudentId);
Memberships.EnsureIndex(x => x.GroupId);
Memberships.EnsureIndex("ux_student_group",
@@ -34,7 +34,9 @@ public class StudentRepository(LiteDbContext db) : IStudentRepository
db.Grades.Count(g => g.StudentId == studentId),
db.ReportGrades.Count(g => g.StudentId == studentId),
db.ParticipationEntries.Count(e => e.StudentId == studentId),
db.Documentation.FindAll().Count(d => d.Concerns(studentId)));
db.Documentation.FindAll().Count(d => d.Concerns(studentId)),
db.StudentGroupPlans.FindAll().Count(plan =>
plan.Groups.Any(group => group.StudentIds.Contains(studentId))));
public void Save(Student s)
{
s.ExternalIds ??= [];
@@ -0,0 +1,55 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
namespace LehrerApp.Data.Repositories;
public class StudentGroupPlanRepository(LiteDbContext db) : IStudentGroupPlanRepository
{
public StudentGroupPlan? GetById(Guid id) => db.StudentGroupPlans.FindById(id);
public List<StudentGroupPlan> GetByGroup(Guid groupId) =>
db.StudentGroupPlans.Find(p => p.GroupId == groupId)
.OrderByDescending(p => p.UpdatedAt).ToList();
public List<StudentGroupPlan> GetByStudent(Guid studentId) =>
db.StudentGroupPlans.FindAll()
.Where(plan => plan.Groups.Any(group => group.StudentIds.Contains(studentId)))
.OrderByDescending(plan => plan.UpdatedAt).ToList();
public void Save(StudentGroupPlan plan)
{
if (db.Groups.FindById(plan.GroupId) is null)
throw new InvalidOperationException("Die zugehörige Lerngruppe existiert nicht.");
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
plan.Name = plan.Name?.Trim() ?? "";
if (plan.Name.Length == 0)
throw new ArgumentException("Bitte einen Namen für die Gruppeneinteilung eingeben.");
plan.Groups ??= [];
if (plan.Groups.Count == 0)
throw new InvalidOperationException("Die Gruppeneinteilung enthält keine Gruppen.");
var ids = plan.Groups.SelectMany(g => g.StudentIds ?? []).ToList();
if (ids.Count != ids.Distinct().Count())
throw new InvalidOperationException("Ein Schüler darf nur in einer Gruppe stehen.");
if (db.StudentGroupPlans.Find(p => p.GroupId == plan.GroupId)
.Any(p => p.Id != plan.Id && string.Equals(p.Name, plan.Name, StringComparison.OrdinalIgnoreCase)))
throw new InvalidOperationException("Eine gespeicherte Einteilung mit diesem Namen gibt es bereits.");
if (db.StudentGroupPlans.FindById(plan.Id) is { } existing)
{
if (existing.GroupId != plan.GroupId)
throw new InvalidOperationException("Eine Gruppeneinteilung kann nicht in eine andere Lerngruppe verschoben werden.");
plan.CreatedAt = existing.CreatedAt;
}
plan.UpdatedAt = DateTime.UtcNow;
db.StudentGroupPlans.Upsert(plan);
db.OnChange?.Invoke(nameof(StudentGroupPlan), plan.Id.ToString(), "Save", plan);
}
public void Delete(Guid id)
{
if (db.StudentGroupPlans.FindById(id) is not { } plan) return;
ArchivedGroupWriteGuard.EnsureActive(db, plan.GroupId);
db.StudentGroupPlans.Delete(id);
db.OnChange?.Invoke(nameof(StudentGroupPlan), id.ToString(), "Delete", null);
}
}
+16
View File
@@ -179,6 +179,22 @@ public class FakeSeatingPlans(List<SeatingPlan>? initial = null) : ISeatingPlanR
public void Restore(Guid trashId) { }
}
public class FakeStudentGroupPlans : IStudentGroupPlanRepository
{
private readonly List<StudentGroupPlan> _plans = [];
public StudentGroupPlan? GetById(Guid id) => _plans.FirstOrDefault(p => p.Id == id);
public List<StudentGroupPlan> GetByGroup(Guid groupId) =>
_plans.Where(p => p.GroupId == groupId).ToList();
public List<StudentGroupPlan> GetByStudent(Guid studentId) =>
_plans.Where(p => p.Groups.Any(g => g.StudentIds.Contains(studentId))).ToList();
public void Save(StudentGroupPlan plan)
{
_plans.RemoveAll(p => p.Id == plan.Id);
_plans.Add(plan);
}
public void Delete(Guid id) => _plans.RemoveAll(p => p.Id == id);
}
public class FakeTrash(List<TrashedItem>? initial = null) : ITrashRepository
{
private readonly List<TrashedItem> _all = initial ?? [];
@@ -31,8 +31,14 @@ public sealed class PersonalDataExportServiceTests
Assignments = [new SeatAssignment { Row = 1, Column = 2, StudentId = studentId }],
},
]);
var groupPlans = new FakeStudentGroupPlans();
groupPlans.Save(new StudentGroupPlan
{
GroupId = groupId, Name = "Projektwoche",
Groups = [new StudentGroupPlanGroup { StudentIds = [studentId] }],
});
var service = new PersonalDataExportService(students, groups, memberships, grades, results, entries,
documentation, seatingPlans);
documentation, seatingPlans, groupPlans);
var json = service.ExportAsJson(studentId);
@@ -41,6 +47,7 @@ public sealed class PersonalDataExportServiceTests
Assert.Contains("\"Value\": \"2\"", json);
Assert.Contains("\"Sitzplan\": \"Standard\"", json);
Assert.Contains("\"Reihe\": 2", json);
Assert.Contains("Projektwoche", json);
}
[Fact]
@@ -53,4 +60,23 @@ public sealed class PersonalDataExportServiceTests
Assert.Throws<InvalidOperationException>(() => service.ExportAsJson(Guid.NewGuid()));
}
[Fact]
public void ExportAsJson_EnthaeltGespeichertenPlanAuchNachEndeDerMitgliedschaft()
{
var student = new Student { FirstName = "Anna", LastName = "Beispiel" };
var groupId = Guid.NewGuid();
var plans = new FakeStudentGroupPlans();
plans.Save(new StudentGroupPlan
{
GroupId = groupId, Name = "Alte Projektgruppe",
Groups = [new StudentGroupPlanGroup { StudentIds = [student.Id] }],
});
var service = new PersonalDataExportService(
new FakeStudents([student]), new FakeGroups([]), new FakeMemberships([]),
new FakeGrades(), new FakeResults(), new FakeEntries(), new FakeDocumentation(),
new FakeSeatingPlans(), plans);
Assert.Contains("Alte Projektgruppe", service.ExportAsJson(student.Id));
}
}
@@ -7,7 +7,8 @@ namespace LehrerApp.Desktop.Tests;
public sealed class StudentGroupGeneratorViewModelTests
{
private static (StudentGroupGeneratorViewModel Vm, List<Student> Students, LearningGroup Group,
FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10)
FakeSessions Sessions, FakeEntries Entries) Build(int studentCount = 10,
FakeStudentGroupPlans? savedPlans = null)
{
var group = new LearningGroup { Name = "8a", GradingSystem = GradingSystem.Grades1To6 };
var students = Enumerable.Range(1, studentCount).Select(i => new Student
@@ -22,10 +23,77 @@ public sealed class StudentGroupGeneratorViewModelTests
var entries = new FakeEntries();
var vm = new StudentGroupGeneratorViewModel(
new FakeStudents(students), new FakeGroups([group]), new FakeMemberships(memberships),
sessions, entries, new FakeGrades(), group.Id, new Random(7));
sessions, entries, new FakeGrades(), group.Id, new Random(7), savedPlans);
return (vm, students, group, sessions, entries);
}
[Fact]
public void ManuelleUmverteilung_WirdGespeichertUndSpaeterWiederGeladen()
{
var plans = new FakeStudentGroupPlans();
var (vm, students, group, _, _) = Build(6, plans);
vm.TargetGroupCount = 2;
vm.CountPriority = 100;
vm.GenerateCommand.Execute(null);
var source = vm.GeneratedGroups[0];
var target = vm.GeneratedGroups[1];
var member = source.Members[0];
Assert.True(vm.MoveStudent(member, target));
Assert.False(vm.MoveStudent(member, target));
Assert.DoesNotContain(member, source.Members);
vm.PlanName = "Projektwoche";
vm.SavePlanCommand.Execute(null);
var saved = Assert.Single(plans.GetByGroup(group.Id));
Assert.Equal("Projektwoche", saved.Name);
Assert.Contains(member.Id, saved.Groups[1].StudentIds);
Assert.False(vm.HasUnsavedChanges);
vm.GenerateCommand.Execute(null);
vm.SelectedSavedPlan = Assert.Single(vm.SavedPlans);
vm.LoadSavedPlanCommand.Execute(null);
Assert.Contains(vm.GeneratedGroups[1].Members, m => m.Id == member.Id);
Assert.DoesNotContain(vm.GeneratedGroups[0].Members, m => m.Id == member.Id);
Assert.False(vm.HasUnsavedChanges);
Assert.Equal("Projektwoche", vm.PlanName);
var reopened = new StudentGroupGeneratorViewModel(
new FakeStudents(students), new FakeGroups([group]),
new FakeMemberships(students.Select(s => new GroupMembership
{
GroupId = group.Id, StudentId = s.Id,
}).ToList()),
new FakeSessions([]), new FakeEntries(), new FakeGrades(), group.Id,
savedPlans: plans);
reopened.SelectedSavedPlan = Assert.Single(reopened.SavedPlans);
reopened.LoadSavedPlanCommand.Execute(null);
Assert.Contains(reopened.GeneratedGroups[1].Members, m => m.Id == member.Id);
reopened.PlanName = "Projektwoche aktualisiert";
reopened.SavePlanCommand.Execute(null);
Assert.Equal("Projektwoche aktualisiert", Assert.Single(plans.GetByGroup(group.Id)).Name);
}
[Fact]
public void SpeichernUeberschreibtAusgewaehltenPlanErstNachDemLaden()
{
var plans = new FakeStudentGroupPlans();
var (vm, _, group, _, _) = Build(6, plans);
vm.GenerateCommand.Execute(null);
vm.PlanName = "Alter Plan";
vm.SavePlanCommand.Execute(null);
vm.GenerateCommand.Execute(null);
vm.SelectedSavedPlan = Assert.Single(vm.SavedPlans);
vm.PlanName = "Neuer Plan";
vm.SavePlanCommand.Execute(null);
Assert.Equal(2, plans.GetByGroup(group.Id).Count);
Assert.Contains(plans.GetByGroup(group.Id), p => p.Name == "Alter Plan");
Assert.Contains(plans.GetByGroup(group.Id), p => p.Name == "Neuer Plan");
}
[Fact]
public void FesteZielgroesse_ErzeugtPassendeAnzahlMitKleinerAusnahme()
{
+1
View File
@@ -156,6 +156,7 @@ public static class AppBootstrapper
services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<ISeatingPlanRepository, SeatingPlanRepository>();
services.AddSingleton<IStudentGroupPlanRepository, StudentGroupPlanRepository>();
services.AddSingleton<ITrashRepository, TrashRepository>();
services.AddSingleton<IGroupMembershipRepository, GroupMembershipRepository>();
services.AddSingleton<IExamRepository, ExamRepository>();
@@ -0,0 +1 @@
{"v":"5.7.8","fr":25,"ip":0,"op":25,"w":1080,"h":1080,"nm":"[ pecah Asset - Games Roda Keberuntungan ] EMOS - Juni","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"link-tengah Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[538.46,407.962,0],"ix":2,"l":2},"a":{"a":0,"k":[65.18,65.182,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.586,31.425],[-31.425,-1.586],[1.586,-31.426],[31.425,1.588]],"o":[[1.588,-31.423],[31.426,1.588],[-1.589,31.425],[-31.425,-1.586]],"v":[[-56.9,-2.875],[2.873,-56.901],[56.9,2.873],[-2.874,56.899]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-1.668,33.037],[33.036,1.668],[1.668,-33.037],[-33.036,-1.671]],"o":[[1.67,-33.037],[-33.037,-1.668],[-1.67,33.036],[33.037,1.668]],"v":[[59.817,3.02],[3.021,-59.818],[-59.817,-3.022],[-3.022,59.818]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2196078431372549,0.28627450980392155,0.8196078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[65.181,65.182],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":6,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-28.146,-8.66],[8.66,-28.146],[28.147,8.66],[-7.333,25.672]],"o":[[28.146,8.661],[-8.66,28.144],[-28.146,-8.661],[8.09,-28.315]],"v":[[14.668,-49.317],[50.116,15.469],[-14.669,49.694],[-50.116,-13.869]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[-30.651,-9.431],[-9.431,30.652],[30.651,9.432],[9.431,-30.652]],"o":[[30.652,9.432],[9.432,-30.652],[-30.652,-9.431],[-9.431,30.651]],"v":[[-17.078,55.5],[55.499,17.076],[17.077,-55.501],[-55.5,-17.077]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[65.181,65.182],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 2","np":6,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-1.55,30.701],[30.701,1.55],[1.55,-30.701],[-30.702,-1.552]],"o":[[1.552,-30.701],[-30.701,-1.55],[-1.552,30.701],[30.701,1.55]],"v":[[55.589,2.806],[2.808,-55.59],[-55.588,-2.809],[-2.808,55.59]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.3686274509803922,0.4470588235294118,0.8941176470588236,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[65.18,65.182],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 3","np":4,"cix":2,"bm":0,"ix":3,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":25,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Arrow Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[-17]},{"t":7,"s":[10]}],"ix":10,"x":"var $bm_rt;\n$bm_rt = loopOut('cycle', 0);"},"p":{"a":0,"k":[882.284,387.765,0],"ix":2,"l":2},"a":{"a":0,"k":[90.2,66.694,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[1.184,-20.641],[-20.642,-1.186],[-1.183,20.641],[20.643,1.183]],"o":[[-1.185,20.643],[20.643,1.184],[1.186,-20.644],[-20.643,-1.185]],"v":[[-37.376,-2.145],[-2.144,37.377],[37.375,2.146],[2.145,-37.375]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2196078431372549,0.28627450980392155,0.8196078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[112.538,63.94],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k"Line truncated
@@ -0,0 +1 @@
{"v":"5.6.6","fr":60,"ip":60,"op":120,"w":600,"h":600,"nm":"development and submittals","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"person-top-face-outlinne Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.415,-16.001,0],"ix":2},"a":{"a":0,"k":[33.313,42.52,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[4.515,0],[2.745,1.593],[0,10.18],[-5.843,10.003],[-8.233,4.781],[-6.463,-3.719],[0,-10.181],[5.754,-10.093],[8.234,-4.692]],"o":[[-3.276,0],[-6.551,-3.718],[0,-9.561],[5.754,-10.004],[8.853,-5.046],[6.551,3.718],[0,9.561],[-5.843,10.092],[-5.223,2.921]],"v":[[-13.102,41.52],[-22.221,39.13],[-32.313,17.529],[-23.283,-12.836],[-1.505,-35.765],[22.22,-37.801],[32.313,-16.2],[23.372,14.165],[1.593,37.094]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[2.214,0],[4.161,-2.301],[5.312,-9.118],[0,-8.41],[-4.516,-2.656],[-6.816,3.895],[-0.089,0],[-5.222,9.119],[0,8.498],[4.603,2.656]],"o":[[-3.452,0],[-7.348,4.25],[-5.312,9.118],[0,7.879],[4.514,2.656],[0.089,0],[7.17,-4.16],[5.224,-9.118],[0,-7.88],[-1.682,-0.973]],"v":[[13.013,-33.995],[1.505,-30.454],[-18.06,-9.738],[-26.205,17.529],[-19.21,33.818],[-1.594,31.782],[-1.416,31.693],[17.882,11.066],[26.027,-16.2],[18.945,-32.49]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0.4,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[33.313,42.52],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":5,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"person-top-face-face Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.324,-15.34,0],"ix":2},"a":{"a":0,"k":[29.464,39.069,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-8.056,4.692],[-5.223,9.119],[0,9.295],[5.312,3.099],[8.056,-4.692],[5.223,-9.207],[0,-9.384],[-5.312,-3.098]],"o":[[8.056,-4.692],[5.312,-9.118],[0,-9.296],[-5.312,-3.098],[-8.056,4.692],[-5.312,9.119],[0,9.296],[5.312,2.833]],"v":[[0,33.773],[20.627,11.995],[29.214,-16.775],[20.627,-35.722],[0,-33.684],[-20.627,-11.818],[-29.214,17.041],[-20.627,35.986]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[29.464,39.07],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"person-top-body-front-outline Outlines","parent":8,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[73.461,58.935,0],"ix":2},"a":{"a":0,"k":[53.718,49.646,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.531,0],[0.443,0.266],[0,1.151],[-8.764,15.05],[-13.633,7.967],[-10.358,-5.932],[-0.886,-14.431],[0.974,-0.531],[0,0]],"o":[[-0.62,0],[-0.973,-0.531],[0.886,-14.784],[9.649,-16.732],[14.253,-8.233],[9.471,5.488],[0.088,1.151],[0,0],[-0.62,0.354]],"v":[[-49.619,48.646],[-51.214,48.204],[-52.719,45.371],[-37.846,-0.929],[-1.638,-39.173],[36.607,-42.714],[52.63,-11.906],[51.125,-9.074],[-48.025,48.204]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ind":1,"ty":"sh","ix":2,"ks":{"a":0,"k":{"i":[[4.249,0],[7.437,-4.249],[9.119,-15.758],[1.682,-12.305],[0,0],[7.171,4.161]],"o":[[-6.286,0],[-12.837,7.436],[-7.082,12.306],[0,0],[-1.063,-11.332],[-3.364,-1.948]],"v":[[22.089,-40.147],[1.372,-33.773],[-32.622,2.169],[-46.079,39.971],[46.257,-13.324],[33.508,-37.314]],"c":true},"ix":2},"nm":"Path 2","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"mm","mm":1,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"st","c":{"a":0,"k":[0,0,0,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0.4,"ix":5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1",Line truncated
@@ -18,6 +18,7 @@
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Labs.Lottie" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="QuestPDF" />
@@ -7,7 +7,7 @@ using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
public sealed record StudentGroupMember(string Name);
public sealed record StudentGroupMember(Guid Id, string Name);
public sealed record StudentPairCandidate(Guid Id, string Name);
public sealed class StudentPairRule
@@ -29,8 +29,9 @@ public sealed class StudentPairRule
FirstId == first && SecondId == second || FirstId == second && SecondId == first;
}
public sealed class GeneratedStudentGroup
public sealed partial class GeneratedStudentGroup : ObservableObject
{
[ObservableProperty] private bool _isDropTarget;
public string Title { get; }
public ObservableCollection<StudentGroupMember> Members { get; } = [];
@@ -39,7 +40,13 @@ public sealed class GeneratedStudentGroup
Title = $"Gruppe {number}";
foreach (var student in students.OrderBy(s => s.LastName, StringComparer.CurrentCultureIgnoreCase)
.ThenBy(s => s.FirstName, StringComparer.CurrentCultureIgnoreCase))
Members.Add(new StudentGroupMember(student.FullName));
Members.Add(new StudentGroupMember(student.Id, student.FullName));
}
public GeneratedStudentGroup(int number, IEnumerable<StudentGroupMember> members)
{
Title = $"Gruppe {number}";
foreach (var member in members) Members.Add(member);
}
}
@@ -55,10 +62,12 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
private readonly IParticipationRepository _participation;
private readonly IGradeRepository _grades;
private readonly IGroupRepository _groupRepository;
private readonly IStudentGroupPlanRepository? _savedPlans;
private readonly Random _random;
private readonly List<Student> _participants = [];
private readonly Dictionary<Guid, double?> _performance = [];
private readonly HashSet<Guid> _supportStudentIds = [];
private Guid? _editingPlanId;
private string _attendanceWarning = "";
[ObservableProperty] private StudentDrawGroup? _selectedGroup;
@@ -77,14 +86,24 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
[ObservableProperty] private string _statusText = "Optionen wählen und Gruppen zusammenstellen.";
[ObservableProperty] private string _warningText = "";
[ObservableProperty] private bool _hasGeneratedGroups;
[ObservableProperty] private StudentGroupPlan? _selectedSavedPlan;
[ObservableProperty] private string _planName = "";
[ObservableProperty] private string _saveStatus = "";
[ObservableProperty] private bool _hasUnsavedChanges;
public ObservableCollection<StudentDrawGroup> Groups { get; } = [];
public ObservableCollection<GeneratedStudentGroup> GeneratedGroups { get; } = [];
public ObservableCollection<StudentPairCandidate> PairCandidates { get; } = [];
public ObservableCollection<StudentPairRule> ForcedPairs { get; } = [];
public ObservableCollection<StudentPairRule> ExcludedPairs { get; } = [];
public ObservableCollection<StudentGroupPlan> SavedPlans { get; } = [];
public string[] ParticipantModeOptions { get; } = [FutureModeLabel, AdHocModeLabel];
public bool CanGenerate => SelectedGroup is not null && _participants.Count >= 2;
public bool CanSave => _savedPlans is not null && SelectedGroup is not null &&
HasGeneratedGroups && !string.IsNullOrWhiteSpace(PlanName);
public bool CanLoadSavedPlan => SelectedSavedPlan is not null;
public bool CanDeleteSavedPlan => SelectedSavedPlan is not null;
public bool HasSavedPlans => SavedPlans.Count > 0;
public string SizePriorityLabel => PriorityLabel(SizePriority);
public string CountPriorityLabel => PriorityLabel(CountPriority);
public string PerformanceDistributionLabel => PerformanceDistribution switch
@@ -106,7 +125,8 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
IParticipationRepository participation,
IGradeRepository grades,
Guid? initialGroupId = null,
Random? random = null)
Random? random = null,
IStudentGroupPlanRepository? savedPlans = null)
{
_students = students;
_groupRepository = groups;
@@ -115,9 +135,13 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
_participation = participation;
_grades = grades;
_random = random ?? Random.Shared;
_savedPlans = savedPlans;
foreach (var group in groups.GetAll().OrderBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase))
Groups.Add(new StudentDrawGroup(group.Id, group.Name));
if (initialGroupId is { } requestedId && Groups.All(g => g.Id != requestedId) &&
groups.GetById(requestedId) is { } archived)
Groups.Add(new StudentDrawGroup(archived.Id, $"{archived.Name} (archiviert)"));
SelectedGroup = Groups.FirstOrDefault(g => g.Id == initialGroupId) ?? Groups.FirstOrDefault();
}
@@ -126,8 +150,24 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
ForcedPairs.Clear();
ExcludedPairs.Clear();
ReloadParticipants();
ReloadSavedPlans();
_editingPlanId = null;
PlanName = "";
}
partial void OnParticipantModeNameChanged(string value) => ReloadParticipants();
partial void OnSelectedSavedPlanChanged(StudentGroupPlan? value)
{
OnPropertyChanged(nameof(CanLoadSavedPlan));
OnPropertyChanged(nameof(CanDeleteSavedPlan));
LoadSavedPlanCommand.NotifyCanExecuteChanged();
DeleteSavedPlanCommand.NotifyCanExecuteChanged();
}
partial void OnPlanNameChanged(string value)
{
SavePlanCommand.NotifyCanExecuteChanged();
if (HasGeneratedGroups) HasUnsavedChanges = true;
}
partial void OnHasGeneratedGroupsChanged(bool value) => SavePlanCommand.NotifyCanExecuteChanged();
partial void OnTargetGroupSizeChanged(int value) => InvalidateResult();
partial void OnTargetGroupCountChanged(int value) => InvalidateResult();
partial void OnSizePriorityChanged(double value)
@@ -177,11 +217,118 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
GeneratedGroups.Add(new GeneratedStudentGroup(i + 1, assignments[i]));
HasGeneratedGroups = true;
_editingPlanId = null;
SelectedSavedPlan = null;
PlanName = $"Einteilung {DateTime.Now:dd.MM.yyyy HH:mm:ss}";
HasUnsavedChanges = true;
SaveStatus = "Noch nicht gespeichert.";
StatusText = $"{_participants.Count} Schüler · {groupCount} Gruppen · Größen " +
string.Join("/", capacities.Distinct().OrderBy(x => x));
UpdateWarning(groupCount, assignments);
}
[RelayCommand(CanExecute = nameof(CanSave))]
private void SavePlan()
{
if (_savedPlans is null || SelectedGroup is null) return;
var plan = new StudentGroupPlan
{
Id = _editingPlanId ?? Guid.NewGuid(),
GroupId = SelectedGroup.Id,
Name = PlanName,
Groups = GeneratedGroups.Select(group => new StudentGroupPlanGroup
{
StudentIds = group.Members.Select(member => member.Id).ToList(),
}).ToList(),
};
try
{
_savedPlans.Save(plan);
_editingPlanId = plan.Id;
ReloadSavedPlans(plan.Id);
HasUnsavedChanges = false;
SaveStatus = "Einteilung gespeichert.";
}
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
{
SaveStatus = ex.Message;
}
}
[RelayCommand(CanExecute = nameof(CanLoadSavedPlan))]
private void LoadSavedPlan()
{
if (_savedPlans is null || SelectedSavedPlan is not { } selected ||
SelectedGroup?.Id != selected.GroupId) return;
var plan = _savedPlans.GetById(selected.Id);
if (plan is null) { ReloadSavedPlans(); return; }
_editingPlanId = plan.Id;
var missing = 0;
GeneratedGroups.Clear();
foreach (var group in plan.Groups ?? [])
{
var members = new List<StudentGroupMember>();
foreach (var id in group.StudentIds ?? [])
{
if (_students.GetById(id) is { } student)
members.Add(new StudentGroupMember(id, student.FullName));
else missing++;
}
GeneratedGroups.Add(new GeneratedStudentGroup(GeneratedGroups.Count + 1, members));
}
HasGeneratedGroups = GeneratedGroups.Count > 0;
PlanName = plan.Name;
HasUnsavedChanges = missing > 0;
SaveStatus = missing > 0
? $"{missing} nicht mehr vorhandene Schüler wurden ausgelassen. Bitte erneut speichern."
: "Gespeicherte Einteilung geladen.";
StatusText = $"{GeneratedGroups.Sum(group => group.Members.Count)} Schüler · " +
$"{GeneratedGroups.Count} Gruppen · gespeichert am {plan.UpdatedAt.ToLocalTime():dd.MM.yyyy}";
WarningText = missing > 0 ? SaveStatus : "";
}
[RelayCommand(CanExecute = nameof(CanDeleteSavedPlan))]
private void DeleteSavedPlan()
{
if (_savedPlans is null || SelectedSavedPlan is not { } plan) return;
_savedPlans.Delete(plan.Id);
var deletedCurrent = _editingPlanId == plan.Id;
if (deletedCurrent) _editingPlanId = null;
ReloadSavedPlans();
if (deletedCurrent) HasUnsavedChanges = HasGeneratedGroups;
SaveStatus = "Gespeicherte Einteilung gelöscht.";
}
public bool MoveStudent(StudentGroupMember member, GeneratedStudentGroup target)
{
if (!HasGeneratedGroups || !GeneratedGroups.Contains(target)) return false;
var source = GeneratedGroups.FirstOrDefault(group => group.Members.Contains(member));
if (source is null || source == target || target.Members.Any(m => m.Id == member.Id)) return false;
source.Members.Remove(member);
target.Members.Add(member);
HasUnsavedChanges = true;
SaveStatus = "Änderungen noch nicht gespeichert.";
StatusText = $"{GeneratedGroups.Sum(group => group.Members.Count)} Schüler · " +
$"{GeneratedGroups.Count} Gruppen · manuell angepasst";
var assignments = GeneratedGroups.Select(group => group.Members
.Select(m => _students.GetById(m.Id)).OfType<Student>().ToList()).ToList();
UpdateWarning(GeneratedGroups.Count, assignments);
return true;
}
private void ReloadSavedPlans(Guid? selectedId = null)
{
SavedPlans.Clear();
SelectedSavedPlan = null;
if (_savedPlans is not null && SelectedGroup is not null)
{
foreach (var plan in _savedPlans.GetByGroup(SelectedGroup.Id)) SavedPlans.Add(plan);
SelectedSavedPlan = SavedPlans.FirstOrDefault(plan => plan.Id == selectedId);
}
OnPropertyChanged(nameof(HasSavedPlans));
}
private void ReloadParticipants()
{
_participants.Clear();
@@ -385,6 +532,8 @@ public partial class StudentGroupGeneratorViewModel : ObservableObject
{
GeneratedGroups.Clear();
HasGeneratedGroups = false;
HasUnsavedChanges = false;
SaveStatus = "";
StatusText = _participants.Count >= 2
? $"{_participants.Count} Schüler stehen zur Einteilung bereit."
: "Für eine Gruppeneinteilung werden mindestens zwei Schüler benötigt.";
@@ -22,6 +22,7 @@ public class TeachingModeViewModel
{
public TeachingTimelineViewModel Timeline { get; }
public string GroupName { get; }
public DateOnly LessonDate { get; }
public LessonViewerViewModel LessonInfo { get; }
public SeatingPlanTabViewModel SeatingPlan { get; }
/// <summary>Nutzer-Feedback: die Schnellbewertungs-Dialoge für Mitarbeit/Anwesenheit-Hausaufgabe
@@ -41,6 +42,7 @@ public class TeachingModeViewModel
TeachingModeOutstandingHomeworkViewModel? outstandingHomework = null)
{
GroupName = group.Name;
LessonDate = lesson.Date;
Timeline = new TeachingTimelineViewModel(lesson, lessons, !group.IsActive);
LessonInfo = new LessonViewerViewModel(lesson, alternativePaths);
@@ -1,10 +1,11 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:lottie="clr-namespace:Avalonia.Labs.Lottie;assembly=Avalonia.Labs.Lottie"
x:Class="LehrerApp.Desktop.Views.Groups.RandomStudentPickerWindow"
x:DataType="vm:RandomStudentPickerViewModel"
Title="Schüler ziehen"
Width="430" MinHeight="390"
Width="430" MinHeight="430"
SizeToContent="Height"
CanResize="False"
WindowStartupLocation="CenterScreen">
@@ -16,12 +17,13 @@
<TextBlock Text="Eigenständiges Fenster für die Bildschirmfreigabe"
FontSize="12" Opacity="0.55"/>
</StackPanel>
<Button Grid.Column="1" Content="↺" Command="{Binding ResetCommand}"
<Button Grid.Column="1" x:Name="ResetButton" Content="↺" Command="{Binding ResetCommand}"
ToolTip.Tip="Ziehung zurücksetzen" FontSize="18"
Width="38" Height="38"/>
</Grid>
<Border Grid.Row="1" x:Name="RevealCard"
<Grid Grid.Row="1" MinHeight="230">
<Border x:Name="RevealCard"
MinHeight="190" Padding="24"
Background="{DynamicResource AppAccentSoftBackgroundBrush}"
BorderBrush="{DynamicResource AppFilterActiveBorderBrush}"
@@ -42,13 +44,19 @@
TextAlignment="Center" TextWrapping="Wrap" FontSize="12" Opacity="0.7"/>
</StackPanel>
</Border>
<lottie:Lottie x:Name="WheelAnimation"
Path="/Assets/Animations/LotterySpinWheel.json"
RepeatCount="-1" AutoPlay="False"
Width="220" Height="220" HorizontalAlignment="Center"
VerticalAlignment="Center" IsVisible="False"/>
</Grid>
<Button Grid.Row="2" Content="🎲 Ziehen" x:Name="DrawButton"
IsEnabled="{Binding CanDraw}" Click="OnDraw"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
FontSize="18" FontWeight="SemiBold" Height="54" Margin="0,16,0,12"/>
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
<Expander Grid.Row="3" x:Name="OptionsExpander" Header="Optionen" IsExpanded="False">
<StackPanel Spacing="10" Margin="0,10,0,0">
<TextBlock Text="Lerngruppe" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
@@ -1,26 +1,60 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Labs.Lottie;
using LehrerApp.Desktop.ViewModels.Groups;
namespace LehrerApp.Desktop.Views.Groups;
public partial class RandomStudentPickerWindow : Window
{
private bool _isAnimating;
private bool _isSpinning;
private DateTime _spinStartedAt;
public RandomStudentPickerWindow() => InitializeComponent();
private async void OnDraw(object? sender, RoutedEventArgs e)
{
if (_isAnimating || DataContext is not RandomStudentPickerViewModel vm ||
if (DataContext is not RandomStudentPickerViewModel vm ||
!vm.DrawCommand.CanExecute(null)) return;
_isAnimating = true;
var card = this.FindControl<Border>("RevealCard");
if (card is not null) card.Opacity = 0.15;
await Task.Delay(150);
var wheel = this.FindControl<Lottie>("WheelAnimation");
var button = this.FindControl<Button>("DrawButton");
if (card is null || wheel is null || button is null) return;
if (!_isSpinning)
{
_isSpinning = true;
_spinStartedAt = DateTime.UtcNow;
card.IsVisible = false;
wheel.IsVisible = true;
this.FindControl<Expander>("OptionsExpander")!.IsEnabled = false;
this.FindControl<Button>("ResetButton")!.IsEnabled = false;
button.Content = "Dreht …";
// Erst nach dem Layoutwechsel starten, damit das erste Bild sichtbar ist.
await Task.Yield();
if (!_isSpinning) return;
wheel.Start();
button.Content = "Ergebnis anzeigen";
return;
}
// Ein Doppelklick soll die Ziehung nicht unbemerkt sofort auflösen.
if (DateTime.UtcNow - _spinStartedAt < TimeSpan.FromSeconds(1.2)) return;
wheel.Stop();
wheel.IsVisible = false;
vm.DrawCommand.Execute(null);
if (card is not null) card.Opacity = 1;
_isAnimating = false;
card.IsVisible = true;
this.FindControl<Expander>("OptionsExpander")!.IsEnabled = true;
this.FindControl<Button>("ResetButton")!.IsEnabled = true;
button.Content = "🎲 Ziehen";
_isSpinning = false;
}
protected override void OnClosed(EventArgs e)
{
_isSpinning = false;
this.FindControl<Lottie>("WheelAnimation")?.Stop();
base.OnClosed(e);
}
}
@@ -1,12 +1,21 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:models="clr-namespace:LehrerApp.Core.Models;assembly=LehrerApp.Core"
xmlns:lottie="clr-namespace:Avalonia.Labs.Lottie;assembly=Avalonia.Labs.Lottie"
x:Class="LehrerApp.Desktop.Views.Groups.StudentGroupGeneratorWindow"
x:DataType="vm:StudentGroupGeneratorViewModel"
Title="Gruppen zusammenstellen"
Width="780" Height="650" MinWidth="620" MinHeight="480"
WindowStartupLocation="CenterScreen">
<Window.Styles>
<Style Selector="Border.studentgroup.droptarget">
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentTextBrush}"/>
<Setter Property="BorderThickness" Value="2"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,16">
<TextBlock Text="Gruppen zusammenstellen" Classes="dialogtitle"/>
@@ -14,7 +23,7 @@
FontSize="12" Opacity="0.55"/>
</StackPanel>
<ScrollViewer Grid.Row="1">
<ScrollViewer Grid.Row="1" x:Name="ResultsScrollViewer">
<Grid x:Name="GroupResults">
<Grid.Transitions>
<Transitions>
@@ -40,17 +49,26 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GeneratedStudentGroup">
<Border Width="220" Margin="7" Padding="16"
<Border Width="220" Margin="7" Padding="16" Classes="studentgroup"
Classes.droptarget="{Binding IsDropTarget}"
Background="{DynamicResource AppCardBackgroundBrush}"
BorderBrush="{DynamicResource AppCardBorderBrush}"
BorderThickness="1" CornerRadius="12">
BorderThickness="1" CornerRadius="12"
DragDrop.AllowDrop="True" DragDrop.DragEnter="OnGroupDragEnter"
DragDrop.DragLeave="OnGroupDragLeave" DragDrop.DragOver="OnGroupDragOver"
DragDrop.Drop="OnGroupDrop">
<StackPanel Spacing="9">
<TextBlock Text="{Binding Title}" FontSize="16" FontWeight="SemiBold"
Foreground="{DynamicResource AppAccentTextBrush}"/>
<ItemsControl ItemsSource="{Binding Members}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:StudentGroupMember">
<TextBlock Text="{Binding Name}" Margin="0,3" TextWrapping="Wrap"/>
<Border Background="{DynamicResource AppChipBackgroundBrush}"
CornerRadius="6" Padding="8,5" Margin="0,3"
PointerPressed="OnMemberPressed" PointerMoved="OnMemberMoved"
PointerReleased="OnMemberReleased">
<TextBlock Text="{Binding Name}" TextWrapping="Wrap"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -61,11 +79,41 @@
</ItemsControl>
</Grid>
</ScrollViewer>
<lottie:Lottie Grid.Row="1" x:Name="TeamworkAnimation"
Path="/Assets/Animations/Teamwork.json"
RepeatCount="-1" AutoPlay="False"
Width="270" Height="270" HorizontalAlignment="Center"
VerticalAlignment="Center" IsVisible="False"/>
<StackPanel Grid.Row="2" Spacing="8" Margin="0,12,0,10">
<Button Content="Gruppen zusammenstellen" Click="OnGenerate"
<Button x:Name="GenerateButton" Content="Gruppen zusammenstellen" Click="OnGenerate"
IsEnabled="{Binding CanGenerate}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center" Height="48" FontSize="16" FontWeight="SemiBold"/>
<StackPanel x:Name="PlanActions" Spacing="8">
<TextBlock Text="Schüler zwischen den Gruppenkarten ziehen, um die Einteilung anzupassen."
FontSize="11" Opacity="0.65" IsVisible="{Binding HasGeneratedGroups}"/>
<Grid ColumnDefinitions="*,10,Auto,8,Auto" IsVisible="{Binding HasSavedPlans}">
<ComboBox ItemsSource="{Binding SavedPlans}" SelectedItem="{Binding SelectedSavedPlan}"
PlaceholderText="Gespeicherte Einteilung auswählen">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="models:StudentGroupPlan">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Button Grid.Column="2" Content="Laden" Click="OnLoadSavedPlan"
IsEnabled="{Binding CanLoadSavedPlan}"/>
<Button Grid.Column="4" Content="Löschen" Click="OnDeleteSavedPlan"
IsEnabled="{Binding CanDeleteSavedPlan}"/>
</Grid>
<Grid ColumnDefinitions="*,10,Auto" IsVisible="{Binding HasGeneratedGroups}">
<TextBox Text="{Binding PlanName, Mode=TwoWay}" PlaceholderText="Name der Einteilung"/>
<Button Grid.Column="2" Content="Speichern" Command="{Binding SavePlanCommand}"/>
</Grid>
<TextBlock Text="{Binding SaveStatus}" FontSize="11" Opacity="0.7" TextWrapping="Wrap"
IsVisible="{Binding SaveStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel x:Name="ResultSummary" Spacing="8">
<TextBlock Text="{Binding StatusText}" HorizontalAlignment="Center" Opacity="0.65"
IsVisible="{Binding HasGeneratedGroups}"/>
<Border Background="{DynamicResource AppChipBackgroundBrush}" CornerRadius="8" Padding="10,7"
@@ -74,8 +122,9 @@
Foreground="{DynamicResource AppStatusWarningBrush}"/>
</Border>
</StackPanel>
</StackPanel>
<Expander Grid.Row="3" Header="Optionen" IsExpanded="False">
<Expander Grid.Row="3" x:Name="OptionsExpander" Header="Optionen" IsExpanded="False">
<ScrollViewer MaxHeight="340">
<StackPanel Spacing="11" Margin="0,10,6,4">
<Grid ColumnDefinitions="*,16,*">
@@ -1,25 +1,204 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Labs.Lottie;
using Avalonia.Threading;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class StudentGroupGeneratorWindow : Window
{
private bool _isAnimating;
private bool _isSpinning;
private DateTime _spinStartedAt;
private StudentGroupMember? _dragCandidate;
private PointerPressedEventArgs? _dragTrigger;
private Avalonia.Point _pressPosition;
private StudentGroupMember? _draggedMember;
private GeneratedStudentGroup? _pendingDropTarget;
public StudentGroupGeneratorWindow() => InitializeComponent();
public static StudentGroupGeneratorWindow Create(Guid? initialGroupId, bool useTodayAttendance = false)
{
var vm = new StudentGroupGeneratorViewModel(
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<IGradeRepository>(),
initialGroupId,
savedPlans: App.Services.GetRequiredService<IStudentGroupPlanRepository>());
if (useTodayAttendance)
vm.ParticipantModeName = StudentGroupGeneratorViewModel.AdHocModeLabel;
return new StudentGroupGeneratorWindow { DataContext = vm };
}
private async void OnGenerate(object? sender, RoutedEventArgs e)
{
if (_isAnimating || DataContext is not StudentGroupGeneratorViewModel vm ||
if (DataContext is not StudentGroupGeneratorViewModel vm ||
!vm.GenerateCommand.CanExecute(null)) return;
_isAnimating = true;
var results = this.FindControl<Grid>("GroupResults");
if (results is not null) results.Opacity = 0.15;
await Task.Delay(140);
var results = this.FindControl<ScrollViewer>("ResultsScrollViewer");
var teamwork = this.FindControl<Lottie>("TeamworkAnimation");
var button = this.FindControl<Button>("GenerateButton");
var options = this.FindControl<Expander>("OptionsExpander");
var summary = this.FindControl<StackPanel>("ResultSummary");
var actions = this.FindControl<StackPanel>("PlanActions");
if (results is null || teamwork is null || button is null || options is null ||
summary is null || actions is null) return;
if (!_isSpinning)
{
_isSpinning = true;
_spinStartedAt = DateTime.UtcNow;
results.IsVisible = false;
actions.IsVisible = false;
summary.IsVisible = false;
teamwork.IsVisible = true;
options.IsEnabled = false;
button.Content = "Teamwork läuft …";
// Nach dem Layoutwechsel starten, damit der erste Frame sichtbar ist.
await Task.Yield();
if (!_isSpinning) return;
teamwork.Start();
button.Content = "Ergebnis anzeigen";
return;
}
// Ein Doppelklick soll die Animation nicht sofort wieder schließen.
if (DateTime.UtcNow - _spinStartedAt < TimeSpan.FromSeconds(1.2)) return;
try
{
vm.GenerateCommand.Execute(null);
if (results is not null) results.Opacity = 1;
_isAnimating = false;
}
finally
{
teamwork.Stop();
teamwork.IsVisible = false;
results.IsVisible = true;
actions.IsVisible = true;
summary.IsVisible = true;
options.IsEnabled = true;
button.Content = "Gruppen zusammenstellen";
_isSpinning = false;
}
}
private void OnMemberPressed(object? sender, PointerPressedEventArgs e)
{
if (_isSpinning || sender is not Control { DataContext: StudentGroupMember member } control ||
!e.GetCurrentPoint(control).Properties.IsLeftButtonPressed) return;
_dragCandidate = member;
_dragTrigger = e;
_pressPosition = e.GetPosition(this);
}
private async void OnLoadSavedPlan(object? sender, RoutedEventArgs e)
{
if (DataContext is not StudentGroupGeneratorViewModel vm ||
!vm.LoadSavedPlanCommand.CanExecute(null)) return;
if (vm.HasUnsavedChanges)
{
var dialog = new ConfirmDialog
{
DataContext = new ConfirmDialogInfo
{
Title = "Ungespeicherte Einteilung ersetzen?",
Message = "Die aktuelle Einteilung und manuelle Änderungen werden beim Laden verworfen.",
ConfirmText = "Gespeicherte Einteilung laden",
},
};
if (!await dialog.ShowDialog<bool>(this)) return;
}
vm.LoadSavedPlanCommand.Execute(null);
}
private async void OnDeleteSavedPlan(object? sender, RoutedEventArgs e)
{
if (DataContext is not StudentGroupGeneratorViewModel vm ||
!vm.DeleteSavedPlanCommand.CanExecute(null)) return;
var dialog = new ConfirmDialog
{
DataContext = new ConfirmDialogInfo
{
Title = "Einteilung löschen?",
Message = $"Die gespeicherte Einteilung „{vm.SelectedSavedPlan!.Name}“ wird gelöscht.",
ConfirmText = "Löschen",
},
};
if (await dialog.ShowDialog<bool>(this)) vm.DeleteSavedPlanCommand.Execute(null);
}
private async void OnMemberMoved(object? sender, PointerEventArgs e)
{
if (_dragCandidate is null || _dragTrigger is null ||
!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
var position = e.GetPosition(this);
if (Math.Abs(position.X - _pressPosition.X) < 6 &&
Math.Abs(position.Y - _pressPosition.Y) < 6) return;
_draggedMember = _dragCandidate;
_pendingDropTarget = null;
_dragCandidate = null;
var trigger = _dragTrigger;
_dragTrigger = null;
var data = new DataTransfer();
data.Add(DataTransferItem.CreateText("LehrerApp-Gruppeneinteilung"));
var effect = await DragDrop.DoDragDropAsync(trigger, data, DragDropEffects.Move);
var member = _draggedMember;
var target = _pendingDropTarget;
_draggedMember = null;
_pendingDropTarget = null;
if ((effect & DragDropEffects.Move) == 0 || member is null || target is null) return;
// Die Quelle erst verändern, wenn Avalonia den Drop vollständig verarbeitet hat.
Dispatcher.UIThread.Post(() =>
{
if (DataContext is StudentGroupGeneratorViewModel vm) vm.MoveStudent(member, target);
}, DispatcherPriority.Background);
}
private void OnMemberReleased(object? sender, PointerReleasedEventArgs e)
{
_dragCandidate = null;
_dragTrigger = null;
}
private bool CanDropOn(GeneratedStudentGroup target) =>
!_isSpinning && _draggedMember is { } member &&
!target.Members.Any(existing => existing.Id == member.Id);
private void OnGroupDragEnter(object? sender, DragEventArgs e)
{
if (sender is Border { DataContext: GeneratedStudentGroup target } && CanDropOn(target))
target.IsDropTarget = true;
}
private void OnGroupDragLeave(object? sender, DragEventArgs e)
{
if (sender is Border { DataContext: GeneratedStudentGroup target }) target.IsDropTarget = false;
}
private void OnGroupDragOver(object? sender, DragEventArgs e) =>
e.DragEffects = sender is Border { DataContext: GeneratedStudentGroup target } && CanDropOn(target)
? DragDropEffects.Move : DragDropEffects.None;
private void OnGroupDrop(object? sender, DragEventArgs e)
{
if (sender is not Border { DataContext: GeneratedStudentGroup target }) return;
target.IsDropTarget = false;
if (!CanDropOn(target)) { e.DragEffects = DragDropEffects.None; return; }
_pendingDropTarget = target;
e.DragEffects = DragDropEffects.Move;
}
protected override void OnClosed(EventArgs e)
{
_isSpinning = false;
this.FindControl<Lottie>("TeamworkAnimation")?.Stop();
base.OnClosed(e);
}
}
@@ -52,6 +52,8 @@
<Button Content="Hausaufgaben kontrollieren" Command="{Binding SeatingPlan.CheckHomeworkCommand}" IsEnabled="{Binding SeatingPlan.IsEditable}"/>
<Button Content="Kontrolle beenden" Command="{Binding SeatingPlan.EndQuickCheckCommand}" IsVisible="{Binding SeatingPlan.IsQuickMode}"/>
<Button Content="Listenansicht …" Command="{Binding Participation.StatusQuickInputCommand}" ToolTip.Tip="Auch Schüler ohne Sitzplatz erfassen."/>
<Button Content="Gruppen zusammenstellen …" Click="OnOpenStudentGroups"
ToolTip.Tip="Gruppen für diese Lerngruppe ziehen, bearbeiten und später wieder laden."/>
<Button Content="Vollbild ↔" Click="OnFullScreen" ToolTip.Tip="Vollbild umschalten (F11); mit Escape verlassen."/>
<Button Content="Zur Mitarbeit" Command="{Binding LessonInfo.NavigateToParticipationCommand}"
ToolTip.Tip="Schließt den Unterrichtsmodus und springt zum Tab 'Mitarbeit' der Lerngruppe."/>
@@ -15,6 +15,7 @@ public partial class TeachingModeWindow : Window
private static readonly Dictionary<Guid, TeachingModeWindow> OpenWindows = [];
private readonly DispatcherTimer _clock = new() { Interval = TimeSpan.FromSeconds(1) };
private WindowState _previousState = WindowState.Maximized;
private StudentGroupGeneratorWindow? _studentGroupGeneratorWindow;
public static void Open(Lesson lesson)
{
@@ -66,6 +67,20 @@ public partial class TeachingModeWindow : Window
}
private void OnFullScreen(object? sender, RoutedEventArgs e) => ToggleFullScreen();
private void OnOpenStudentGroups(object? sender, RoutedEventArgs e)
{
if (_studentGroupGeneratorWindow is not null)
{
_studentGroupGeneratorWindow.Activate();
return;
}
if (DataContext is not TeachingModeViewModel vm) return;
_studentGroupGeneratorWindow = StudentGroupGeneratorWindow.Create(vm.LessonInfo.GroupId,
useTodayAttendance: vm.LessonDate == DateOnly.FromDateTime(DateTime.Today));
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
_studentGroupGeneratorWindow.Show(this);
}
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
+1 -9
View File
@@ -103,15 +103,7 @@ public partial class MainWindow : Window
}
var currentGroupId = (DataContext as MainWindowViewModel)?.CurrentGroupDetail?.Group?.Id;
var vm = new StudentGroupGeneratorViewModel(
App.Services.GetRequiredService<IStudentRepository>(),
App.Services.GetRequiredService<IGroupRepository>(),
App.Services.GetRequiredService<IGroupMembershipRepository>(),
App.Services.GetRequiredService<IParticipationSessionRepository>(),
App.Services.GetRequiredService<IParticipationRepository>(),
App.Services.GetRequiredService<IGradeRepository>(),
currentGroupId);
_studentGroupGeneratorWindow = new StudentGroupGeneratorWindow { DataContext = vm };
_studentGroupGeneratorWindow = StudentGroupGeneratorWindow.Create(currentGroupId);
_studentGroupGeneratorWindow.Closed += (_, _) => _studentGroupGeneratorWindow = null;
_studentGroupGeneratorWindow.Show();
}
+19
View File
@@ -11,6 +11,25 @@ public sealed class EventApplierTests
private static LiteDbContext NewInMemoryContext() => new(new MemoryStream());
private static readonly byte[] Key = SyncCrypto.GenerateKey();
[Fact]
public async Task ApplyAsync_StudentGroupPlan_SynchronisiertEinteilungUndLoeschung()
{
using var db = NewInMemoryContext();
var applier = new EventApplier(db, Key);
var studentId = Guid.NewGuid();
var plan = new StudentGroupPlan
{
GroupId = Guid.NewGuid(), Name = "Projektwoche",
Groups = [new StudentGroupPlanGroup { StudentIds = [studentId] }],
};
await applier.ApplyAsync(MakeEvent(nameof(StudentGroupPlan), plan.Id.ToString(), "Save", plan));
Assert.Equal(studentId, db.StudentGroupPlans.FindById(plan.Id)!.Groups[0].StudentIds[0]);
await applier.ApplyAsync(MakeEvent(nameof(StudentGroupPlan), plan.Id.ToString(), "Delete", null));
Assert.Null(db.StudentGroupPlans.FindById(plan.Id));
}
[Fact]
public async Task ApplyAsync_Save_SchreibtEntitaetDirektInDieCollection()
{
+1
View File
@@ -125,6 +125,7 @@ public class EventApplier(LiteDbContext db, byte[] syncKey, HttpClient? http = n
Simple<Student>(context => context.Students);
Simple<SeatingPlan>(context => context.SeatingPlans);
Simple<StudentGroupPlan>(context => context.StudentGroupPlans);
Simple<GroupMembership>(context => context.Memberships);
Simple<GradingKeyTemplate>(context => context.GradingKeyTemplates);
Simple<Grade>(context => context.Grades);