This commit is contained in:
2026-03-29 23:47:31 +02:00
commit 216d5d2280
75 changed files with 5702 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LehrerApp.Desktop.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="avares://LehrerApp.Desktop/Views/DataTemplates.axaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,33 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.Views;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop;
public class App : Application
{
public static IServiceProvider Services { get; private set; } = null!;
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
Services = AppBootstrapper.BuildServices();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = Services.GetRequiredService<MainWindowViewModel>(),
};
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@@ -0,0 +1,181 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Services;
using LehrerApp.Data;
using LehrerApp.Data.Repositories;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using LehrerApp.Sync;
using LehrerApp.Sync.Crypto;
using LehrerApp.Sync.Models;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop;
public static class AppBootstrapper
{
public static IServiceProvider BuildServices()
{
var services = new ServiceCollection();
// ── Pfade ─────────────────────────────────────────────────────────────
var appData = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"LehrerApp");
Directory.CreateDirectory(appData);
var dbPath = Path.Combine(appData, "lehrerapp.db");
var queuePath = Path.Combine(appData, "syncqueue.db");
var keyPath = Path.Combine(appData, "sync.key");
// ── Datenbank ─────────────────────────────────────────────────────────
services.AddSingleton(_ => new LiteDbContext(dbPath));
// ── Repositories ──────────────────────────────────────────────────────
services.AddSingleton<IStudentRepository, StudentRepository>();
services.AddSingleton<IGroupRepository, GroupRepository>();
services.AddSingleton<IEnrollmentRepository, EnrollmentRepository>();
services.AddSingleton<IExamRepository, ExamRepository>();
services.AddSingleton<IExamResultRepository, ExamResultRepository>();
services.AddSingleton<IGradeRepository, GradeRepository>();
services.AddSingleton<IUnitRepository, UnitRepository>();
services.AddSingleton<ILessonRepository, LessonRepository>();
services.AddSingleton<IDocumentationRepository, DocumentationRepository>();
services.AddSingleton<IWorkTaskRepository, WorkTaskRepository>();
services.AddSingleton<ITimeEntryRepository, TimeEntryRepository>();
// ── Core Services ─────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
services.AddSingleton<SchoolYearService>();
// ── Sync ──────────────────────────────────────────────────────────────
services.AddSingleton(_ => new EventQueue(queuePath));
services.AddSingleton(sp =>
new ConflictResolver(sp.GetRequiredService<EventQueue>()));
// Schlüssel laden oder neu generieren
services.AddSingleton<byte[]>(_ =>
{
var key = SyncCrypto.LoadKey(keyPath) ?? SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
});
// EventApplier wendet gezogene Events auf LiteDB an
services.AddSingleton<EventApplier>(sp => new EventApplier(
key: sp.GetRequiredService<byte[]>(),
students: sp.GetRequiredService<IStudentRepository>(),
groups: sp.GetRequiredService<IGroupRepository>(),
enrollments: sp.GetRequiredService<IEnrollmentRepository>(),
exams: sp.GetRequiredService<IExamRepository>(),
examResults: sp.GetRequiredService<IExamResultRepository>(),
grades: sp.GetRequiredService<IGradeRepository>(),
units: sp.GetRequiredService<IUnitRepository>(),
lessons: sp.GetRequiredService<ILessonRepository>(),
docs: sp.GetRequiredService<IDocumentationRepository>(),
tasks: sp.GetRequiredService<IWorkTaskRepository>(),
timeEntries: sp.GetRequiredService<ITimeEntryRepository>()));
// SyncEngine + ReadableSnapshotService nur wenn Server konfiguriert
var serverUrl = LoadServerUrl(appData);
var deviceId = LoadOrCreateDeviceId(appData);
services.AddSingleton<SyncEngine?>(sp =>
{
if (string.IsNullOrEmpty(serverUrl)) return null;
var http = BuildHttpClient(serverUrl, appData);
var config = new SyncConfig
{
ServerUrl = serverUrl,
DeviceId = deviceId,
DeviceType = DeviceType.Desktop,
AutoSyncIntervalMinutes = 5,
};
return new SyncEngine(
sp.GetRequiredService<EventQueue>(),
sp.GetRequiredService<ConflictResolver>(),
http,
config);
});
services.AddSingleton<ReadableSnapshotService?>(sp =>
{
if (string.IsNullOrEmpty(serverUrl)) return null;
return new ReadableSnapshotService(
http: BuildHttpClient(serverUrl, appData),
students: sp.GetRequiredService<IStudentRepository>(),
groups: sp.GetRequiredService<IGroupRepository>(),
enrollments: sp.GetRequiredService<IEnrollmentRepository>(),
exams: sp.GetRequiredService<IExamRepository>(),
examResults: sp.GetRequiredService<IExamResultRepository>(),
grades: sp.GetRequiredService<IGradeRepository>(),
units: sp.GetRequiredService<IUnitRepository>(),
lessons: sp.GetRequiredService<ILessonRepository>(),
tasks: sp.GetRequiredService<IWorkTaskRepository>(),
schoolYear: sp.GetRequiredService<SchoolYearService>(),
deviceId: deviceId);
});
services.AddSingleton<SnapshotService?>(sp =>
{
if (string.IsNullOrEmpty(serverUrl)) return null;
return new SnapshotService(
http: BuildHttpClient(serverUrl, appData),
db: sp.GetRequiredService<LiteDbContext>(),
syncKey: sp.GetRequiredService<byte[]>(),
deviceType: DeviceType.Desktop,
dbPath: dbPath,
keyPath: keyPath);
});
// ── ViewModels ────────────────────────────────────────────────────────
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<DashboardViewModel>();
services.AddTransient<GroupListViewModel>();
services.AddTransient<GroupDetailViewModel>();
services.AddTransient<StudentListViewModel>();
services.AddTransient<StudentDetailViewModel>();
services.AddSingleton<SyncStatusViewModel>();
services.AddTransient<DevicePairingViewModel>(sp =>
new DevicePairingViewModel(
sp.GetRequiredService<SnapshotService?>()
?? throw new InvalidOperationException(
"Kein Server konfiguriert.")));
return services.BuildServiceProvider();
}
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private static HttpClient BuildHttpClient(string serverUrl, string appData)
{
var http = new HttpClient { BaseAddress = new Uri(serverUrl) };
var tokenPath = Path.Combine(appData, "auth.token");
if (File.Exists(tokenPath))
{
var token = File.ReadAllText(tokenPath).Trim();
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", token);
}
return http;
}
private static string LoadServerUrl(string appData)
{
var path = Path.Combine(appData, "server.txt");
return File.Exists(path) ? File.ReadAllText(path).Trim() : "";
}
private static string LoadOrCreateDeviceId(string appData)
{
var path = Path.Combine(appData, "device.id");
if (File.Exists(path)) return File.ReadAllText(path).Trim();
var id = Guid.NewGuid().ToString();
File.WriteAllText(path, id);
return id;
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LehrerApp.Core\LehrerApp.Core.csproj" />
<ProjectReference Include="..\LehrerApp.Data\LehrerApp.Data.csproj" />
<ProjectReference Include="..\LehrerApp.Sync\LehrerApp.Sync.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Avalonia 11.3.x aktuell stabiler Branch -->
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<!-- ReactiveUI aus Avalonia 11.3 entfernt CommunityToolkit.Mvvm reicht -->
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
using Avalonia;
namespace LehrerApp.Desktop;
class Program
{
[STAThread]
public static void Main(string[] args) =>
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@@ -0,0 +1,136 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels;
public partial class DashboardViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly ILessonRepository _lessons;
private readonly IWorkTaskRepository _tasks;
private readonly SchoolYearService _schoolYearService;
[ObservableProperty] private string _greeting = "";
[ObservableProperty] private string _currentDate = "";
[ObservableProperty] private string _currentSchoolYear = "";
public ObservableCollection<LessonItem> TodaysLessons { get; } = [];
public ObservableCollection<TaskItem> OpenTasks { get; } = [];
public ObservableCollection<GroupChip> CurrentGroups { get; } = [];
public DashboardViewModel(
IGroupRepository groups,
ILessonRepository lessons,
IWorkTaskRepository tasks,
SchoolYearService schoolYearService)
{
_groups = groups;
_lessons = lessons;
_tasks = tasks;
_schoolYearService = schoolYearService;
Load();
}
private void Load()
{
var now = DateTime.Now;
var today = DateOnly.FromDateTime(now);
CurrentDate = now.ToString("dddd, d. MMMM yyyy",
new System.Globalization.CultureInfo("de-DE"));
CurrentSchoolYear = _schoolYearService.CurrentSchoolYear();
Greeting = now.Hour < 12 ? "Guten Morgen" :
now.Hour < 18 ? "Guten Tag" : "Guten Abend";
// Heutige Stunden
TodaysLessons.Clear();
var schoolYear = _schoolYearService.CurrentSchoolYear();
var allGroups = _groups.GetBySchoolYear(schoolYear)
.ToDictionary(g => g.Id);
var todayLessons = allGroups.Keys
.SelectMany(gid => _lessons.GetByGroupAndDate(gid, today))
.OrderBy(l => l.LessonNumber)
.ToList();
foreach (var lesson in todayLessons)
{
if (!allGroups.TryGetValue(lesson.GroupId, out var group)) continue;
TodaysLessons.Add(new LessonItem
{
GroupName = group.Name,
Topic = lesson.Topic,
Status = lesson.Status,
});
}
// Offene Aufgaben (mit Fälligkeit)
OpenTasks.Clear();
var openTasks = _tasks.GetByStatus(WorkTaskStatus.Open)
.Concat(_tasks.GetByStatus(WorkTaskStatus.InProgress))
.OrderBy(t => t.DueDate ?? DateOnly.MaxValue)
.Take(5);
foreach (var task in openTasks)
{
var isOverdue = task.DueDate.HasValue && task.DueDate < today;
var isDueSoon = task.DueDate.HasValue &&
task.DueDate >= today &&
task.DueDate <= today.AddDays(3);
OpenTasks.Add(new TaskItem
{
Title = task.Title,
DueDate = task.DueDate?.ToString("dd.MM.") ?? "",
IsOverdue = isOverdue,
IsDueSoon = isDueSoon,
Status = task.Status,
});
}
// Aktuelle Lerngruppen als Chips
CurrentGroups.Clear();
foreach (var group in _groups.GetBySchoolYear(schoolYear)
.OrderBy(g => g.Name))
{
CurrentGroups.Add(new GroupChip
{
GroupId = group.Id,
Name = group.Name,
Subject = group.Subject ?? "",
});
}
}
[RelayCommand]
private void Refresh() => Load();
}
// Anzeigemodelle für Dashboard-Widgets
public class LessonItem
{
public string GroupName { get; set; } = "";
public string Topic { get; set; } = "";
public LessonStatus Status { get; set; }
}
public class TaskItem
{
public string Title { get; set; } = "";
public string DueDate { get; set; } = "";
public bool IsOverdue { get; set; }
public bool IsDueSoon { get; set; }
public WorkTaskStatus Status { get; set; }
}
public class GroupChip
{
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public string Subject { get; set; } = "";
}

View File

@@ -0,0 +1,158 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Data;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
namespace LehrerApp.Desktop.ViewModels;
/// <summary>
/// Steuert den Dialog zum Einrichten eines neuen Geräts.
///
/// Zwei Modi:
/// Sender → "Neues Gerät hinzufügen" erzeugt Snapshot und zeigt Code
/// Empfänger → "Mit bestehendem Account verbinden" Code eingeben, Restore
/// </summary>
public partial class DevicePairingViewModel : ObservableObject
{
private readonly SnapshotService _snapshotService;
// ── Gemeinsamer State ─────────────────────────────────────────────────────
[ObservableProperty] private PairingMode _mode = PairingMode.SelectMode;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private int _progressPercent;
[ObservableProperty] private bool _isError;
[ObservableProperty] private bool _isComplete;
// ── Sender-State ─────────────────────────────────────────────────────────
[ObservableProperty] private string _generatedCode = "";
[ObservableProperty] private DateTime _codeExpiresAt;
[ObservableProperty] private string _codeExpiresText = "";
// ── Empfänger-State ───────────────────────────────────────────────────────
[ObservableProperty] private string _inputCode = "";
public DevicePairingViewModel(SnapshotService snapshotService)
{
_snapshotService = snapshotService;
_snapshotService.ProgressChanged += OnProgress;
}
// ── Navigation zwischen Modi ──────────────────────────────────────────────
[RelayCommand]
private void SelectSender() => Mode = PairingMode.Sender;
[RelayCommand]
private void SelectReceiver() => Mode = PairingMode.Receiver;
[RelayCommand]
private void Back()
{
Mode = PairingMode.SelectMode;
Reset();
}
// ── Sender: Snapshot erstellen ────────────────────────────────────────────
[RelayCommand(CanExecute = nameof(CanCreateSnapshot))]
private async Task CreateSnapshotAsync()
{
IsBusy = true;
IsError = false;
IsComplete = false;
try
{
var result = await _snapshotService.CreateAndUploadAsync();
GeneratedCode = result.Code;
CodeExpiresAt = result.ExpiresAt;
CodeExpiresText = $"Gültig bis {result.ExpiresAt:HH:mm} Uhr " +
$"({result.ExpiresAt:dd.MM.yyyy})";
Mode = PairingMode.SenderShowCode;
}
catch (Exception ex)
{
IsError = true;
StatusMessage = $"Fehler: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private bool CanCreateSnapshot() => !IsBusy;
// ── Empfänger: Snapshot wiederherstellen ──────────────────────────────────
[RelayCommand(CanExecute = nameof(CanRestore))]
private async Task RestoreAsync(string targetDbPath)
{
IsBusy = true;
IsError = false;
IsComplete = false;
try
{
// Schlüssel wird aus dem Code extrahiert und lokal gespeichert
// → App-Neustart lädt ihn automatisch
await _snapshotService.RestoreFromCodeAsync(
InputCode.Trim(), targetDbPath);
IsComplete = true;
StatusMessage = "Erfolgreich! Bitte die App neu starten.";
Mode = PairingMode.ReceiverDone;
}
catch (SnapshotNotFoundException ex)
{
IsError = true;
StatusMessage = ex.Message;
}
catch (Exception ex)
{
IsError = true;
StatusMessage = $"Fehler beim Wiederherstellen: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private bool CanRestore() =>
!IsBusy && InputCode.Trim().Length >= 5;
// ── Hilfsmethoden ─────────────────────────────────────────────────────────
private void OnProgress(SnapshotProgress progress)
{
StatusMessage = progress.Message;
ProgressPercent = progress.PercentComplete;
}
private void Reset()
{
StatusMessage = "";
ProgressPercent = 0;
IsError = false;
IsComplete = false;
GeneratedCode = "";
InputCode = "";
IsBusy = false;
}
}
public enum PairingMode
{
SelectMode, // Startbildschirm: Sender oder Empfänger wählen
Sender, // Sender bereit, Snapshot erstellen
SenderShowCode, // Code wird angezeigt
Receiver, // Code-Eingabe
ReceiverDone, // Erfolgreich wiederhergestellt
}

View File

@@ -0,0 +1,90 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.ViewModels.Groups;
public partial class AddGroupDialogViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly SchoolYearService _schoolYearService;
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _subject = "";
[ObservableProperty] private GroupType _type = GroupType.Course;
[ObservableProperty] private int _gradeLevel = 10;
[ObservableProperty] private GradingSystem _gradingSystem = GradingSystem.Grades1To6;
[ObservableProperty] private int? _hoursPerWeek;
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _validationMessage = "";
public List<string> SchoolYears { get; }
public List<GroupTypeItem> GroupTypes { get; } =
[
new(GroupType.Course, "Fachkurs"),
new(GroupType.Class, "Klassenverband"),
];
public List<GradingSystemItem> GradingSystems { get; } =
[
new(GradingSystem.Grades1To6, "Noten 16 (Sek I)"),
new(GradingSystem.Points0To15, "Punkte 015 (Oberstufe)"),
];
// Wird vom Dialog aufgerufen wenn OK geklickt wurde
public LearningGroup? Result { get; private set; }
public AddGroupDialogViewModel(
IGroupRepository groups,
SchoolYearService schoolYearService)
{
_groups = groups;
_schoolYearService = schoolYearService;
SchoolYears = schoolYearService.RecentSchoolYears(3);
SelectedSchoolYear = schoolYearService.CurrentSchoolYear();
// Automatisch Notensystem vorschlagen wenn Klassenstufe sich ändert
PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(GradeLevel))
GradingSystem = GradeLevel >= 11
? GradingSystem.Points0To15
: GradingSystem.Grades1To6;
};
}
[RelayCommand]
private bool Save()
{
// Validierung
if (string.IsNullOrWhiteSpace(Name))
{
ValidationMessage = "Bitte einen Namen eingeben.";
return false;
}
if (GradeLevel is < 1 or > 13)
{
ValidationMessage = "Klassenstufe muss zwischen 1 und 13 liegen.";
return false;
}
Result = new LearningGroup
{
Name = Name.Trim(),
Subject = string.IsNullOrWhiteSpace(Subject) ? null : Subject.Trim(),
Type = Type,
GradeLevel = GradeLevel,
GradingSystem = GradingSystem,
SchoolYear = SelectedSchoolYear,
HoursPerWeek = HoursPerWeek,
};
_groups.Save(Result);
ValidationMessage = "";
return true;
}
}
public record GroupTypeItem(GroupType Value, string Label);
public record GradingSystemItem(GradingSystem Value, string Label);

View File

@@ -0,0 +1,208 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Groups;
// ── Gruppenliste ──────────────────────────────────────────────────────────────
public partial class GroupListViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly SchoolYearService _schoolYearService;
[ObservableProperty] private string _selectedSchoolYear = "";
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private GroupListItem? _selectedGroup;
public ObservableCollection<string> SchoolYears { get; } = [];
public ObservableCollection<GroupListItem> Groups { get; } = [];
public GroupListViewModel(
IGroupRepository groups,
SchoolYearService schoolYearService)
{
_groups = groups;
_schoolYearService = schoolYearService;
foreach (var y in schoolYearService.RecentSchoolYears())
SchoolYears.Add(y);
SelectedSchoolYear = schoolYearService.CurrentSchoolYear();
LoadGroups();
}
partial void OnSelectedSchoolYearChanged(string value) => LoadGroups();
partial void OnSearchTextChanged(string value) => LoadGroups();
private void LoadGroups()
{
Groups.Clear();
var all = _groups.GetBySchoolYear(SelectedSchoolYear);
var filtered = string.IsNullOrWhiteSpace(SearchText)
? all
: all.Where(g =>
g.Name.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ||
(g.Subject?.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ?? false));
foreach (var g in filtered)
Groups.Add(new GroupListItem(g));
}
[RelayCommand]
private void AddGroup()
{
// TODO: Dialog öffnen
}
[RelayCommand]
private void Refresh() => LoadGroups();
}
public class GroupListItem
{
public Guid Id { get; }
public string Name { get; }
public string Subject { get; }
public string SchoolYear { get; }
public int GradeLevel { get; }
public string TypeLabel { get; }
public string GradingLabel { get; }
public GroupListItem(LearningGroup g)
{
Id = g.Id;
Name = g.Name;
Subject = g.Subject ?? "";
SchoolYear = g.SchoolYear;
GradeLevel = g.GradeLevel;
TypeLabel = g.Type == GroupType.Class ? "Klasse" : "Kurs";
GradingLabel = g.GradingSystem == GradingSystem.Grades1To6
? "Noten 16"
: "Punkte 015";
}
}
// ── Gruppendetail (Tab-Navigation) ────────────────────────────────────────────
public partial class GroupDetailViewModel : ObservableObject
{
private readonly IGroupRepository _groups;
private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments;
private readonly IExamRepository _exams;
private readonly SchoolYearService _schoolYearService;
[ObservableProperty] private LearningGroup? _group;
[ObservableProperty] private string _groupTitle = "";
[ObservableProperty] private int _studentCount;
[ObservableProperty] private GroupTab _activeTab = GroupTab.Overview;
// Sub-ViewModels für Tabs lazy geladen
public ObservableCollection<StudentSummary> Students { get; } = [];
public ObservableCollection<ExamSummary> Exams { get; } = [];
public GroupDetailViewModel(
IGroupRepository groups,
IStudentRepository students,
IEnrollmentRepository enrollments,
IExamRepository exams,
SchoolYearService schoolYearService)
{
_groups = groups;
_students = students;
_enrollments = enrollments;
_exams = exams;
_schoolYearService = schoolYearService;
}
public void LoadGroup(Guid groupId)
{
Group = _groups.GetById(groupId);
if (Group is null) return;
GroupTitle = $"{Group.Name} · {Group.SchoolYear}";
LoadStudents();
LoadExams();
}
[RelayCommand]
private void SwitchTab(GroupTab tab)
{
ActiveTab = tab;
}
private void LoadStudents()
{
if (Group is null) return;
Students.Clear();
var enrolled = _students.GetByGroup(Group.Id, Group.SchoolYear);
StudentCount = enrolled.Count;
foreach (var s in enrolled)
Students.Add(new StudentSummary(s));
}
private void LoadExams()
{
if (Group is null) return;
Exams.Clear();
foreach (var e in _exams.GetByGroup(Group.Id))
Exams.Add(new ExamSummary(e));
}
[RelayCommand]
private void AddStudent()
{
// TODO: Schüler-Auswahl-Dialog
}
[RelayCommand]
private void AddExam()
{
// TODO: Klausur-Erstellen-Dialog
}
}
public enum GroupTab { Overview, Students, Exams, Grades, Planner, Documentation }
public class StudentSummary
{
public Guid Id { get; }
public string FullName { get; }
public StudentSummary(Core.Models.Student s)
{
Id = s.Id;
FullName = s.FullName;
}
}
public class ExamSummary
{
public Guid Id { get; }
public string Title { get; }
public string Date { get; }
public string Status { get; }
public ExamSummary(Core.Models.Exam e)
{
Id = e.Id;
Title = e.Title;
Date = e.Date.ToString("dd.MM.yyyy");
Status = e.Status switch
{
ExamStatus.Planned => "Geplant",
ExamStatus.Conducted => "Durchgeführt",
ExamStatus.Graded => "Korrigiert",
ExamStatus.Returned => "Zurückgegeben",
_ => "",
};
}
}

View File

@@ -0,0 +1,87 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.ViewModels.Groups;
using LehrerApp.Desktop.ViewModels.Students;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.ViewModels;
public partial class MainWindowViewModel : ObservableObject
{
private readonly IServiceProvider _services;
[ObservableProperty]
private ObservableObject? _currentPage;
[ObservableProperty]
private NavItem _activeNavItem = NavItem.Dashboard;
// ── Fehlende Property die im MainWindow.axaml referenziert wird ───────────
[ObservableProperty]
private string _currentSchoolYear = "";
public MainWindowViewModel(
IServiceProvider services,
DashboardViewModel dashboard,
SchoolYearService schoolYearService)
{
_services = services;
CurrentPage = dashboard;
CurrentSchoolYear = schoolYearService.CurrentSchoolYear();
}
// ── Navigation ────────────────────────────────────────────────────────────
[RelayCommand]
private void NavigateTo(NavItem item)
{
ActiveNavItem = item;
CurrentPage = item switch
{
NavItem.Dashboard => _services.GetRequiredService<DashboardViewModel>(),
NavItem.Groups => _services.GetRequiredService<GroupListViewModel>(),
NavItem.Students => _services.GetRequiredService<StudentListViewModel>(),
NavItem.Exams => CreatePlaceholder("Klausuren"),
NavItem.Planner => CreatePlaceholder("Unterrichtsplanung"),
NavItem.Workload => CreatePlaceholder("Arbeitszeit"),
NavItem.Settings => CreatePlaceholder("Einstellungen"),
_ => CurrentPage,
};
}
public void NavigateToGroup(Guid groupId)
{
ActiveNavItem = NavItem.Groups;
var vm = _services.GetRequiredService<GroupDetailViewModel>();
vm.LoadGroup(groupId);
CurrentPage = vm;
}
public void NavigateToStudent(Guid studentId)
{
ActiveNavItem = NavItem.Students;
var vm = _services.GetRequiredService<StudentDetailViewModel>();
vm.LoadStudent(studentId);
CurrentPage = vm;
}
private static PlaceholderViewModel CreatePlaceholder(string title) =>
new() { Title = title };
}
public enum NavItem
{
Dashboard,
Groups,
Students,
Exams,
Planner,
Workload,
Settings,
}
public partial class PlaceholderViewModel : ObservableObject
{
[ObservableProperty] private string _title = "";
}

View File

@@ -0,0 +1,223 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using System.Collections.ObjectModel;
namespace LehrerApp.Desktop.ViewModels.Students;
// ── Schülerliste ──────────────────────────────────────────────────────────────
public partial class StudentListViewModel : ObservableObject
{
private readonly IStudentRepository _students;
[ObservableProperty] private string _searchText = "";
[ObservableProperty] private bool _showInactive;
[ObservableProperty] private StudentListItem? _selectedStudent;
public ObservableCollection<StudentListItem> Students { get; } = [];
public StudentListViewModel(IStudentRepository students)
{
_students = students;
LoadStudents();
}
partial void OnSearchTextChanged(string value) => LoadStudents();
partial void OnShowInactiveChanged(bool value) => LoadStudents();
private void LoadStudents()
{
Students.Clear();
var all = _students.GetAll(includeInactive: ShowInactive);
var filtered = string.IsNullOrWhiteSpace(SearchText)
? all
: all.Where(s =>
s.LastName.Contains(SearchText, StringComparison.OrdinalIgnoreCase) ||
s.FirstName.Contains(SearchText, StringComparison.OrdinalIgnoreCase));
foreach (var s in filtered)
Students.Add(new StudentListItem(s));
}
[RelayCommand]
private void AddStudent()
{
// TODO: Neuer-Schüler-Dialog
}
[RelayCommand]
private void Refresh() => LoadStudents();
}
public class StudentListItem
{
public Guid Id { get; }
public string FullName { get; }
public string DateOfBirth { get; }
public bool IsActive { get; }
public StudentListItem(Student s)
{
Id = s.Id;
FullName = s.FullName;
DateOfBirth = s.DateOfBirth?.ToString("dd.MM.yyyy") ?? "";
IsActive = s.IsActive;
}
}
// ── Schülerdetail ─────────────────────────────────────────────────────────────
public partial class StudentDetailViewModel : ObservableObject
{
private readonly IStudentRepository _students;
private readonly IEnrollmentRepository _enrollments;
private readonly IGroupRepository _groups;
private readonly IGradeRepository _grades;
private readonly IDocumentationRepository _docs;
[ObservableProperty] private Student? _student;
[ObservableProperty] private string _studentTitle = "";
[ObservableProperty] private StudentTab _activeTab = StudentTab.Overview;
[ObservableProperty] private bool _isEditing;
// Bearbeitbare Felder
[ObservableProperty] private string _editFirstName = "";
[ObservableProperty] private string _editLastName = "";
[ObservableProperty] private string _editNotes = "";
public ObservableCollection<EnrollmentEntry> Enrollments { get; } = [];
public ObservableCollection<GradeEntry> Grades { get; } = [];
public ObservableCollection<DocEntry> Documentation { get; } = [];
public StudentDetailViewModel(
IStudentRepository students,
IEnrollmentRepository enrollments,
IGroupRepository groups,
IGradeRepository grades,
IDocumentationRepository docs)
{
_students = students;
_enrollments = enrollments;
_groups = groups;
_grades = grades;
_docs = docs;
}
public void LoadStudent(Guid studentId)
{
Student = _students.GetById(studentId);
if (Student is null) return;
StudentTitle = Student.FullName;
EditFirstName = Student.FirstName;
EditLastName = Student.LastName;
EditNotes = Student.Notes ?? "";
LoadEnrollments();
LoadDocs();
}
[RelayCommand]
private void SwitchTab(StudentTab tab) => ActiveTab = tab;
[RelayCommand]
private void StartEdit() => IsEditing = true;
[RelayCommand]
private void CancelEdit()
{
if (Student is null) return;
EditFirstName = Student.FirstName;
EditLastName = Student.LastName;
EditNotes = Student.Notes ?? "";
IsEditing = false;
}
[RelayCommand]
private void SaveEdit()
{
if (Student is null) return;
Student.FirstName = EditFirstName;
Student.LastName = EditLastName;
Student.Notes = string.IsNullOrWhiteSpace(EditNotes) ? null : EditNotes;
_students.Save(Student);
StudentTitle = Student.FullName;
IsEditing = false;
}
private void LoadEnrollments()
{
if (Student is null) return;
Enrollments.Clear();
var enrollments = _enrollments.GetByStudent(Student.Id);
var groupIds = enrollments.Select(e => e.GroupId).Distinct();
var groupMap = groupIds
.Select(id => _groups.GetById(id))
.Where(g => g is not null)
.ToDictionary(g => g!.Id);
foreach (var e in enrollments.OrderByDescending(e => e.SchoolYear))
{
if (!groupMap.TryGetValue(e.GroupId, out var group)) continue;
Enrollments.Add(new EnrollmentEntry
{
SchoolYear = e.SchoolYear,
GroupName = group.Name,
Subject = group.Subject ?? "",
});
}
}
private void LoadDocs()
{
if (Student is null) return;
Documentation.Clear();
foreach (var doc in _docs.GetByStudent(Student.Id))
{
Documentation.Add(new DocEntry
{
Date = doc.Date.ToString("dd.MM.yyyy"),
Title = doc.Title,
TypeLabel = doc.Type switch
{
DocumentationType.Conversation => "Gespräch",
DocumentationType.Incident => "Vorkommnis",
DocumentationType.SupportPlan => "Förderplan",
DocumentationType.Absence => "Fehlzeit",
_ => "",
},
IsConfidential = doc.IsConfidential,
});
}
}
}
public enum StudentTab { Overview, Grades, Documentation }
public class EnrollmentEntry
{
public string SchoolYear { get; set; } = "";
public string GroupName { get; set; } = "";
public string Subject { get; set; } = "";
}
public class GradeEntry
{
public string Date { get; set; } = "";
public string Value { get; set; } = "";
public string Category { get; set; } = "";
public string GroupName { get; set; } = "";
}
public class DocEntry
{
public string Date { get; set; } = "";
public string Title { get; set; } = "";
public string TypeLabel { get; set; } = "";
public bool IsConfidential { get; set; }
}

View File

@@ -0,0 +1,63 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Sync;
using LehrerApp.Sync.Models;
namespace LehrerApp.Desktop.ViewModels;
public partial class SyncStatusViewModel : ObservableObject
{
private readonly SyncEngine? _engine;
[ObservableProperty] private string _statusText = "Kein Server konfiguriert";
[ObservableProperty] private string _lastSyncText = "";
[ObservableProperty] private int _pendingCount;
[ObservableProperty] private int _conflictCount;
[ObservableProperty] private bool _isSyncing;
[ObservableProperty] private bool _hasConflicts;
[ObservableProperty] private bool _isServerConfigured;
public SyncStatusViewModel(SyncEngine? engine)
{
_engine = engine;
IsServerConfigured = engine is not null;
if (_engine is not null)
{
_engine.StatusChanged += OnStatusChanged;
OnStatusChanged(_engine.Status);
}
}
private void OnStatusChanged(SyncStatus status)
{
IsSyncing = status.State == SyncState.Syncing;
HasConflicts = status.ConflictCount > 0;
PendingCount = status.PendingEvents;
ConflictCount = status.ConflictCount;
StatusText = status.State switch
{
SyncState.Idle => PendingCount > 0
? $"{PendingCount} ausstehend"
: "Synchronisiert",
SyncState.Syncing => "Synchronisiere...",
SyncState.Offline => "Offline",
SyncState.Error => $"Fehler: {status.ErrorMessage}",
_ => "",
};
LastSyncText = status.LastSyncAt.HasValue
? $"Zuletzt: {status.LastSyncAt:HH:mm}"
: "Noch nie synchronisiert";
}
[RelayCommand(CanExecute = nameof(CanSyncNow))]
private async Task SyncNow()
{
if (_engine is null) return;
await _engine.SyncNowAsync(isAutomatic: false);
}
private bool CanSyncNow() => _engine is not null && !IsSyncing;
}

View File

@@ -0,0 +1,115 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.DashboardView"
x:DataType="vm:DashboardViewModel">
<ScrollViewer Padding="24">
<StackPanel Spacing="20">
<!-- Header -->
<StackPanel>
<TextBlock Text="{Binding Greeting}" FontSize="14" Opacity="0.6" />
<TextBlock Text="{Binding CurrentDate}" FontSize="24" FontWeight="SemiBold" />
</StackPanel>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" >
<!-- Heutige Stunden -->
<Border Grid.Column="0" Grid.Row="0"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="HEUTE" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,12"/>
<ItemsControl ItemsSource="{Binding TodaysLessons}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LessonItem">
<Border Padding="0,6" BorderThickness="0,0,0,1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="4" CornerRadius="2"
Background="{DynamicResource SystemAccentColor}"
Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding GroupName}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Topic}"
FontSize="12" Opacity="0.7"
TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine Stunden heute" FontSize="13" Opacity="0.5"
IsVisible="{Binding !TodaysLessons.Count}"/>
</StackPanel>
</Border>
<!-- Offene Aufgaben -->
<Border Grid.Column="1" Grid.Row="0"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16" Margin="8,0,0,8">
<StackPanel>
<TextBlock Text="OFFENE AUFGABEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,12"/>
<ItemsControl ItemsSource="{Binding OpenTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskItem">
<Grid ColumnDefinitions="*,Auto" Margin="0,4">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding Title}"
FontSize="13" TextTrimming="CharacterEllipsis"/>
</StackPanel>
<TextBlock Grid.Column="1" Text="{Binding DueDate}"
FontSize="12" Opacity="0.6" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine offenen Aufgaben" FontSize="13" Opacity="0.5"
IsVisible="{Binding !OpenTasks.Count}"/>
</StackPanel>
</Border>
<!-- Meine Lerngruppen -->
<Border Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="8" Padding="16">
<StackPanel>
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,12"/>
<ItemsControl ItemsSource="{Binding CurrentGroups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:GroupChip">
<Border Background="{DynamicResource SystemAccentColorLight2}"
CornerRadius="6" Padding="12,6" Margin="0,0,8,8">
<StackPanel>
<TextBlock Text="{Binding Name}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding Subject}"
FontSize="11" Opacity="0.7"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Noch keine Lerngruppen für dieses Schuljahr"
FontSize="13" Opacity="0.5"
IsVisible="{Binding !CurrentGroups.Count}"/>
</StackPanel>
</Border>
</Grid>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,38 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
xmlns:vmg="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
xmlns:vms="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
xmlns:vg="clr-namespace:LehrerApp.Desktop.Views.Groups"
xmlns:vs="clr-namespace:LehrerApp.Desktop.Views.Students"
xmlns:v="clr-namespace:LehrerApp.Desktop.Views">
<!-- Dashboard -->
<DataTemplate DataType="vm:DashboardViewModel">
<v:DashboardView/>
</DataTemplate>
<!-- Platzhalter -->
<DataTemplate DataType="vm:PlaceholderViewModel">
<v:PlaceholderView/>
</DataTemplate>
<!-- Gruppen -->
<DataTemplate DataType="vmg:GroupListViewModel">
<vg:GroupListView/>
</DataTemplate>
<DataTemplate DataType="vmg:GroupDetailViewModel">
<vg:GroupDetailView/>
</DataTemplate>
<!-- Schüler -->
<DataTemplate DataType="vms:StudentListViewModel">
<vs:StudentListView/>
</DataTemplate>
<DataTemplate DataType="vms:StudentDetailViewModel">
<vs:StudentDetailView/>
</DataTemplate>
</ResourceDictionary>

