4 Commits
Author SHA1 Message Date
adminandClaude Sonnet 5 762c8236fe fix: Sync-Statusanzeige zeigt nach Neustart fälschlich "nicht konfiguriert"
SyncEngine feuert sein erstes StatusChanged bereits im eigenen
Konstruktor. Da SyncEngine und SyncStatusViewModel beide DI-Singletons
sind und Letzterer Ersteren erst innerhalb der eigenen Factory aus dem
Container holt, lief dieser erste Broadcast ab, bevor
SyncStatusViewModel überhaupt abonniert hatte - der Status ging
verloren, StatusText blieb bis zum nächsten Auto-Sync oder manuellen
Sync beim hartcodierten Default "Kein Server konfiguriert".

Fix: Konstruktor ruft nach dem Abonnieren zusätzlich einmal
OnStatus(engine.Status) mit dem bereits vorhandenen aktuellen Zustand
auf. Regressionstest ergänzt - dabei fehlte LehrerApp.Desktop.Tests
das DisableTestParallelization-Attribut (gleicher bekannter
LiteDB-BsonMapper.Global-Bug wie in den anderen Testprojekten, sobald
zwei Testklassen parallel LiteDbContext konstruieren).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:14:22 +02:00
adminandClaude Sonnet 5 f8058b537b feat: Geräte-Pairing-UI + Einstellungen-Neustrukturierung
- Sync-Settings-Tab: neuer Bereich "Gerät koppeln" (10.3.1), nutzt den
  bereits vorhandenen, bisher nirgends verdrahteten SnapshotService.
  Ein Gerät erzeugt per CreatePairingCode einen verschlüsselten
  DB-Snapshot + Einmal-Code, das zweite Gerät übernimmt per
  RedeemPairingCode Datenbank und Sync-Schlüssel. Bestätigungsdialog
  vor dem Einlösen, da die lokale Datenbank dabei vollständig ersetzt
  wird (alter Stand wird automatisch als Backup gesichert).
- Einstellungen: TabPlacement von Top auf Left umgestellt (vertikale
  Liste statt langem horizontalem Balken) und die 13 Tabs in drei
  logische Gruppen sortiert (Fachliches / Zeitplanung / System).
- Neuer SettingsTab-Enum ersetzt rohe int-Tab-Indizes bei
  MainWindowViewModel.NavigateToSettings/
  TimetableViewModel.OnNavigateToSettings. Dabei einen bestehenden Bug
  gefunden und mitbehoben: das Zahnrad im Stundenplan öffnete über den
  hartcodierten Index 7 tatsächlich "Datenschutz" statt des laut
  Kommentar/Tooltip beabsichtigten "Ferien & Feiertage".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:09:25 +02:00