View File

@@ -0,0 +1,185 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.DevicePairingDialog"
x:DataType="vm:DevicePairingViewModel"
Title="Gerät einrichten"
Width="480" Height="420"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid Margin="28">
<!-- ── Modus: Auswahl ──────────────────────────────────────────────────── -->
<StackPanel Spacing="16"
IsVisible="{Binding Mode,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:PairingMode.SelectMode}}">
<TextBlock Text="Gerät einrichten"
FontSize="20" FontWeight="SemiBold"/>
<TextBlock TextWrapping="Wrap" Opacity="0.7">
Möchtest du ein neues Gerät mit deinem bestehenden Datenbestand
verbinden, oder dieses Gerät als erstes Gerät einrichten?
</TextBlock>
<Button Content="Daten auf dieses Gerät übertragen"
HorizontalAlignment="Stretch"
Command="{Binding SelectReceiverCommand}"
Padding="12,10"/>
<Button Content="Von diesem Gerät Daten übertragen"
HorizontalAlignment="Stretch"
Command="{Binding SelectSenderCommand}"
Padding="12,10"/>
</StackPanel>
<!-- ── Modus: Sender ───────────────────────────────────────────────────── -->
<StackPanel Spacing="16"
IsVisible="{Binding Mode,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:PairingMode.Sender}}">
<TextBlock Text="Daten übertragen"
FontSize="20" FontWeight="SemiBold"/>
<TextBlock TextWrapping="Wrap" Opacity="0.7">
Es wird ein verschlüsselter Snapshot deiner lokalen Datenbank erstellt
und kurzzeitig auf dem Server hinterlegt. Du erhältst einen Einmal-Code
der 24 Stunden gültig ist.
</TextBlock>
<!-- Fortschritt -->
<ProgressBar Value="{Binding ProgressPercent}"
Maximum="100" Height="6" CornerRadius="3"
IsVisible="{Binding IsBusy}"/>
<TextBlock Text="{Binding StatusMessage}"
FontSize="12" Opacity="0.7"
IsVisible="{Binding IsBusy}"/>
<!-- Fehler -->
<Border Background="#22FF0000" CornerRadius="6" Padding="12"
IsVisible="{Binding IsError}">
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap" Foreground="Red"/>
</Border>
<Grid ColumnDefinitions="*,8,Auto" Margin="0,8,0,0">
<Button Grid.Column="0"
Content="← Zurück"
Command="{Binding BackCommand}"
IsEnabled="{Binding !IsBusy}"/>
<Button Grid.Column="2"
Content="Snapshot erstellen"
Command="{Binding CreateSnapshotCommand}"
IsEnabled="{Binding !IsBusy}"
Padding="16,8"/>
</Grid>
</StackPanel>
<!-- ── Modus: Sender zeigt Code ────────────────────────────────────────── -->
<StackPanel Spacing="16"
IsVisible="{Binding Mode,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:PairingMode.SenderShowCode}}">
<TextBlock Text="Code bereit" FontSize="20" FontWeight="SemiBold"/>
<TextBlock TextWrapping="Wrap" Opacity="0.7">
Gib diesen Code auf dem Zielgerät ein. Der Code ist einmalig verwendbar.
</TextBlock>
<!-- Der Code prominent anzeigen -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="10" Padding="24,20">
<StackPanel HorizontalAlignment="Center" Spacing="8">
<TextBlock Text="{Binding GeneratedCode}"
FontSize="32" FontWeight="Bold"
FontFamily="Cascadia Code, Consolas, monospace"
HorizontalAlignment="Center"
LetterSpacing="4"/>
<TextBlock Text="{Binding CodeExpiresText}"
FontSize="12" Opacity="0.5"
HorizontalAlignment="Center"/>
</StackPanel>
</Border>
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.6">
Der Code wird nach dem ersten Abruf automatisch ungültig.
Der Server speichert nur verschlüsselte Daten kein Klartext.
</TextBlock>
<Button Content="Fertig" HorizontalAlignment="Right"
Click="OnClose" Padding="16,8"/>
</StackPanel>
<!-- ── Modus: Empfänger Code eingeben ────────────────────────────────── -->
<StackPanel Spacing="16"
IsVisible="{Binding Mode,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:PairingMode.Receiver}}">
<TextBlock Text="Code eingeben"
FontSize="20" FontWeight="SemiBold"/>
<TextBlock TextWrapping="Wrap" Opacity="0.7">
Gib den Code ein der auf dem Quellgerät angezeigt wird.
Format: WORT-ZZ-WORT (z.B. TIGER-42-BLAU)
</TextBlock>
<TextBox Text="{Binding InputCode}"
Watermark="TIGER-42-BLAU"
FontSize="20"
FontFamily="Cascadia Code, Consolas, monospace"
TextAlignment="Center"
CharacterCasing="Upper"/>
<!-- Fortschritt -->
<ProgressBar Value="{Binding ProgressPercent}"
Maximum="100" Height="6" CornerRadius="3"
IsVisible="{Binding IsBusy}"/>
<TextBlock Text="{Binding StatusMessage}"
FontSize="12" Opacity="0.7"
IsVisible="{Binding IsBusy}"/>
<!-- Fehler -->
<Border Background="#22FF0000" CornerRadius="6" Padding="12"
IsVisible="{Binding IsError}">
<TextBlock Text="{Binding StatusMessage}"
TextWrapping="Wrap" Foreground="Red"/>
</Border>
<Grid ColumnDefinitions="*,8,Auto">
<Button Grid.Column="0" Content="← Zurück"
Command="{Binding BackCommand}"
IsEnabled="{Binding !IsBusy}"/>
<Button Grid.Column="2"
Content="Wiederherstellen"
Click="OnRestore"
IsEnabled="{Binding !IsBusy}"
Padding="16,8"/>
</Grid>
</StackPanel>
<!-- ── Modus: Empfänger Fertig ───────────────────────────────────────── -->
<StackPanel Spacing="16" HorizontalAlignment="Center"
VerticalAlignment="Center"
IsVisible="{Binding Mode,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:PairingMode.ReceiverDone}}">
<TextBlock Text="✓" FontSize="48" HorizontalAlignment="Center"
Foreground="Green"/>
<TextBlock Text="Erfolgreich übertragen!"
FontSize="20" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBlock Text="{Binding StatusMessage}"
Opacity="0.7" HorizontalAlignment="Center"
TextWrapping="Wrap" TextAlignment="Center"/>
<Button Content="App neu starten"
HorizontalAlignment="Center"
Click="OnRestartRequested"
Padding="16,8" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,34 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels;
namespace LehrerApp.Desktop.Views;
public partial class DevicePairingDialog : Window
{
// Wird von außen gesetzt Pfad zur lokalen DB
public string TargetDbPath { get; set; } = "";
// Signal: App-Neustart nötig
public bool RestartRequested { get; private set; }
public DevicePairingDialog()
{
InitializeComponent();
}
private void OnClose(object? sender, RoutedEventArgs e) =>
Close();
private async void OnRestore(object? sender, RoutedEventArgs e)
{
if (DataContext is DevicePairingViewModel vm)
await vm.RestoreCommand.ExecuteAsync(TargetDbPath);
}
private void OnRestartRequested(object? sender, RoutedEventArgs e)
{
RestartRequested = true;
Close();
}
}

View File

@@ -0,0 +1,109 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.AddGroupDialog"
x:DataType="vm:AddGroupDialogViewModel"
Title="Neue Lerngruppe"
Width="420" Height="480"
CanResize="False"
WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Grid.Row="0" Spacing="16">
<TextBlock Text="Neue Lerngruppe anlegen"
FontSize="18" FontWeight="SemiBold"/>
<!-- Name -->
<StackPanel Spacing="4">
<TextBlock Text="Name *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Name}"
Watermark="z.B. 10E, Q1 Chemie, 5a"/>
</StackPanel>
<!-- Fach -->
<StackPanel Spacing="4">
<TextBlock Text="Fach" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Subject}"
Watermark="z.B. Chemie, Mathematik"/>
</StackPanel>
<!-- Typ und Klassenstufe nebeneinander -->
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Typ" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding GroupTypes}"
SelectedItem="{Binding Type}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate DataType="vm:GroupTypeItem">
<TextBlock Text="{Binding Label}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Klassenstufe *" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding GradeLevel}"
Minimum="1" Maximum="13"
FormatString="0"/>
</StackPanel>
</Grid>
<!-- Notensystem und Schuljahr -->
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Text="Notensystem" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding GradingSystems}"
SelectedItem="{Binding GradingSystem}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate DataType="vm:GradingSystemItem">
<TextBlock Text="{Binding Label}" TextWrapping="Wrap"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Schuljahr" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding SchoolYears}"
SelectedItem="{Binding SelectedSchoolYear}"
HorizontalAlignment="Stretch"/>
</StackPanel>
</Grid>
<!-- Wochenstunden -->
<StackPanel Spacing="4">
<TextBlock Text="Wochenstunden (optional)" FontSize="12" Opacity="0.7"/>
<NumericUpDown Value="{Binding HoursPerWeek}"
Minimum="1" Maximum="20"
FormatString="0"
AllowSpin="True"/>
</StackPanel>
<!-- Validierungsfehler -->
<TextBlock Text="{Binding ValidationMessage}"
Foreground="Red" FontSize="12"
IsVisible="{Binding ValidationMessage,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<!-- ── Buttons ──────────────────────────────────────────────────────── -->
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,16,0,0">
<Button Grid.Column="0"
Content="Abbrechen"
HorizontalAlignment="Stretch"
Click="OnCancel"/>
<Button Grid.Column="2"
Content="Anlegen"
HorizontalAlignment="Stretch"
Click="OnSave"/>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,33 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LehrerApp.Desktop.Views.Groups;
public partial class AddGroupDialog : Window
{
public bool Saved { get; private set; }
public AddGroupDialog()
{
InitializeComponent();
}
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is ViewModels.Groups.AddGroupDialogViewModel vm)
{
if (vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
if (vm.Result is not null)
{
Saved = true;
Close(true);
}
}
}
}
private void OnCancel(object? sender, RoutedEventArgs e) =>
Close(false);
}