admin 65c2211dab feat: Importkonflikte gesammelt übernehmen 2026-08-18 00:52:05 +02:00
admin 99efa3a0fe feat: Teilnehmerlisten in Lerngruppen importieren 2026-08-18 00:44:07 +02:00
26 changed files with 1059 additions and 316 deletions
@@ -0,0 +1,77 @@
using System.Text;
namespace LehrerApp.Core.Importing;
internal sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
internal static class DelimitedTextParser
{
public static bool TryParse(
string text,
char delimiter,
out List<DelimitedRecord> records,
out string? error)
{
records = [];
error = null;
var fields = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var line = 1;
var recordStartLine = 1;
for (var index = 0; index < text.Length; index++)
{
var current = text[index];
if (current == '"')
{
if (inQuotes && index + 1 < text.Length && text[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (current == delimiter && !inQuotes)
{
fields.Add(field.ToString());
field.Clear();
continue;
}
if ((current == '\r' || current == '\n') && !inQuotes)
{
if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n')
index++;
fields.Add(field.ToString());
field.Clear();
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
fields.Clear();
line++;
recordStartLine = line;
continue;
}
if (current == '\n') line++;
field.Append(current);
}
if (inQuotes)
{
error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen.";
return false;
}
if (field.Length > 0 || fields.Count > 0)
{
fields.Add(field.ToString());
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
}
return true;
}
}
@@ -0,0 +1,121 @@
using System.Text;
using LehrerApp.Core.Models;
namespace LehrerApp.Core.Importing;
/// <summary>Importiert den tabulatorgetrennten Export „Teilnehmerliste“ eines Unterrichts.</summary>
public sealed class LessonStudentListCsvImportHandler : IImportHandler<ImportedStudent>
{
private static readonly string[] SignatureHeaders =
["Langname", "Vorname", "Klasse", "Geschlecht", "Eintrittsdatum", "Austrittsdatum",
"E-Mail Adresse", "Mobiltelefon", "Telefonnummer"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
public ImportFormatId FormatId => StudentImportFormats.LessonStudentListCsv;
public string DisplayName => "Unterrichts-Teilnehmerliste";
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> DetectAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
if (!TryDecode(file, out var text))
return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8."));
var headers = FirstLine(text).Split('\t').Select(x => x.Trim())
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return ValueTask.FromResult(SignatureHeaders.All(headers.Contains)
? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95,
"Die charakteristischen Spalten der Unterrichts-Teilnehmerliste wurden gefunden.")
: ImportDetection.NoMatch("Die erwarteten Spalten der Unterrichts-Teilnehmerliste fehlen."));
}
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var messages = new List<ImportMessage>();
if (!TryDecode(file, out var text))
return Result([], [Error("lesson-students.encoding", "Die Teilnehmerliste ist nicht als gültiges UTF-8 gespeichert.")]);
if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError))
return Result([], [Error("lesson-students.structure", parseError ?? "Die Teilnehmerliste ist syntaktisch ungültig.")]);
if (records.Count == 0)
return Result([], [Error("lesson-students.empty", "Die Teilnehmerliste ist leer.")]);
var header = records[0].Fields.Select((name, index) => (Name: name.Trim().TrimStart('\uFEFF'), Index: index)).ToList();
var duplicate = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase).FirstOrDefault(x => x.Count() > 1);
if (duplicate is not null)
messages.Add(Error("lesson-students.header.duplicate", $"Die Spalte „{duplicate.Key}“ kommt mehrfach vor.", "Kopfzeile"));
var columns = header.GroupBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(x => x.Key, x => x.First().Index, StringComparer.OrdinalIgnoreCase);
foreach (var required in SignatureHeaders.Where(x => !columns.ContainsKey(x)))
messages.Add(Error("lesson-students.header.missing", $"Die erforderliche Spalte „{required}“ fehlt.", "Kopfzeile"));
if (messages.Any(x => x.Severity == ImportMessageSeverity.Error)) return Result([], messages);
var students = new List<ImportedStudent>();
foreach (var record in records.Skip(1))
{
cancellationToken.ThrowIfCancellationRequested();
if (record.Fields.All(string.IsNullOrWhiteSpace)) continue;
var source = $"Zeile {record.LineNumber}";
if (record.Fields.Count != header.Count)
{
messages.Add(Error("lesson-students.columns",
$"Die Zeile enthält {record.Fields.Count} statt {header.Count} Spalten.", source));
continue;
}
students.Add(new ImportedStudent
{
LastName = Value(record, columns, "Langname"),
FirstName = Value(record, columns, "Vorname"),
GroupName = NullIfEmpty(Value(record, columns, "Klasse")),
Gender = ParseGender(Value(record, columns, "Geschlecht"), source, messages),
Contact = new ImportedStudentContact
{
Email = NullIfEmpty(Value(record, columns, "E-Mail Adresse")),
MobilePhone = NullIfEmpty(Value(record, columns, "Mobiltelefon")),
Phone = NullIfEmpty(Value(record, columns, "Telefonnummer")),
},
SourceReference = source,
});
}
return Result(students, messages);
}
private static Gender? ParseGender(string value, string source, ICollection<ImportMessage> messages)
{
if (string.IsNullOrWhiteSpace(value)) return null;
return value.Trim().ToLowerInvariant() switch
{
"m" or "männlich" or "maennlich" => Gender.M,
"w" or "weiblich" => Gender.W,
"d" or "divers" => Gender.D,
_ => UnknownGender(value, source, messages),
};
}
private static Gender? UnknownGender(string value, string source, ICollection<ImportMessage> messages)
{
messages.Add(new ImportMessage(ImportMessageSeverity.Warning, "lesson-students.gender.unknown",
$"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.", source));
return null;
}
private static bool TryDecode(ImportFile file, out string text)
{
try { text = StrictUtf8.GetString(file.Content.Span); return true; }
catch (DecoderFallbackException) { text = ""; return false; }
}
private static string FirstLine(string text)
{
var end = text.IndexOfAny(['\r', '\n']);
return (end >= 0 ? text[..end] : text).TrimStart('\uFEFF');
}
private static string Value(DelimitedRecord row, IReadOnlyDictionary<string, int> columns, string name) =>
columns.TryGetValue(name, out var index) && index < row.Fields.Count ? row.Fields[index].Trim() : "";
private static string? NullIfEmpty(string value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static ImportMessage Error(string code, string message, string? source = null) =>
new(ImportMessageSeverity.Error, code, message, source);
private static ValueTask<ImportParseResult<ImportedStudent>> Result(
IReadOnlyList<ImportedStudent> students, IReadOnlyList<ImportMessage> messages) =>
ValueTask.FromResult(new ImportParseResult<ImportedStudent>(students, messages));
}
@@ -0,0 +1,40 @@
using System.Text;
namespace LehrerApp.Core.Importing;
/// <summary>Erkennt den Notenlistenexport und erklärt dessen fehlende Teilnehmerdaten.</summary>
public sealed class MarksPerLessonCsvImportHandler : IImportHandler<ImportedStudent>
{
private static readonly string[] SignatureHeaders =
["Datum", "Name", "Klasse", "Fach", "Prüfungsart", "Note", "Bemerkung", "Benutzer", "Schlüssel (extern)", "Gesamtnote"];
private static readonly UTF8Encoding StrictUtf8 = new(false, true);
public ImportFormatId FormatId => StudentImportFormats.MarksPerLessonCsv;
public string DisplayName => "Unterrichts-Notenliste";
public IReadOnlyCollection<string> SupportedExtensions { get; } = [".csv"];
public ValueTask<ImportDetection> DetectAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
string text;
try { text = StrictUtf8.GetString(file.Content.Span); }
catch (DecoderFallbackException) { return ValueTask.FromResult(ImportDetection.NoMatch()); }
var end = text.IndexOfAny(['\r', '\n']);
var firstLine = (end >= 0 ? text[..end] : text).TrimStart('\uFEFF');
var headers = firstLine.Split('\t').Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
return ValueTask.FromResult(SignatureHeaders.All(headers.Contains)
? ImportDetection.Match(file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95)
: ImportDetection.NoMatch());
}
public ValueTask<ImportParseResult<ImportedStudent>> ParseAsync(ImportFile file, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
IReadOnlyList<ImportMessage> messages =
[
new(ImportMessageSeverity.Error, "marks-per-lesson.no-participants",
"Die Notenliste enthält keine verlässlichen Teilnehmerdaten. Bitte exportiere die echte Kurs-/Teilnehmerliste.")
];
return ValueTask.FromResult(new ImportParseResult<ImportedStudent>([], messages));
}
}
@@ -32,6 +32,8 @@ public sealed record ImportedStudent
public static class StudentImportFormats
{
public static readonly ImportFormatId MasterDataCsv = new("student-master-data.csv");
public static readonly ImportFormatId LessonStudentListCsv = new("lesson-student-list.csv");
public static readonly ImportFormatId MarksPerLessonCsv = new("marks-per-lesson.csv");
}
public enum StudentImportResolutionKind { CreateNew, UseExisting, Skip }
@@ -60,6 +62,7 @@ public sealed class StudentImportPreview
public IReadOnlyList<ImportMessage> Messages { get; }
public IReadOnlyList<ImportConflict> Conflicts { get; }
public IReadOnlyList<StudentImportGroupAssignment> GroupAssignments { get; }
public Guid? TargetGroupId { get; }
internal string ExistingStudentsFingerprint { get; }
internal string GroupStateFingerprint { get; }
@@ -73,6 +76,7 @@ public sealed class StudentImportPreview
IEnumerable<ImportMessage> messages,
IEnumerable<ImportConflict> conflicts,
IEnumerable<StudentImportGroupAssignment> groupAssignments,
Guid? targetGroupId,
string existingStudentsFingerprint,
string groupStateFingerprint)
{
@@ -82,6 +86,7 @@ public sealed class StudentImportPreview
Messages = ReadOnly(messages);
Conflicts = ReadOnly(conflicts);
GroupAssignments = ReadOnly(groupAssignments);
TargetGroupId = targetGroupId;
ExistingStudentsFingerprint = existingStudentsFingerprint;
GroupStateFingerprint = groupStateFingerprint;
}
@@ -222,77 +222,4 @@ public sealed class StudentMasterDataCsvImportHandler : IImportHandler<ImportedS
private static string? NullIfWhiteSpace(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed record DelimitedRecord(int LineNumber, IReadOnlyList<string> Fields);
private static class DelimitedTextParser
{
public static bool TryParse(
string text,
char delimiter,
out List<DelimitedRecord> records,
out string? error)
{
records = [];
error = null;
var fields = new List<string>();
var field = new StringBuilder();
var inQuotes = false;
var line = 1;
var recordStartLine = 1;
for (var index = 0; index < text.Length; index++)
{
var current = text[index];
if (current == '"')
{
if (inQuotes && index + 1 < text.Length && text[index + 1] == '"')
{
field.Append('"');
index++;
}
else
{
inQuotes = !inQuotes;
}
continue;
}
if (current == delimiter && !inQuotes)
{
fields.Add(field.ToString());
field.Clear();
continue;
}
if ((current == '\r' || current == '\n') && !inQuotes)
{
if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n')
index++;
fields.Add(field.ToString());
field.Clear();
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
fields.Clear();
line++;
recordStartLine = line;
continue;
}
if (current == '\n') line++;
field.Append(current);
}
if (inQuotes)
{
error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen.";
return false;
}
if (field.Length > 0 || fields.Count > 0)
{
fields.Add(field.ToString());
records.Add(new DelimitedRecord(recordStartLine, fields.ToList()));
}
return true;
}
}
}
@@ -43,7 +43,19 @@ public sealed class StudentImportService
public async Task<StudentImportPreview> AnalyzeAsync(
ImportFile file,
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default) =>
await AnalyzeInternalAsync(file, null, cancellationToken).ConfigureAwait(false);
public async Task<StudentImportPreview> AnalyzeAsync(
ImportFile file,
Guid targetGroupId,
CancellationToken cancellationToken = default) =>
await AnalyzeInternalAsync(file, targetGroupId, cancellationToken).ConfigureAwait(false);
private async Task<StudentImportPreview> AnalyzeInternalAsync(
ImportFile file,
Guid? targetGroupId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(file);
var detected = await _handlers.DetectAsync(file, cancellationToken).ConfigureAwait(false);
@@ -61,6 +73,15 @@ public sealed class StudentImportService
var existing = _students.GetAll(includeInactive: true);
var groups = _groups.GetAll(includeInactive: true);
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
var targetGroup = targetGroupId is null
? null
: groups.SingleOrDefault(group => group.Id == targetGroupId.Value);
if (targetGroupId is not null && targetGroup is not { IsActive: true })
messages.Add(new ImportMessage(
ImportMessageSeverity.Error,
"student.target-group.unavailable",
"Die Ziel-Lerngruppe existiert nicht oder ist archiviert."));
var currentSchoolYear = _schoolYears.CurrentSchoolYear();
var currentGroups = groups
.Where(group => group.IsActive && group.SchoolYear == currentSchoolYear)
@@ -107,6 +128,17 @@ public sealed class StudentImportService
? sameName.Where(student => student.DateOfBirth == candidate.DateOfBirth).ToList()
: [];
if (candidate.DateOfBirth is null && exact.Count == 0 && targetGroup is not null)
{
var memberIds = groupMemberships
.Where(membership => membership.GroupId == targetGroup.Id)
.Select(membership => membership.StudentId)
.ToHashSet();
var memberMatches = sameName.Where(student => memberIds.Contains(student.Id)).ToList();
if (memberMatches.Count == 1)
exact = memberMatches;
}
if (exact.Count == 1)
{
AddDifferenceWarning(candidate, exact[0], messages);
@@ -139,8 +171,11 @@ public sealed class StudentImportService
entries.Add(new StudentImportEntry(entryId, candidate, null, conflictId, []));
}
var groupAssignments = BuildGroupAssignments(imported, currentGroups, conflicts, messages);
var groupMemberships = groups.SelectMany(group => _memberships.GetByGroup(group.Id)).ToList();
var groupAssignments = targetGroup is { IsActive: true }
? [new StudentImportGroupAssignment(targetGroup.Name, targetGroup.Id, null)]
: targetGroupId is null
? BuildGroupAssignments(imported, currentGroups, conflicts, messages)
: [];
return new StudentImportPreview(
detected.Handler.FormatId,
@@ -149,6 +184,7 @@ public sealed class StudentImportService
messages,
conflicts,
groupAssignments,
targetGroupId,
Fingerprint(existing),
GroupStateFingerprint(groups, groupMemberships, currentSchoolYear));
}
@@ -235,10 +271,9 @@ public sealed class StudentImportService
foreach (var entry in preview.Entries)
{
cancellationToken.ThrowIfCancellationRequested();
if (entry.Student.GroupName is not { } sourceGroup
|| !studentIdsByEntry.TryGetValue(entry.Id, out var studentId)
if (!studentIdsByEntry.TryGetValue(entry.Id, out var studentId)
|| studentId is null
|| !groupResolutions.TryGetValue(sourceGroup, out var groupId)
|| !TryResolveTargetGroup(preview, entry, groupResolutions, out var groupId)
|| groupId is null
|| _memberships.GetByStudentAndGroup(studentId.Value, groupId.Value) is not null)
continue;
@@ -262,6 +297,24 @@ public sealed class StudentImportService
createdIds.AsReadOnly()));
}
private static bool TryResolveTargetGroup(
StudentImportPreview preview,
StudentImportEntry entry,
IReadOnlyDictionary<string, Guid?> groupResolutions,
out Guid? groupId)
{
if (preview.TargetGroupId is not null)
{
groupId = preview.TargetGroupId;
return true;
}
if (entry.Student.GroupName is { } sourceGroup
&& groupResolutions.TryGetValue(sourceGroup, out groupId))
return true;
groupId = null;
return false;
}
private static ImportedStudent Normalize(ImportedStudent student) => student with
{
FirstName = student.FirstName?.Trim() ?? "",
+7
View File
@@ -0,0 +1,7 @@
using Xunit;
// Gleicher Grund wie in LehrerApp.Data.Tests/AssemblyInfo.cs: LiteDBs geteilter, statischer
// BsonMapper.Global verträgt keine parallele Erstzuordnung von Typ-Metadaten über mehrere
// Testklassen hinweg (SettingsViewModelTests/SyncStatusViewModelTests konstruieren beide
// LiteDbContext).
[assembly: CollectionBehavior(DisableTestParallelization = true)]
@@ -56,6 +56,35 @@ public sealed class StudentImportDialogViewModelTests
Assert.Single(stored);
}
[Fact]
public async Task UebernehmenFuerAlle_WaehltBeiJedemEindeutigenKonfliktDenVorhandenenSchueler()
{
var stored = new List<Student>
{
new() { FirstName = "Max", LastName = "Beispiel" },
new() { FirstName = "Erika", LastName = "Muster" },
};
var service = Service(stored,
[
new ImportedStudent { FirstName = "Max", LastName = "Beispiel" },
new ImportedStudent { FirstName = "Erika", LastName = "Muster" },
]);
var preview = await service.AnalyzeAsync(File());
var vm = new StudentImportDialogViewModel(service, preview, "students.csv");
Assert.True(vm.CanUseExistingForAll);
Assert.All(vm.Conflicts, conflict => Assert.Equal("skip", conflict.SelectedOption.Id));
vm.UseExistingForAll();
Assert.All(vm.Conflicts,
conflict => Assert.StartsWith("existing:", conflict.SelectedOption.Id));
Assert.True(await vm.TryApplyAsync());
Assert.Equal(2, vm.Result?.MatchedExistingStudents);
Assert.Equal(0, vm.Result?.SkippedStudents);
Assert.Equal(2, stored.Count);
}
private static StudentImportService Service(
List<Student> stored,
IReadOnlyList<ImportedStudent> imported) => new(
@@ -0,0 +1,62 @@
using LehrerApp.Data;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using Xunit;
namespace LehrerApp.Desktop.Tests;
public sealed class SyncStatusViewModelTests
{
[Fact]
public void Konstruktor_EngineBereitsVorhanden_ZeigtNichtDenNichtKonfiguriertText()
{
// SyncEngine feuert sein erstes StatusChanged schon im eigenen Konstruktor - läuft der vor
// dem Abonnieren in SyncStatusViewModel (z.B. weil beide DI-Singletons sind und
// GetService<SyncEngine>() den Engine-Konstruktor erst innerhalb der VM-Factory auslöst),
// ginge dieser erste Status verloren und StatusText bliebe fälschlich beim Default.
using var temp = new TempSyncEngine();
var vm = new SyncStatusViewModel(temp.Engine);
Assert.True(vm.IsServerConfigured);
Assert.NotEqual("Kein Server konfiguriert", vm.StatusText);
Assert.Equal("Synchronisiert", vm.StatusText);
}
[Fact]
public void Konstruktor_KeineEngine_ZeigtNichtKonfiguriertText()
{
var vm = new SyncStatusViewModel(null);
Assert.False(vm.IsServerConfigured);
Assert.Equal("Kein Server konfiguriert", vm.StatusText);
}
private sealed class TempSyncEngine : IDisposable
{
private readonly string _queuePath = Path.Combine(
Path.GetTempPath(), $"lehrerapp-desktop-tests-{Guid.NewGuid():N}.db");
private readonly LiteDbContext _db = new(new MemoryStream());
private readonly HttpClient _http = new();
public SyncEngine Engine { get; }
public TempSyncEngine()
{
var queue = new EventQueue(_queuePath);
var key = SyncCrypto.GenerateKey();
Engine = new SyncEngine(
queue, new ConflictResolver(queue), new EventApplier(_db, key),
new AttachmentSyncer(_db, _http, key), _http,
new SyncConfig { AutoSyncIntervalMinutes = 5 });
}
public void Dispose()
{
Engine.Dispose(); // disposed auch die EventQueue
_db.Dispose();
_http.Dispose();
if (File.Exists(_queuePath)) File.Delete(_queuePath);
}
}
}
@@ -1,6 +1,7 @@
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using Xunit;
namespace LehrerApp.Desktop.Tests;
@@ -151,12 +152,12 @@ public sealed class TimetableViewModelTests
public void OpenSettings_RuftOnNavigateToSettingsAuf()
{
var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]));
var called = false;
vm.OnNavigateToSettings = () => called = true;
SettingsTab? navigatedTab = null;
vm.OnNavigateToSettings = tab => navigatedTab = tab;
vm.OpenSettingsCommand.Execute(null);
Assert.True(called);
Assert.Equal(SettingsTab.Holidays, navigatedTab);
}
// ── "Heute"-Ansicht: Wochenraster (Nutzer-Feedback, zweite Iteration) ────────────────────
+1 -1
View File
@@ -109,7 +109,7 @@ public class App : Application
// Stundenplan "Heute" → GroupDetail (Tab "Planung") / Einstellungen (Zahnrad, Tab "Ferien & Feiertage")
var timetable = Services.GetRequiredService<TimetableViewModel>();
timetable.OnNavigateToSettings = () => main.NavigateToSettings(7);
timetable.OnNavigateToSettings = tab => main.NavigateToSettings(tab);
timetable.OnNavigateToGroup = id => main.NavigateToGroupDetail(id, 6);
}
+2
View File
@@ -113,6 +113,8 @@ public static class AppBootstrapper
services.AddSingleton<AttendanceBalanceService>();
services.AddSingleton<PersonalDataExportService>();
services.AddSingleton<IImportHandler<ImportedStudent>, StudentMasterDataCsvImportHandler>();
services.AddSingleton<IImportHandler<ImportedStudent>, LessonStudentListCsvImportHandler>();
services.AddSingleton<IImportHandler<ImportedStudent>, MarksPerLessonCsvImportHandler>();
services.AddSingleton<StudentImportService>();
// ── Datenbank ─────────────────────────────────────────────────────────
@@ -116,11 +116,11 @@ public partial class MainWindowViewModel : ObservableObject
vm.LoadGroup(groupId); // dann Daten laden
}
public void NavigateToSettings(int initialTab = 0)
public void NavigateToSettings(SettingsTab initialTab = SettingsTab.Subjects)
{
ActiveNavItem = NavItem.Settings;
var vm = _services.GetRequiredService<SettingsViewModel>();
vm.ActiveTabIndex = initialTab;
vm.ActiveTabIndex = (int)initialTab;
CurrentPage = vm;
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Settings;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Planning;
@@ -78,7 +79,7 @@ public partial class TimetableViewModel : ObservableObject
public Func<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? OnAddSubstitution { get; set; }
public Action? OnNavigateToSettings { get; set; }
public Action<SettingsTab>? OnNavigateToSettings { get; set; }
public TimetableViewModel(ITimetableSlotRepository slots, IGroupRepository groups,
ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams,
@@ -234,7 +235,7 @@ public partial class TimetableViewModel : ObservableObject
}
[RelayCommand]
private void OpenSettings() => OnNavigateToSettings?.Invoke();
private void OpenSettings() => OnNavigateToSettings?.Invoke(SettingsTab.Holidays);
// ── "Heute": Wochenraster (Nutzer-Feedback) — wie das Bearbeiten-Raster, aber nur Anzeige ──
@@ -14,6 +14,28 @@ using System.Text.Json.Serialization;
namespace LehrerApp.Desktop.ViewModels.Settings;
/// <summary>
/// Reihenfolge MUSS exakt der Reihenfolge der ContentPage-Elemente in SettingsView.axaml
/// entsprechen (ActiveTabIndex bindet per Index, nicht per Name) — beim Umsortieren eines
/// Tabs in der XAML immer auch hier den Enum-Wert verschieben.
/// </summary>
public enum SettingsTab
{
Subjects = 0,
ShorthandCodes = 1,
Competencies = 2,
GradingKeyTemplates = 3,
GradingScheme = 4,
LetterTemplates = 5,
Holidays = 6,
PeriodSchedule = 7,
SupervisionDuties = 8,
Security = 9,
Privacy = 10,
Sync = 11,
Ai = 12,
}
// ── Haupt-ViewModel ───────────────────────────────────────────────────────────
public partial class SettingsViewModel : ObservableObject
@@ -176,6 +198,20 @@ public partial class SettingsViewModel : ObservableObject
public ObservableCollection<SyncConflictListItem> SyncConflicts { get; } = [];
// ── Geräte-Pairing (10.3.1: Schlüsselübertragung auf ein zweites Gerät) ───
//
// SnapshotService ist nur registriert, wenn bereits eine Server-URL konfiguriert ist (siehe
// AppBootstrapper) — daher optional/nullable statt eines Pflicht-Konstruktorparameters.
[ObservableProperty] private string _pairingCode = "";
[ObservableProperty] private string _pairingCodeInput = "";
[ObservableProperty] private string _pairingStatus = "";
[ObservableProperty] private bool _pairingBusy;
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog, bevor die lokale Datenbank durch
/// den Stand des anderen Geräts ersetzt wird.
public Func<Task<bool>>? OnConfirmPairingRestore { get; set; }
// ── Konstruktor ───────────────────────────────────────────────────────────
private readonly ISchoolHolidayRepository _schoolHolidays;
@@ -187,6 +223,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
private readonly SnapshotService? _snapshotService;
private readonly CompetencyCatalogImportService _catalogImport;
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
@@ -198,7 +235,8 @@ public partial class SettingsViewModel : ObservableObject
SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule,
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue)
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
SnapshotService? snapshotService = null)
{
_subjects = subjects;
_domainRepo = domainRepo;
@@ -223,6 +261,7 @@ public partial class SettingsViewModel : ObservableObject
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
_snapshotService = snapshotService;
_catalogImport = new CompetencyCatalogImportService(domainRepo);
LoadSubjects();
LoadShorthandCodes();
@@ -463,6 +502,72 @@ public partial class SettingsViewModel : ObservableObject
SyncConflicts.Remove(item);
}
// ── Geräte-Pairing: Code erzeugen / einlösen ─────────────────────────────
//
// CreateAndUploadAsync lädt einen verschlüsselten Snapshot der lokalen Datenbank samt
// Sync-Schlüssel hoch, RestoreFromCodeAsync ersetzt auf dem ZWEITEN Gerät die dortige
// Datenbank vollständig durch diesen Snapshot (vorheriger Stand wird automatisch als
// .backup-Datei gesichert, siehe SnapshotService) — deshalb vor dem Einlösen ein
// Bestätigungsdialog wie bei RestoreBackup.
[RelayCommand]
private async Task CreatePairingCode()
{
if (_snapshotService is null) return;
PairingBusy = true;
PairingCode = "";
PairingStatus = "";
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
_snapshotService.ProgressChanged += OnProgress;
try
{
var result = await _snapshotService.CreateAndUploadAsync();
PairingCode = result.Code;
PairingStatus = $"Gültig bis {result.ExpiresAt:dd.MM.yyyy HH:mm} — auf dem anderen Gerät eingeben.";
}
catch (Exception ex) when (ex is HttpRequestException or InvalidOperationException)
{
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
}
finally
{
_snapshotService.ProgressChanged -= OnProgress;
PairingBusy = false;
}
}
[RelayCommand]
private async Task RedeemPairingCode()
{
if (_snapshotService is null) return;
if (string.IsNullOrWhiteSpace(PairingCodeInput)) { PairingStatus = "Bitte Code eingeben."; return; }
if (OnConfirmPairingRestore is not null && !await OnConfirmPairingRestore()) return;
PairingBusy = true;
PairingStatus = "";
void OnProgress(SnapshotProgress p) => PairingStatus = p.Message;
_snapshotService.ProgressChanged += OnProgress;
// LiteDB hält beim Öffnen einen exklusiven Dateilock — muss vor dem Überschreiben
// geschlossen sein. Läuft danach ein Fehler (falscher Code, Server nicht erreichbar), ist
// _dbContext bereits disposed und die App nicht mehr sicher weiter benutzbar, ohne dass
// die Datenbankdatei selbst angefasst wurde (RestoreFromCodeAsync schreibt sie erst ganz
// am Ende) — deshalb in JEDEM Fall (Erfolg wie Fehlschlag) neu starten, nicht nur bei
// Erfolg. Ein Neustart öffnet dann wieder dieselbe, unveränderte Datenbank.
_dbContext.Dispose();
try
{
await _snapshotService.RestoreFromCodeAsync(PairingCodeInput.Trim(), AppBootstrapper.DbPath);
}
catch (Exception ex) when (ex is SnapshotNotFoundException or InvalidOperationException or HttpRequestException)
{
// _dbContext ist bereits disposed, PairingStatus ist aber ein reines ViewModel-Feld
// ohne DB-Zugriff - das Setzen ist unabhängig davon noch sicher.
PairingStatus = $"Fehlgeschlagen: {ex.Message}";
}
AppBootstrapper.RestartApplication();
}
// ── Stundenraster: Laden / Speichern ─────────────────────────────────────
private void LoadPeriodTimes()
@@ -30,6 +30,7 @@ public partial class StudentImportDialogViewModel : ObservableObject
public ObservableCollection<StudentImportConflictItem> Conflicts { get; } = [];
public bool HasMessages => Messages.Count > 0;
public bool HasConflicts => Conflicts.Count > 0;
public bool CanUseExistingForAll => Conflicts.Any(conflict => conflict.HasSingleExistingStudentOption);
public bool IsReadyWithoutConflicts => !HasConflicts && _preview.CanApply;
public bool HasBlockingErrors => !_preview.CanApply;
public bool CanApply => _preview.CanApply && !IsBusy;
@@ -65,6 +66,16 @@ public partial class StudentImportDialogViewModel : ObservableObject
.ToList()
.AsReadOnly();
/// <summary>
/// Wählt für alle eindeutigen Schüler-Konflikte den jeweils vorhandenen Datensatz.
/// Mehrdeutige Treffer und Lerngruppen-Konflikte bleiben unverändert.
/// </summary>
public void UseExistingForAll()
{
foreach (var conflict in Conflicts)
conflict.TrySelectSingleExistingStudent();
}
public async Task<bool> TryApplyAsync()
{
Error = "";
@@ -116,6 +127,8 @@ public sealed class StudentImportMessageItem
public partial class StudentImportConflictItem : ObservableObject
{
private const string ExistingStudentOptionPrefix = "existing:";
[ObservableProperty] private StudentImportConflictOptionItem _selectedOption;
public string Id { get; }
@@ -125,6 +138,11 @@ public partial class StudentImportConflictItem : ObservableObject
public string ExistingValue { get; }
public string SourceReference { get; }
public IReadOnlyList<StudentImportConflictOptionItem> Options { get; }
public bool HasSingleExistingStudentOption => ExistingStudentOptions.Count == 1;
private IReadOnlyList<StudentImportConflictOptionItem> ExistingStudentOptions => Options
.Where(option => option.Id.StartsWith(ExistingStudentOptionPrefix, StringComparison.Ordinal))
.ToList();
public StudentImportConflictItem(ImportConflict conflict)
{
@@ -141,6 +159,14 @@ public partial class StudentImportConflictItem : ObservableObject
.AsReadOnly();
_selectedOption = Options.First(option => option.Id == conflict.DefaultOptionId);
}
public bool TrySelectSingleExistingStudent()
{
var options = ExistingStudentOptions;
if (options.Count != 1) return false;
SelectedOption = options[0];
return true;
}
}
public sealed record StudentImportConflictOptionItem(
@@ -19,7 +19,16 @@ public partial class SyncStatusViewModel : ObservableObject
{
_engine = engine;
IsServerConfigured = engine is not null;
if (_engine is not null) _engine.StatusChanged += OnStatus;
if (_engine is not null)
{
_engine.StatusChanged += OnStatus;
// SyncEngine feuert sein erstes StatusChanged bereits im eigenen Konstruktor
// (UpdateStatus) - der läuft aber schon, während wir hier noch in GetService<SyncEngine>()
// stecken, also VOR dem obigen Abonnieren. Ohne diesen Nachhol-Aufruf bliebe StatusText
// nach jedem Neustart beim Default "Kein Server konfiguriert", bis der nächste
// Auto-Sync oder Klick auf "Jetzt synchronisieren" den Text erstmals aktualisiert.
OnStatus(_engine.Status);
}
}
private void OnStatus(SyncStatus s)
@@ -23,6 +23,8 @@
<CheckBox Content="Ausgetretene anzeigen" IsChecked="{Binding ShowFormerStudents}"
VerticalAlignment="Center"/>
<Button Content=" Schüler" Command="{Binding AddStudentCommand}" IsEnabled="{Binding IsEditable}"/>
<Button Content="⇩ Teilnehmer importieren…" Click="OnImportParticipantsClick"
IsEnabled="{Binding IsEditable}"/>
<Button Content="{Binding SelectedStudent.WithdrawActionLabel}"
Command="{Binding WithdrawStudentCommand}"
IsVisible="{Binding SelectedStudent, Converter={x:Static ObjectConverters.IsNotNull}}"/>
@@ -1,15 +1,23 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.Views.Shared;
using LehrerApp.Desktop.Views.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupDetailView : UserControl
{
private const int MaximumImportFileSize = 20 * 1024 * 1024;
public GroupDetailView() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
@@ -60,6 +68,61 @@ public partial class GroupDetailView : UserControl
return await dialog.ShowDialog<bool>(owner);
}
private async void OnImportParticipantsClick(object? sender, RoutedEventArgs e)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null || DataContext is not GroupDetailViewModel vm || vm.Group is null) return;
var service = App.Services.GetRequiredService<StudentImportService>();
var patterns = service.SupportedExtensions.Select(extension => $"*{extension}").ToArray();
var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = $"Teilnehmer für {vm.Group.Name} importieren",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("Unterstützte Schülerlisten")
{
Patterns = patterns.Length > 0 ? patterns : ["*.csv"],
},
],
});
if (files.Count == 0) return;
try
{
await using var source = await files[0].OpenReadAsync();
if (source.CanSeek && source.Length > MaximumImportFileSize)
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
using var buffer = new MemoryStream();
await source.CopyToAsync(buffer);
if (buffer.Length > MaximumImportFileSize)
throw new InvalidDataException("Die Importdatei ist größer als 20 MB.");
var importFile = new ImportFile(files[0].Name, buffer.ToArray());
var preview = await Task.Run(async () =>
await service.AnalyzeAsync(importFile, vm.Group.Id).ConfigureAwait(false));
var dialogVm = new StudentImportDialogViewModel(service, preview, files[0].Name);
var dialog = new StudentImportDialog { DataContext = dialogVm };
if (!await dialog.ShowDialog<bool>(owner)) return;
vm.LoadStudents();
vm.ParticipationTab.RefreshCurrentGrid();
var result = dialogVm.Result!;
App.Services.GetRequiredService<NotificationService>().ShowSuccess(
$"Teilnehmerimport abgeschlossen: {result.CreatedStudents} neu, "
+ $"{result.UpdatedStudents} ergänzt, {result.CreatedMemberships} zugeordnet.");
}
catch (Exception ex) when (ex is ImportFormatException
or InvalidDataException
or IOException
or UnauthorizedAccessException)
{
App.Services.GetRequiredService<NotificationService>().ShowError(ex.Message);
}
}
private async Task<bool> ShowWithdrawStudentDialog(StudentSummary student)
{
if (DataContext is not GroupDetailViewModel vm || vm.Group is null) return false;
@@ -10,7 +10,9 @@
<shared:PageHeader Grid.Row="0" Title="Einstellungen" Margin="32,28,32,0"/>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<TabbedPage Grid.Row="1" TabPlacement="Left" SelectedIndex="{Binding ActiveTabIndex}">
<!-- ═══ Fachliches ═══ -->
<!-- Tab: Fächer -->
<ContentPage Header="Fächer">
@@ -282,6 +284,50 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Word-Briefvorlagen (7.1.4 / 11.5) -->
<ContentPage Header="Briefvorlagen">
<ScrollViewer>
@@ -362,50 +408,162 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Notenschema -->
<ContentPage Header="Notenschema">
<ContentPage.Resources>
<DataTemplate x:Key="GradingSchemeTemplate" DataType="vm:GradingSchemeEditItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Label}" FontWeight="SemiBold" FontSize="14"/>
<Grid ColumnDefinitions="Auto,90,Auto,90,Auto,90" ColumnSpacing="6">
<TextBlock Grid.Column="0" Text="Klausuren %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="1" Value="{Binding ExamsPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="2" Text="Mitarbeit %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="3" Value="{Binding ParticipationPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
<TextBlock Grid.Column="4" Text="Sonstige %" VerticalAlignment="Center" FontSize="12"/>
<NumericUpDown Grid.Column="5" Value="{Binding OtherPercent}" Minimum="0" Maximum="100"
FormatString="0.#" ShowButtonSpinner="False"/>
</Grid>
<TextBlock Text="{Binding ValidationMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" Foreground="Green" FontSize="12"
VerticalAlignment="Center"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Grid.Column="1" Content="Speichern" Command="{Binding SaveCommand}"/>
</Grid>
</StackPanel>
</Border>
</DataTemplate>
</ContentPage.Resources>
<!-- ═══ Zeitplanung ═══ -->
<!-- Tab: Ferien & Feiertage (4.3.5) -->
<ContentPage Header="Ferien &amp; Feiertage">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Voreinstellung der Gewichtung für die Zeugnisnote (Klausuren / Mitarbeit / Sonstige), je Gruppentyp. Kann pro Lerngruppe überschrieben werden."
<StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ContentControl Content="{Binding ClassScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
<ContentControl Content="{Binding CourseScheme}" ContentTemplate="{StaticResource GradingSchemeTemplate}"/>
<ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- ═══ System ═══ -->
<!-- Tab: Sicherheit (13.3) -->
<ContentPage Header="Sicherheit">
<ScrollViewer>
@@ -570,195 +728,6 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: Ferien & Feiertage (4.3.5) -->
<ContentPage Header="Ferien &amp; Feiertage">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<Separator Margin="0,4"/>
<TextBlock Text="Schulferien" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Werden im Stundenplan als unterrichtsfreie Tage angezeigt."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<ItemsControl ItemsSource="{Binding SchoolHolidayEntries}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SchoolHolidayItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding RangeDisplay}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSchoolHolidayCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Schulferien hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SchoolHolidayEntries.Count}"/>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding NewHolidayName}" PlaceholderText="Name (z.B. Sommerferien)"/>
<TextBlock Text="{Binding HolidayNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0" Text="{Binding NewHolidayStartText}" PlaceholderText="Beginn TT.MM.JJJJ"/>
<TextBox Grid.Column="2" Text="{Binding NewHolidayEndText}" PlaceholderText="Ende TT.MM.JJJJ"/>
</Grid>
<TextBlock Text="{Binding HolidayDateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding HolidayDateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Schulferien hinzufügen" Command="{Binding AddSchoolHolidayCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Stundenraster (4.2.2 Nachtrag) -->
<ContentPage Header="Stundenraster">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="Uhrzeiten der Einzelstunden" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Grundlage für die Zeitbedarf-Rückmeldung im Verlaufsplan-Editor. Nicht alle Stunden müssen eingetragen sein — für unkonfigurierte Stunden bleibt die Rückmeldung dort einfach aus."/>
<Grid ColumnDefinitions="70,*,8,*" Margin="0,4,0,0">
<TextBlock Grid.Column="1" Text="Beginn" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Grid.Column="3" Text="Ende" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
</Grid>
<ItemsControl ItemsSource="{Binding PeriodTimes}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:PeriodTimeEditItem">
<Grid ColumnDefinitions="70,*,8,*" Margin="0,3">
<TextBlock Grid.Column="0" Text="{Binding PeriodLabel}" FontSize="13" VerticalAlignment="Center"/>
<TextBox Grid.Column="1" Text="{Binding StartText}" PlaceholderText="HH:MM"/>
<TextBox Grid.Column="3" Text="{Binding EndText}" PlaceholderText="HH:MM"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PeriodTimesError}" Foreground="Red" FontSize="12"
IsVisible="{Binding PeriodTimesError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern" Command="{Binding SavePeriodTimesCommand}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PeriodTimesStatus}" Foreground="Green" FontSize="12"
IsVisible="{Binding PeriodTimesStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Aufsichten (4.3 Nachtrag) -->
<ContentPage Header="Aufsichten">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Wiederkehrende Pausenaufsicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Wird im Stundenplan zwischen den betroffenen Stunden angezeigt. Einmalige Vertretungsaufsichten trägst du direkt im Stundenplan (Heute-Ansicht) ein, nicht hier."/>
<ItemsControl ItemsSource="{Binding SupervisionDuties}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SupervisionDutyItem">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="0,7">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="{Binding WeekdayLabel}"/><Run Text=" — "/><Run Text="{Binding PeriodLabel}"/>
</TextBlock>
<TextBlock Text="{Binding Location}" FontSize="12" Opacity="0.6"/>
</StackPanel>
<Button Grid.Column="1" Content="×" FontSize="14" Padding="9,3"
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RemoveSupervisionDutyCommand}"
CommandParameter="{Binding}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Aufsicht hinterlegt." Classes="emptyhint"
IsVisible="{Binding !SupervisionDuties.Count}"/>
<Separator Margin="0,4"/>
<Grid ColumnDefinitions="*,8,Auto">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Wochentag" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding WeekdayOptions}" SelectedItem="{Binding NewDutyWeekdayName}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Nach Stunde (0 = davor)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding NewDutyAfterPeriod}" Minimum="0" Maximum="10" FormatString="0"
Width="140" ShowButtonSpinner="True"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Ort / Bezeichnung" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding NewDutyLocation}" PlaceholderText="z.B. Pausenhof"/>
</StackPanel>
<TextBlock Text="{Binding NewDutyError}" Foreground="Red" FontSize="12"
IsVisible="{Binding NewDutyError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content=" Aufsicht hinzufügen" Command="{Binding AddSupervisionDutyCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Synchronisation (Kapitel 10) -->
<ContentPage Header="Synchronisation">
<ScrollViewer>
@@ -806,6 +775,35 @@
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Speichern/Anmelden startet die App neu, damit die Änderung wirksam wird."/>
<StackPanel Spacing="10" Margin="0,10,0,0" IsVisible="{Binding SyncIsLoggedIn}">
<Separator/>
<TextBlock Text="Gerät koppeln" FontSize="14" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Login allein reicht nicht: Verschlüsselt wird lokal mit einem eigenen Schlüssel je Gerät, der nie über den Server läuft. Für ein zweites Gerät hier einen Code erzeugen — auf dem anderen Gerät eingeben, um Datenbank und Schlüssel zu übernehmen."/>
<StackPanel Spacing="6">
<TextBlock Text="Dieses Gerät als Quelle" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<Button Content="Code erzeugen" Command="{Binding CreatePairingCodeCommand}"
IsEnabled="{Binding !PairingBusy}" HorizontalAlignment="Left"/>
<TextBlock Text="{Binding PairingCode}" FontSize="20" FontWeight="Bold"
FontFamily="Monospace" IsVisible="{Binding PairingCode, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="Code von einem anderen Gerät einlösen" FontSize="12" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock FontSize="11" Opacity="0.5" TextWrapping="Wrap"
Text="Ersetzt die Datenbank auf DIESEM Gerät vollständig — nur auf einem Gerät ohne eigene Daten verwenden."/>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Text="{Binding PairingCodeInput}" PlaceholderText="TIGER-42-BLAU"/>
<Button Grid.Column="2" Content="Einlösen" Command="{Binding RedeemPairingCodeCommand}"
IsEnabled="{Binding !PairingBusy}"/>
</Grid>
</StackPanel>
<TextBlock Text="{Binding PairingStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding PairingStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<TextBlock Text="Konflikte" FontSize="14" FontWeight="SemiBold" Margin="0,10,0,0"
IsVisible="{Binding SyncConflicts.Count}"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
@@ -836,6 +834,42 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: KI-Unterstützung (4.5.9) -->
<ContentPage Header="KI-Unterstützung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="KI-gestützte Planungsunterstützung" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ermöglicht KI-Vorschläge für Unterrichtseinheiten über einen Zwischendienst auf dem eigenen Server (kein API-Schlüssel im Client). Jede Anfrage verbraucht Guthaben."/>
<CheckBox Content="KI-Unterstützung aktivieren" IsChecked="{Binding AiEnabled}"/>
<StackPanel Spacing="8" IsVisible="{Binding !AiIsLoggedIn}">
<StackPanel Spacing="4">
<TextBlock Text="Benutzername" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiUsername}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Passwort" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AiPassword}" PasswordChar="●"/>
</StackPanel>
<TextBlock Text="{Binding AiLoginError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AiLoginError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Anmelden" Command="{Binding AiLoginCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AiIsLoggedIn}">
<TextBlock FontSize="13" FontWeight="SemiBold">
<Run Text="Angemeldet als: "/><Run Text="{Binding AiUsername}"/>
</TextBlock>
<TextBlock Text="{Binding AiBalanceDisplay}" FontSize="13"/>
<Button Content="Abmelden" Command="{Binding AiLogoutCommand}" HorizontalAlignment="Left"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
</TabbedPage>
</Grid>
</UserControl>
@@ -20,6 +20,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRestore = ShowRestoreConfirmDialog;
vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig();
vm.OnConfirmHardDelete = ShowHardDeleteConfirmDialog;
vm.OnConfirmPairingRestore = ShowPairingRestoreConfirmDialog;
}
}
@@ -50,6 +51,21 @@ public partial class SettingsView : UserControl
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> ShowPairingRestoreConfirmDialog()
{
var info = new ConfirmDialogInfo
{
Title = "Gerät koppeln?",
Message = "Die lokale Datenbank auf diesem Gerät wird vollständig durch den Stand des anderen " +
"Geräts ersetzt. Der bisherige Stand wird automatisch als Backup gesichert. " +
"Die App wird danach automatisch neu gestartet.",
ConfirmText = "Ersetzen",
};
var dialog = new ConfirmDialog { DataContext = info };
var owner = TopLevel.GetTopLevel(this) as Window;
return owner is not null && await dialog.ShowDialog<bool>(owner);
}
private async void OnImportClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
@@ -34,8 +34,14 @@
</StackPanel>
<Grid Grid.Row="1" RowDefinitions="Auto,*" Margin="0,16,0,0">
<TextBlock Grid.Row="0" Text="Entscheidungen" FontSize="15" FontWeight="SemiBold"
Margin="0,0,0,8" IsVisible="{Binding HasConflicts}"/>
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,8"
IsVisible="{Binding HasConflicts}">
<TextBlock Grid.Column="0" Text="Entscheidungen" FontSize="15" FontWeight="SemiBold"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Übernehmen für alle" Click="OnUseExistingForAll"
IsVisible="{Binding CanUseExistingForAll}"
ToolTip.Tip="Wählt bei allen eindeutigen Treffern den jeweils vorhandenen Schüler aus."/>
</Grid>
<Border Grid.Row="1" IsVisible="{Binding IsReadyWithoutConflicts}"
Background="#1422A06B" CornerRadius="6" Padding="14"
@@ -14,5 +14,11 @@ public partial class StudentImportDialog : Window
Close(true);
}
private void OnUseExistingForAll(object? sender, RoutedEventArgs e)
{
if (DataContext is StudentImportDialogViewModel vm)
vm.UseExistingForAll();
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}
@@ -0,0 +1,59 @@
using System.Text;
using LehrerApp.Core.Importing;
using LehrerApp.Core.Models;
using Xunit;
namespace LehrerApp.Tests;
public sealed class LessonStudentListCsvImportHandlerTests
{
[Fact]
public async Task Parse_UebernimmtTeilnehmerUndOptionaleKontaktdaten()
{
var handler = new LessonStudentListCsvImportHandler();
var result = await handler.ParseAsync(File(LessonList()));
Assert.True(result.IsValid);
var student = Assert.Single(result.Items);
Assert.Equal("Max", student.FirstName);
Assert.Equal("Beispiel", student.LastName);
Assert.Equal("10c", student.GroupName);
Assert.Equal(Gender.M, student.Gender);
Assert.Equal("max@example.invalid", student.Contact?.Email);
Assert.Equal("0151 123", student.Contact?.MobilePhone);
Assert.Equal("0123 456", student.Contact?.Phone);
Assert.Equal("Zeile 2", student.SourceReference);
}
[Fact]
public async Task Detect_UnterscheidetTeilnehmerlisteVonStammdatenliste()
{
var handler = new LessonStudentListCsvImportHandler();
var detection = await handler.DetectAsync(File(LessonList()));
Assert.True(detection.IsMatch);
Assert.Equal(100, detection.Confidence);
}
[Fact]
public async Task MarksExport_WirdErkanntUndErklaertFehlendeTeilnehmerdaten()
{
var handler = new MarksPerLessonCsvImportHandler();
const string header = "Datum\tName\tKlasse\tFach\tPrüfungsart\tNote\tBemerkung\tBenutzer\tSchlüssel (extern)\tGesamtnote\n";
var detection = await handler.DetectAsync(File(header));
var result = await handler.ParseAsync(File(header));
Assert.True(detection.IsMatch);
Assert.False(result.IsValid);
Assert.Empty(result.Items);
Assert.Contains(result.Messages, message => message.Code == "marks-per-lesson.no-participants");
}
private static ImportFile File(string text) => new("export.csv", Encoding.UTF8.GetBytes(text));
private static string LessonList() => string.Join('\n',
"Langname\tVorname\tKlasse\tGeschlecht\tEintrittsdatum\tAustrittsdatum\tE-Mail Adresse\tMobiltelefon\tTelefonnummer",
"Beispiel\tMax\t10c\tm\t01.08.2025\t\tmax@example.invalid\t0151 123\t0123 456") + "\n";
}
@@ -305,6 +305,67 @@ public sealed class StudentImportServiceTests
Assert.Equal(ownClass.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
}
[Fact]
public async Task Apply_ExpliziteZielgruppeIgnoriertHeimatklasseUndVervollstaendigtTeilnehmerliste()
{
var repository = new InMemoryStudentRepository();
var target = new LearningGroup { Name = "Mathematik 10", IsActive = true };
var groups = new InMemoryGroupRepository([target]);
var memberships = new InMemoryGroupMembershipRepository();
var service = Service(repository,
[
new ImportedStudent
{
FirstName = "Max", LastName = "Beispiel", GroupName = "10c",
},
], groups, memberships);
var preview = await service.AnalyzeAsync(File(), target.Id);
var result = await service.ApplyAsync(preview);
Assert.True(preview.CanApply);
Assert.Equal(target.Id, preview.TargetGroupId);
Assert.Equal(target.Id, Assert.Single(preview.GroupAssignments).AutomaticGroupId);
Assert.Equal(1, result.CreatedStudents);
Assert.Equal(1, result.CreatedMemberships);
Assert.Equal(target.Id, Assert.Single(memberships.Memberships).GroupId);
}
[Fact]
public async Task Analyze_ExpliziteZielgruppeErkenntBereitsZugeordnetenNamensgleichenSchueler()
{
var existing = new Student { FirstName = "Max", LastName = "Beispiel" };
var repository = new InMemoryStudentRepository([existing]);
var target = new LearningGroup { Name = "Mathematik 10", IsActive = true };
var groups = new InMemoryGroupRepository([target]);
var memberships = new InMemoryGroupMembershipRepository();
memberships.Save(new GroupMembership { StudentId = existing.Id, GroupId = target.Id });
var service = Service(repository,
[new ImportedStudent { FirstName = "Max", LastName = "Beispiel" }],
groups, memberships);
var preview = await service.AnalyzeAsync(File(), target.Id);
Assert.Empty(preview.Conflicts);
var resolution = Assert.Single(preview.Entries).AutomaticResolution;
Assert.Equal(StudentImportResolutionKind.UseExisting, resolution?.Kind);
Assert.Equal(existing.Id, resolution?.ExistingStudentId);
}
[Fact]
public async Task Analyze_ArchivierteZielgruppeVerhindertImport()
{
var target = new LearningGroup { Name = "Alter Kurs", IsActive = false };
var service = Service(new InMemoryStudentRepository(),
[new ImportedStudent { FirstName = "Max", LastName = "Beispiel" }],
new InMemoryGroupRepository([target]));
var preview = await service.AnalyzeAsync(File(), target.Id);
Assert.False(preview.CanApply);
Assert.Contains(preview.Messages, message => message.Code == "student.target-group.unavailable");
}
private static StudentImportService Service(
InMemoryStudentRepository repository,
IReadOnlyList<ImportedStudent> imported,
+32 -1
View File
@@ -1317,6 +1317,17 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
RestartApplication`, gleiches Muster wie bei DB-Passwort/AppLock-Änderungen) — `SyncEngine`/
`SnapshotService` werden nur einmalig beim Start registriert, es gibt keinen
Live-Re-Registrierungspfad.
**Nachtrag (Einstellungen-Reorg):** Der Tab-Balken war mit 13 Tabs oben (`TabPlacement="Top"`)
unübersichtlich lang geworden und wird mit weiteren Sync-Features noch länger. Umgestellt auf
`TabPlacement="Left"` (vertikale Liste, skaliert besser) und die Tabs in drei logische Gruppen
sortiert (Fachliches / Zeitplanung / System). Neuer Enum `SettingsTab` in
`SettingsViewModel.cs` ersetzt die bisherigen rohen `int`-Tab-Indizes bei
`MainWindowViewModel.NavigateToSettings`/`TimetableViewModel.OnNavigateToSettings` — beim
Umsortieren dabei ein bereits vorhandener Bug aufgefallen und mitbehoben: das Zahnrad-Symbol
im Stundenplan öffnete über den hartcodierten Index `7` tatsächlich den Tab "Datenschutz"
statt des in Kommentar/Tooltip beabsichtigten "Ferien & Feiertage" — mit benanntem Enum kann
diese Klasse von Fehler nicht mehr auftreten.
- [x] **10.1.2** Verbindungstest mit klarer Fehlermeldung (nicht erreichbar / Token ungültig).
**Umsetzung:** `SyncAuthService.TestConnectionAsync` unterscheidet drei Zustände (erreichbar
@@ -1337,6 +1348,17 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
**Umsetzung:** War bereits vorhanden (`SyncStatusViewModel`/`SyncStatusBar`), nur hier noch
nicht abgehakt.
**Nachtrag (Bug beim Nutzertest gefunden):** Nach jedem App-Neustart zeigte die Statusleiste
fälschlich "Kein Server konfiguriert", obwohl Sync korrekt eingerichtet war — bis zum
nächsten Auto-Sync oder Klick auf "Jetzt synchronisieren". Ursache: `SyncEngine` feuert sein
erstes `StatusChanged` bereits im eigenen Konstruktor; da `SyncEngine` und
`SyncStatusViewModel` beide DI-Singletons sind und Letzterer Ersteren erst innerhalb der
eigenen Factory aus dem Container holt, läuft dieser erste Broadcast ab, bevor
`SyncStatusViewModel` überhaupt abonniert hat — der Status ging verloren, `StatusText` blieb
beim hartcodierten Default. Fix: `SyncStatusViewModel`-Konstruktor ruft nach dem Abonnieren
zusätzlich einmal `OnStatus(engine.Status)` mit dem bereits vorhandenen aktuellen Zustand auf
(`SyncStatusViewModel.cs`). Regressionstest `SyncStatusViewModelTests.cs`.
- [x] **10.1.6** Lokale Schreibvorgänge atomar an die Outbox (`EventQueue`) anbinden — aktuell ist
das Sync-Grundgerüst registriert, die Repositories erzeugen aber noch keine Sync-Ereignisse.
@@ -1431,7 +1453,16 @@ die Docker-Verifikation unter 10.2.4 (kein Docker im Entwicklungsstand verfügba
Pfad explizit außerhalb des von der Plattform verwalteten Checkouts sind sicher.
### 10.3 Verschlüsselung
- [ ] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
- [x] **10.3.1** Schlüsselübertragung auf ein zweites Gerät (QR-Code oder Passphrase).
**Umsetzung:** Der Backend-Mechanismus (`SnapshotService`/`SyncCrypto`, Einmal-Code-Pairing)
existierte bereits, war aber an keiner Stelle im Client verdrahtet. Neuer Bereich "Gerät
koppeln" im Sync-Settings-Tab: ein Gerät erzeugt per `CreatePairingCode` einen verschlüsselten
DB-Snapshot + Code (Format `WORT-ZZ-WORT`, 24h gültig), das zweite Gerät gibt den Code über
`RedeemPairingCode` ein und übernimmt Datenbank + Sync-Schlüssel. Bestätigungsdialog vor dem
Einlösen (überschreibt die lokale Datenbank vollständig, alter Stand wird automatisch als
Backup gesichert). Kein QR-Code (nur Passphrase-Code) — für zwei eigene Geräte per Hand
abtippen ausreichend, QR-Code wäre erst für eine Companion-App relevant.
- [ ] **10.3.2** Warnung und Wiederherstellungspfad bei verlorenem Schlüssel.
- [x] **10.3.3** Prüfen, welche Daten unverschlüsselt über `PlainEventStore` laufen —
personenbezogene Daten dürfen das nicht. `Grade`/`ExamResult` (beide mit `StudentId` plus