View File

@@ -0,0 +1,129 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GroupDetailView"
x:DataType="vm:GroupDetailViewModel">
<Grid RowDefinitions="Auto,Auto,*">
<!-- ── Header ──────────────────────────────────────────────────────── -->
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding GroupTitle}"
FontSize="22" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding StudentCount}"/>
<Run Text="Schüler"/>
</TextBlock>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="+ Schüler"
Command="{Binding AddStudentCommand}"/>
<Button Content="+ Klausur"
Command="{Binding AddExamCommand}"/>
</StackPanel>
</Grid>
</Border>
<!-- ── Tab-Leiste ──────────────────────────────────────────────────── -->
<Border Grid.Row="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1"
Padding="16,0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="Übersicht"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:GroupTab.Overview}"
Classes.active="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:GroupTab.Overview}}"
Background="Transparent" Padding="12,8"/>
<Button Content="Schüler"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:GroupTab.Students}"
Classes.active="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:GroupTab.Students}}"
Background="Transparent" Padding="12,8"/>
<Button Content="Klausuren"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:GroupTab.Exams}"
Classes.active="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:GroupTab.Exams}}"
Background="Transparent" Padding="12,8"/>
<Button Content="Noten"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:GroupTab.Grades}"
Background="Transparent" Padding="12,8"/>
<Button Content="Planung"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:GroupTab.Planner}"
Background="Transparent" Padding="12,8"/>
<Button Content="Dokumentation"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:GroupTab.Documentation}"
Background="Transparent" Padding="12,8"/>
</StackPanel>
</Border>
<!-- ── Tab-Inhalt ──────────────────────────────────────────────────── -->
<ScrollViewer Grid.Row="2" Padding="20">
<!-- Schülerliste -->
<DataGrid ItemsSource="{Binding Students}"
IsVisible="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:GroupTab.Students}}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False"
CanUserResizeColumns="True">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding FullName}"
Width="*"/>
</DataGrid.Columns>
</DataGrid>
<!-- Klausuren -->
<DataGrid ItemsSource="{Binding Exams}"
IsVisible="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:GroupTab.Exams}}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Datum"
Binding="{Binding Date}"
Width="100"/>
<DataGridTextColumn Header="Titel"
Binding="{Binding Title}"
Width="*"/>
<DataGridTextColumn Header="Status"
Binding="{Binding Status}"
Width="120"/>
</DataGrid.Columns>
</DataGrid>
<!-- Übersicht (Platzhalter) -->
<StackPanel IsVisible="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:GroupTab.Overview}}"
Spacing="12">
<TextBlock Text="Übersicht" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Hier erscheint eine Zusammenfassung der Lerngruppe."
Opacity="0.5"/>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View File

@@ -0,0 +1,100 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
x:Class="LehrerApp.Desktop.Views.Groups.GroupListView"
x:DataType="vm:GroupListViewModel">
<Grid RowDefinitions="Auto,*">
<!-- ── Toolbar ─────────────────────────────────────────────────────── -->
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Lerngruppen" FontSize="22" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding Groups.Count}"/>
<Run Text="Gruppen ·"/>
<Run Text="{Binding SelectedSchoolYear}"/>
</TextBlock>
</StackPanel>
<!-- Schuljahr-Auswahl -->
<ComboBox Grid.Column="1"
ItemsSource="{Binding SchoolYears}"
SelectedItem="{Binding SelectedSchoolYear}"
Width="100" Margin="0,0,8,0"
VerticalAlignment="Center"/>
<!-- Neue Gruppe -->
<Button Grid.Column="2"
Content="+ Neue Gruppe"
Command="{Binding AddGroupCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- ── Inhalt ──────────────────────────────────────────────────────── -->
<Grid Grid.Row="1" ColumnDefinitions="260,*">
<!-- Suchliste links -->
<Border Grid.Column="0"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0">
<DockPanel>
<TextBox DockPanel.Dock="Top"
Text="{Binding SearchText}"
Watermark="Suchen…"
Margin="12,8"/>
<ListBox ItemsSource="{Binding Groups}"
SelectedItem="{Binding SelectedGroup}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:GroupListItem">
<Grid ColumnDefinitions="4,*" Margin="0,4">
<Border Grid.Column="0" Width="4" CornerRadius="2"
Background="{DynamicResource SystemAccentColor}"
Margin="0,0,10,0"/>
<StackPanel Grid.Column="1">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Grid.Column="1"
Text="{Binding TypeLabel}"
FontSize="11" Opacity="0.5"/>
</Grid>
<TextBlock Text="{Binding Subject}"
FontSize="12" Opacity="0.65"
IsVisible="{Binding Subject,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding GradingLabel}"
FontSize="11" Opacity="0.4"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Detail rechts Platzhalter bis Gruppe gewählt -->
<Border Grid.Column="1">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="8"
IsVisible="{Binding !SelectedGroup}">
<TextBlock Text="Keine Gruppe ausgewählt"
FontSize="16" Opacity="0.4"
HorizontalAlignment="Center"/>
<TextBlock Text="Wähle eine Gruppe aus der Liste oder lege eine neue an."
FontSize="12" Opacity="0.3"
HorizontalAlignment="Center"/>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Groups;
public partial class GroupListView : UserControl
{
public GroupListView() => InitializeComponent();
}
public partial class GroupDetailView : UserControl
{
public GroupDetailView() => InitializeComponent();
}

View File

@@ -0,0 +1,108 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
xmlns:views="clr-namespace:LehrerApp.Desktop.Views"
x:Class="LehrerApp.Desktop.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Title="LehrerApp"
Width="1280" Height="800"
MinWidth="900" MinHeight="600">
<Grid ColumnDefinitions="220,*">
<!-- ── Sidebar ───────────────────────────────────────────────────────── -->
<Border Grid.Column="0"
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,1,0">
<DockPanel>
<!-- Header -->
<Border DockPanel.Dock="Top" Padding="16,20,16,12"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<StackPanel>
<TextBlock Text="LehrerApp"
FontSize="20" FontWeight="SemiBold" />
<TextBlock Text="{Binding CurrentSchoolYear, FallbackValue=''}"
FontSize="12" Opacity="0.6" Margin="0,2,0,0"/>
</StackPanel>
</Border>
<!-- Sync-Status unten -->
<Border DockPanel.Dock="Bottom" Padding="12,8"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,1,0,0">
<views:SyncStatusBar />
</Border>
<!-- Nav-Items -->
<StackPanel Margin="8,12">
<views:NavButton Icon="&#xE80F;" Label="Dashboard"
Item="Dashboard"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Dashboard}" />
<TextBlock Text="UNTERRICHT" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="8,16,0,4" />
<views:NavButton Icon="&#xE8F1;" Label="Lerngruppen"
Item="Groups"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Groups}" />
<views:NavButton Icon="&#xE77B;" Label="Schüler"
Item="Students"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Students}" />
<views:NavButton Icon="&#xE943;" Label="Klausuren"
Item="Exams"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Exams}" />
<views:NavButton Icon="&#xE70B;" Label="Planung"
Item="Planner"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Planner}" />
<TextBlock Text="VERWALTUNG" FontSize="10" FontWeight="Bold"
Opacity="0.4" Margin="8,16,0,4" />
<views:NavButton Icon="&#xE823;" Label="Arbeitszeit"
Item="Workload"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Workload}" />
<views:NavButton Icon="&#xE713;" Label="Einstellungen"
Item="Settings"
ActiveItem="{Binding ActiveNavItem}"
Command="{Binding NavigateToCommand}"
CommandParameter="{x:Static vm:NavItem.Settings}" />
</StackPanel>
</DockPanel>
</Border>
<!-- ── Hauptinhalt ───────────────────────────────────────────────────── -->
<ContentControl Grid.Column="1"
Content="{Binding CurrentPage}">
<ContentControl.DataTemplates>
<DataTemplate DataType="vm:DashboardViewModel">
<views:DashboardView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:PlaceholderViewModel}">
<views:PlaceholderView />
</DataTemplate>
</ContentControl.DataTemplates>
</ContentControl>
</Grid>
</Window>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,38 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.NavButton">
<Button Command="{Binding Command, RelativeSource={RelativeSource AncestorType=UserControl}}"
CommandParameter="{Binding CommandParameter, RelativeSource={RelativeSource AncestorType=UserControl}}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="10,8"
CornerRadius="6"
Background="Transparent">
<Grid ColumnDefinitions="24,*">
<TextBlock Grid.Column="0"
Text="{Binding Icon, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontFamily="Segoe MDL2 Assets"
FontSize="14"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Opacity="0.7"/>
<TextBlock Grid.Column="1"
Text="{Binding Label, RelativeSource={RelativeSource AncestorType=UserControl}}"
FontSize="13"
VerticalAlignment="Center"
Margin="8,0,0,0"/>
</Grid>
<Button.Styles>
<Style Selector="Button:pointerover /template/ ContentPresenter">
<Setter Property="Background"
Value="{DynamicResource SystemControlBackgroundListLowBrush}"/>
</Style>
</Button.Styles>
</Button>
</UserControl>

View File

@@ -0,0 +1,69 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using System.Windows.Input;
using LehrerApp.Desktop.ViewModels;
namespace LehrerApp.Desktop.Views;
public partial class NavButton : UserControl
{
public static readonly StyledProperty<string> IconProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Icon), "");
public static readonly StyledProperty<string> LabelProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Label), "");
public static readonly StyledProperty<NavItem> ItemProperty =
AvaloniaProperty.Register<NavButton, NavItem>(nameof(Item));
public static readonly StyledProperty<NavItem> ActiveItemProperty =
AvaloniaProperty.Register<NavButton, NavItem>(nameof(ActiveItem));
public static readonly StyledProperty<ICommand?> CommandProperty =
AvaloniaProperty.Register<NavButton, ICommand?>(nameof(Command));
public static readonly StyledProperty<object?> CommandParameterProperty =
AvaloniaProperty.Register<NavButton, object?>(nameof(CommandParameter));
public string Icon
{
get => GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
public string Label
{
get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
public NavItem Item
{
get => GetValue(ItemProperty);
set => SetValue(ItemProperty, value);
}
public NavItem ActiveItem
{
get => GetValue(ActiveItemProperty);
set => SetValue(ActiveItemProperty, value);
}
public ICommand? Command
{
get => GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
public object? CommandParameter
{
get => GetValue(CommandParameterProperty);
set => SetValue(CommandParameterProperty, value);
}
public NavButton()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,13 @@
<!-- PlaceholderView.axaml -->
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.PlaceholderView"
x:DataType="vm:PlaceholderViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="8">
<TextBlock Text="{Binding Title}" FontSize="24" FontWeight="SemiBold"
HorizontalAlignment="Center"/>
<TextBlock Text="Wird noch implementiert…" Opacity="0.5"
HorizontalAlignment="Center"/>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,148 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentDetailView"
x:DataType="vm:StudentDetailViewModel">
<Grid RowDefinitions="Auto,Auto,*">
<!-- ── Header ──────────────────────────────────────────────────────── -->
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto">
<!-- Anzeigemodus -->
<StackPanel Grid.Column="0"
IsVisible="{Binding !IsEditing}">
<TextBlock Text="{Binding StudentTitle}"
FontSize="22" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Student.DateOfBirth,
StringFormat='Geb. {0:dd.MM.yyyy}',
FallbackValue=''}"
FontSize="12" Opacity="0.5"/>
</StackPanel>
<!-- Bearbeitungsmodus -->
<StackPanel Grid.Column="0" Spacing="6"
IsVisible="{Binding IsEditing}">
<Grid ColumnDefinitions="*,8,*">
<TextBox Grid.Column="0"
Text="{Binding EditFirstName}"
Watermark="Vorname"/>
<TextBox Grid.Column="2"
Text="{Binding EditLastName}"
Watermark="Nachname"/>
</Grid>
<TextBox Text="{Binding EditNotes}"
Watermark="Interne Notiz (optional)"
AcceptsReturn="True" MaxHeight="80"/>
</StackPanel>
<!-- Aktions-Buttons -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Top">
<Button Content="Bearbeiten"
Command="{Binding StartEditCommand}"
IsVisible="{Binding !IsEditing}"/>
<Button Content="Speichern"
Command="{Binding SaveEditCommand}"
IsVisible="{Binding IsEditing}"/>
<Button Content="Abbrechen"
Command="{Binding CancelEditCommand}"
IsVisible="{Binding IsEditing}"/>
</StackPanel>
</Grid>
</Border>
<!-- ── Tab-Leiste ──────────────────────────────────────────────────── -->
<Border Grid.Row="1"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1" Padding="16,0">
<StackPanel Orientation="Horizontal" Spacing="4">
<Button Content="Übersicht"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:StudentTab.Overview}"
Background="Transparent" Padding="12,8"/>
<Button Content="Noten"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:StudentTab.Grades}"
Background="Transparent" Padding="12,8"/>
<Button Content="Dokumentation"
Command="{Binding SwitchTabCommand}"
CommandParameter="{x:Static vm:StudentTab.Documentation}"
Background="Transparent" Padding="12,8"/>
</StackPanel>
</Border>
<!-- ── Tab-Inhalt ──────────────────────────────────────────────────── -->
<ScrollViewer Grid.Row="2" Padding="20">
<!-- Übersicht: Lerngruppen-Verlauf -->
<StackPanel IsVisible="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:StudentTab.Overview}}"
Spacing="16">
<TextBlock Text="Lerngruppen" FontSize="15" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding Enrollments}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:EnrollmentEntry">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,8" Margin="0,0,0,6">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0"
Text="{Binding SchoolYear}"
FontWeight="SemiBold" Width="70"/>
<TextBlock Grid.Column="1"
Text="{Binding GroupName}"
Margin="8,0"/>
<TextBlock Grid.Column="2"
Text="{Binding Subject}"
Opacity="0.5" FontSize="12"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Student.Notes}"
IsVisible="{Binding Student.Notes,
Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Opacity="0.7" FontSize="13"
TextWrapping="Wrap"/>
</StackPanel>
<!-- Dokumentation -->
<ItemsControl IsVisible="{Binding ActiveTab,
Converter={x:Static ObjectConverters.Equal},
ConverterParameter={x:Static vm:StudentTab.Documentation}}"
ItemsSource="{Binding Documentation}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:DocEntry">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="12,10" Margin="0,0,0,8">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Date}"
Opacity="0.5" FontSize="12" Width="80"/>
<StackPanel Grid.Column="1" Margin="8,0">
<TextBlock Text="{Binding Title}"
FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding TypeLabel}"
FontSize="11" Opacity="0.5"/>
</StackPanel>
<TextBlock Grid.Column="2"
Text="🔒" FontSize="14"
IsVisible="{Binding IsConfidential}"
ToolTip.Tip="Vertraulich"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</UserControl>

View File

@@ -0,0 +1,63 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.StudentListView"
x:DataType="vm:StudentListViewModel">
<Grid RowDefinitions="Auto,*">
<!-- ── Toolbar ─────────────────────────────────────────────────────── -->
<Border Grid.Row="0" Padding="20,16"
BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="Schüler" FontSize="22" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.5">
<Run Text="{Binding Students.Count}"/>
<Run Text="Schüler gesamt"/>
</TextBlock>
</StackPanel>
<CheckBox Grid.Column="1"
Content="Inaktive anzeigen"
IsChecked="{Binding ShowInactive}"
VerticalAlignment="Center"
Margin="0,0,12,0"/>
<Button Grid.Column="2"
Content="+ Neuer Schüler"
Command="{Binding AddStudentCommand}"
VerticalAlignment="Center"/>
</Grid>
</Border>
<!-- ── Liste + Suche ───────────────────────────────────────────────── -->
<DockPanel Grid.Row="1">
<TextBox DockPanel.Dock="Top"
Text="{Binding SearchText}"
Watermark="Name suchen…"
Margin="16,10,16,4"/>
<DataGrid ItemsSource="{Binding Students}"
SelectedItem="{Binding SelectedStudent}"
AutoGenerateColumns="False"
IsReadOnly="True"
GridLinesVisibility="Horizontal"
CanUserReorderColumns="False"
CanUserResizeColumns="True"
Margin="16,4">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding FullName}"
Width="*"/>
<DataGridTextColumn Header="Geburtsdatum"
Binding="{Binding DateOfBirth}"
Width="130"/>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,13 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views.Students;
public partial class StudentListView : UserControl
{
public StudentListView() => InitializeComponent();
}
public partial class StudentDetailView : UserControl
{
public StudentDetailView() => InitializeComponent();
}

View File

@@ -0,0 +1,24 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
x:Class="LehrerApp.Desktop.Views.SyncStatusBar"
x:DataType="vm:SyncStatusViewModel">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding StatusText}" FontSize="12"
Opacity="0.7" TextTrimming="CharacterEllipsis"/>
<TextBlock Text="{Binding LastSyncText}" FontSize="10"
Opacity="0.4"/>
</StackPanel>
<Button Grid.Column="1"
Command="{Binding SyncNowCommand}"
IsVisible="{Binding IsServerConfigured}"
ToolTip.Tip="Jetzt synchronisieren"
Padding="6,4">
<TextBlock Text="↻" FontSize="14"
IsVisible="{Binding !IsSyncing}"/>
</Button>
</Grid>
</UserControl>

View File

@@ -0,0 +1,20 @@
using Avalonia.Controls;
using LehrerApp.Desktop.ViewModels;
using Microsoft.Extensions.DependencyInjection;
namespace LehrerApp.Desktop.Views;
public partial class SyncStatusBar : UserControl
{
public SyncStatusBar()
{
InitializeComponent();
// DataContext aus DI holen sobald die View geladen ist
Loaded += (_, _) =>
{
if (DataContext is null)
DataContext = App.Services.GetRequiredService<SyncStatusViewModel>();
};
}
}

View File

@@ -0,0 +1,17 @@
using Avalonia.Controls;
namespace LehrerApp.Desktop.Views;
// Code-behind Stubs für Views ohne eigene .axaml.cs
// NavButton → eigene NavButton.axaml.cs
// SyncStatusBar → eigene SyncStatusBar.axaml.cs
public partial class DashboardView : UserControl
{
public DashboardView() => InitializeComponent();
}
public partial class PlaceholderView : UserControl
{
public PlaceholderView() => InitializeComponent();
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="LehrerApp.Desktop"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>