Jahresplanimport und Ablgeich von Untis

This commit is contained in:
2026-08-23 19:23:05 +02:00
parent 4a59bff9de
commit dd5ecb0334
20 changed files with 1039 additions and 20 deletions
+12
View File
@@ -167,6 +167,7 @@ public static class AppBootstrapper
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -206,6 +207,17 @@ public static class AppBootstrapper
sp.GetRequiredService<AppLogger>()));
}
// ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ──
var annualPlanSettings = new AnnualPlanSettingsService(appData);
services.AddSingleton(annualPlanSettings);
if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new AnnualPlanSyncService(
new HttpClient(), annualPlanSettings,
sp.GetRequiredService<IAnnualPlanEventRepository>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings);
@@ -0,0 +1,87 @@
using System.Text.Json;
using LehrerApp.Sync.Crypto;
namespace LehrerApp.Desktop.Services;
internal sealed class AnnualPlanSettingsConfig
{
public bool Enabled { get; set; }
public string? EncryptedIcalUrl { get; set; }
public DateTime? LastSyncAt { get; set; }
public string LastSyncStatus { get; set; } = "";
}
/// <summary>
/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel
/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert.
/// </summary>
public sealed class AnnualPlanSettingsService
{
private readonly string _configPath;
private readonly byte[] _urlKey;
private AnnualPlanSettingsConfig _config;
public bool Enabled => _config.Enabled;
public bool IsConfigured => _config.EncryptedIcalUrl is not null;
public DateTime? LastSyncAt => _config.LastSyncAt;
public string LastSyncStatus => _config.LastSyncStatus;
public AnnualPlanSettingsService(string appDataPath)
{
_configPath = Path.Combine(appDataPath, "annual-plan-settings.json");
var keyPath = Path.Combine(appDataPath, "annual-plan-url.key");
_urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath);
_config = Load();
}
public void SetIcalUrl(string url)
{
_config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey);
Save();
}
public string? GetIcalUrl() => _config.EncryptedIcalUrl is null
? null
: SyncCrypto.DecryptObject<string>(_config.EncryptedIcalUrl, _urlKey);
public void SetEnabled(bool enabled)
{
_config.Enabled = enabled;
Save();
}
public void ClearIcalUrl()
{
_config.EncryptedIcalUrl = null;
_config.Enabled = false;
Save();
}
public void SetLastSync(DateTime at, string status)
{
_config.LastSyncAt = at;
_config.LastSyncStatus = status;
Save();
}
private byte[] GenerateAndSaveKey(string keyPath)
{
var key = SyncCrypto.GenerateKey();
SyncCrypto.SaveKey(key, keyPath);
return key;
}
private AnnualPlanSettingsConfig Load()
{
try
{
if (File.Exists(_configPath))
return JsonSerializer.Deserialize<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
?? new AnnualPlanSettingsConfig();
}
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
return new AnnualPlanSettingsConfig();
}
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
}
@@ -0,0 +1,147 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted)
{
public int ChangeCount => Added + Updated + Deleted;
}
/// <summary>
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
/// </summary>
public sealed class AnnualPlanSyncService : IDisposable
{
private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6);
private readonly HttpClient _http;
private readonly AnnualPlanSettingsService _settings;
private readonly IAnnualPlanEventRepository _events;
private readonly AppLogger? _logger;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Timer _timer;
public event Action? DataChanged;
public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings,
IAnnualPlanEventRepository events, AppLogger? logger = null)
{
_http = http;
_settings = settings;
_events = events;
_logger = logger;
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
}
public async Task PollAsync()
{
if (!await _gate.WaitAsync(0)) return;
try
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrWhiteSpace(url)) return;
string icsText;
try
{
icsText = await _http.GetStringAsync(url);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}");
return;
}
AnnualPlanPollResult result;
try
{
result = ProcessIcsText(icsText);
}
catch (Exception ex)
{
_logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex);
_settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}");
return;
}
_settings.SetLastSync(DateTime.UtcNow,
$"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt.");
_logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en).");
if (result.ChangeCount > 0) DataChanged?.Invoke();
}
finally
{
_gate.Release();
}
}
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
public AnnualPlanPollResult ProcessIcsText(string icsText)
{
// Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der
// strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands.
var imported = AnnualPlanIcsParser.Parse(icsText);
var existing = _events.GetAll();
var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal);
var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal);
var added = 0;
var updated = 0;
foreach (var entry in imported)
{
if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous))
{
_events.Save(entry);
added++;
continue;
}
entry.Id = previous.Id;
if (SameContent(previous, entry)) continue;
_events.Save(entry);
updated++;
}
var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList();
foreach (var entry in stale) _events.Delete(entry.Id);
return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count);
}
private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) =>
left.ExternalId == right.ExternalId &&
left.Title == right.Title &&
left.Description == right.Description &&
left.Location == right.Location &&
left.StartDate == right.StartDate &&
left.EndDate == right.EndDate &&
left.StartTime == right.StartTime &&
left.EndTime == right.EndTime &&
left.IsAllDay == right.IsAllDay &&
left.CalendarGroup == right.CalendarGroup &&
left.Color == right.Color &&
left.Status == right.Status &&
left.Sequence == right.Sequence &&
SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc);
private static bool SameInstant(DateTime? left, DateTime? right) =>
left is null && right is null ||
left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime();
public void Clear()
{
var existing = _events.GetAll();
foreach (var entry in existing) _events.Delete(entry.Id);
if (existing.Count > 0) DataChanged?.Invoke();
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
using LehrerApp.Desktop.Services;
using System.Collections.ObjectModel;
using System.Globalization;
@@ -34,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly PublicHolidayService _publicHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -108,7 +110,8 @@ public partial class DashboardViewModel : ObservableObject
PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy,
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions)
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -118,6 +121,12 @@ public partial class DashboardViewModel : ObservableObject
_attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings;
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
_ = annualPlanSync.PollAsync();
}
LoadDashboardCards();
Load();
}
@@ -580,6 +589,27 @@ public partial class DashboardViewModel : ObservableObject
}
}
// Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an
// keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem
// betroffenen Kalendertag.
if (_annualPlanEvents is not null)
{
foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd)
.Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)))
{
var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate;
var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate;
for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1))
{
var agg = Agg(date);
agg.HasAnnualPlanEvent = true;
agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date,
annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null,
annualEvent.Description));
}
}
}
// "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung
// existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den
// aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht.
@@ -615,7 +645,7 @@ public partial class DashboardViewModel : ObservableObject
byDay.TryGetValue(date, out var agg);
CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today,
agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false,
agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? []));
}
SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth)
?? CalendarDays.First(d => d.IsCurrentMonth));
@@ -635,8 +665,9 @@ public partial class DashboardViewModel : ObservableObject
private void OpenCalendarEvent(CalendarEventItem? item)
{
if (item is null) return;
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId);
else OnNavigateToLesson?.Invoke(item.GroupId); // auch für ParticipationSession: Tab "Mitarbeit"
if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information.
if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId);
else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit"
}
[RelayCommand]
@@ -699,9 +730,35 @@ public partial class DashboardViewModel : ObservableObject
public bool HasLesson;
public bool HasExam;
public bool HasSession;
public bool HasAnnualPlanEvent;
public bool IsOwnClassDay;
public List<CalendarEventItem> Details { get; } = [];
}
private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry)
{
string time;
if (entry.IsAllDay)
{
time = entry.StartDate == entry.EndDate
? "Ganztägig"
: $"{entry.StartDate:dd.MM.}{entry.EndDate:dd.MM.yyyy} · ganztägig";
}
else if (entry.StartDate == entry.EndDate)
{
time = entry.EndTime is { } end
? $"{entry.StartTime:HH\\:mm}{end:HH\\:mm}"
: $"{entry.StartTime:HH\\:mm}";
}
else
{
time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}" +
$"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}";
}
return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location }
.Where(part => !string.IsNullOrWhiteSpace(part)));
}
}
public class LessonItem
@@ -783,12 +840,14 @@ public partial class CalendarDayCell : ObservableObject
public bool HasLesson { get; }
public bool HasExam { get; }
public bool HasSession { get; }
public bool HasAnnualPlanEvent { get; }
public bool IsOwnClassDay { get; }
public string Tooltip { get; }
public IReadOnlyList<CalendarEventItem> Events { get; }
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List<CalendarEventItem> details)
bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent,
bool isOwnClassDay, List<CalendarEventItem> details)
{
Date = date;
DayNumber = date.Day;
@@ -797,6 +856,7 @@ public partial class CalendarDayCell : ObservableObject
HasLesson = hasLesson;
HasExam = hasExam;
HasSession = hasSession;
HasAnnualPlanEvent = hasAnnualPlanEvent;
IsOwnClassDay = isOwnClassDay;
Events = details;
Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy")
@@ -804,20 +864,22 @@ public partial class CalendarDayCell : ObservableObject
}
}
public enum CalendarEventKind { Lesson, Exam, ParticipationSession }
public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan }
public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title,
string subtitle, Guid groupId)
string subtitle, Guid? groupId, string description = "")
{
public CalendarEventKind Kind { get; } = kind;
public DateOnly Date { get; } = date;
public string Title { get; } = title;
public string Subtitle { get; } = subtitle;
public Guid GroupId { get; } = groupId;
public Guid? GroupId { get; } = groupId;
public string Description { get; } = description;
public string KindLabel => Kind switch
{
CalendarEventKind.Exam => "Klausur",
CalendarEventKind.ParticipationSession => "Sitzung",
CalendarEventKind.AnnualPlan => "Jahresplan",
_ => "Unterricht"
};
}
@@ -202,6 +202,14 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private bool _untisFetchBusy;
public Func<Task>? OnReviewUntisMapping { get; set; }
// ── Schulweiter Jahresplan (ClassyPlan-iCal) ─────────────────────────────
[ObservableProperty] private bool _annualPlanIsConfigured;
[ObservableProperty] private string _annualPlanIcalUrlInput = "";
[ObservableProperty] private string _annualPlanUrlError = "";
[ObservableProperty] private string _annualPlanStatusDisplay = "";
[ObservableProperty] private bool _annualPlanFetchBusy;
// ── Synchronisation (Kapitel 10) ──────────────────────────────────────────
[ObservableProperty] private string _syncServerUrl = "";
@@ -281,6 +289,8 @@ public partial class SettingsViewModel : ObservableObject
private readonly AiPlanningService _aiPlanning;
private readonly WebUntisSettingsService _untisSettings;
private readonly UntisSyncService? _untisSync;
private readonly AnnualPlanSettingsService _annualPlanSettings;
private readonly AnnualPlanSyncService? _annualPlanSync;
private readonly SyncSettingsService _syncSettings;
private readonly SyncAuthService _syncAuth;
private readonly EventQueue _eventQueue;
@@ -303,11 +313,12 @@ public partial class SettingsViewModel : ObservableObject
ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates,
AiSettingsService aiSettings, AiPlanningService aiPlanning,
WebUntisSettingsService untisSettings,
AnnualPlanSettingsService annualPlanSettings,
SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue,
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null)
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -337,6 +348,8 @@ public partial class SettingsViewModel : ObservableObject
_aiPlanning = aiPlanning;
_untisSettings = untisSettings;
_untisSync = untisSync;
_annualPlanSettings = annualPlanSettings;
_annualPlanSync = annualPlanSync;
_syncSettings = syncSettings;
_syncAuth = syncAuth;
_eventQueue = eventQueue;
@@ -360,6 +373,7 @@ public partial class SettingsViewModel : ObservableObject
LoadLetterTemplates();
LoadAiSettings();
LoadUntisSettings();
LoadAnnualPlanSettings();
LoadSyncSettings();
LoadSyncConflicts();
}
@@ -557,6 +571,68 @@ public partial class SettingsViewModel : ObservableObject
if (OnReviewUntisMapping is not null) await OnReviewUntisMapping();
}
// ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─
private void LoadAnnualPlanSettings()
{
AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured;
AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at
? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}"
: "Noch kein Abgleich durchgeführt.";
}
[RelayCommand]
private void AnnualPlanSaveUrl()
{
AnnualPlanUrlError = "";
if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput))
{
AnnualPlanUrlError = "iCal-URL erforderlich.";
return;
}
if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) ||
uri.Scheme is not ("http" or "https"))
{
AnnualPlanUrlError = "Ungültige HTTP(S)-URL.";
return;
}
_annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim());
_annualPlanSettings.SetEnabled(true);
AnnualPlanIcalUrlInput = "";
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private void AnnualPlanRemove()
{
_annualPlanSync?.Clear();
_annualPlanSettings.ClearIcalUrl();
LoadAnnualPlanSettings();
AppBootstrapper.RestartApplication();
}
[RelayCommand]
private async Task AnnualPlanFetchNow()
{
if (_annualPlanSync is null)
{
AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten.";
return;
}
AnnualPlanFetchBusy = true;
try
{
await _annualPlanSync.PollAsync();
LoadAnnualPlanSettings();
}
finally
{
AnnualPlanFetchBusy = false;
}
}
// ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ───────
//
// Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen
@@ -214,6 +214,7 @@
HorizontalAlignment="Center" VerticalAlignment="Bottom">
<Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/>
<Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/>
<Ellipse Width="5" Height="5" Fill="#FF8A00" IsVisible="{Binding HasAnnualPlanEvent}"/>
</StackPanel>
</Grid>
</Border>
@@ -222,25 +223,29 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<StackPanel Orientation="Horizontal" Spacing="14" Margin="0,4,0,0">
<StackPanel Orientation="Horizontal" Spacing="4">
<WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Border Width="10" Height="10" CornerRadius="3" Background="#14808080"/>
<TextBlock Text="Unterricht" FontSize="10" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Ellipse Width="7" Height="7" Fill="#E53935"/>
<TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Ellipse Width="7" Height="7" Fill="#1E88E5"/>
<TextBlock Text="Sitzung" FontSize="10" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Ellipse Width="7" Height="7" Fill="#FF8A00"/>
<TextBlock Text="Jahresplan" FontSize="10" Opacity="0.6"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
<Border Width="10" Height="10" CornerRadius="3" BorderThickness="2"
BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/>
<TextBlock Text="Meine Klasse" FontSize="10" Opacity="0.6"/>
</StackPanel>
</StackPanel>
</WrapPanel>
<Separator Margin="0,4"/>
<TextBlock Text="{Binding SelectedDayLabel}" FontWeight="SemiBold" FontSize="12"/>
@@ -256,6 +261,9 @@
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold"/>
<TextBlock Text="{Binding Subtitle}" FontSize="10" Opacity="0.6"/>
<TextBlock Text="{Binding Description}" FontSize="10" Opacity="0.6"
TextWrapping="Wrap"
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
</Button>
@@ -921,10 +921,14 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: WebUntis-iCal-Abgleich (Nutzer-Feedback) -->
<ContentPage Header="Stundenplan-Abgleich">
<!-- Gemeinsamer UI-Tab; beide Importpfade bleiben fachlich und technisch getrennt. -->
<ContentPage Header="Untis-Einbettung">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
@@ -952,6 +956,36 @@
</StackPanel>
</StackPanel>
<Separator Margin="0,8"/>
<TextBlock Text="Schulweiter Jahresplan" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Importiert Konferenzen, Prüfungszeiträume, Fortbildungen und weitere schulweite Termine aus einem iCal-Feed. Diese Termine werden zusätzlich im Dashboard-Kalender angezeigt und verändern den persönlichen Stundenplan nicht."/>
<StackPanel Spacing="8" IsVisible="{Binding !AnnualPlanIsConfigured}">
<StackPanel Spacing="4">
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding AnnualPlanIcalUrlInput}" PasswordChar="●"
PlaceholderText="https://…/export.php?type=ics…"/>
</StackPanel>
<TextBlock Text="{Binding AnnualPlanUrlError}" Foreground="Red" FontSize="12"
IsVisible="{Binding AnnualPlanUrlError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern und aktivieren" Command="{Binding AnnualPlanSaveUrlCommand}"
HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding AnnualPlanIsConfigured}">
<TextBlock Text="Jahresplan-iCal hinterlegt (verschlüsselt gespeichert)."
FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding AnnualPlanStatusDisplay}" FontSize="12" Opacity="0.7"
TextWrapping="Wrap"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Jetzt abrufen" Command="{Binding AnnualPlanFetchNowCommand}"
IsEnabled="{Binding !AnnualPlanFetchBusy}"/>
<Button Content="Entfernen" Command="{Binding AnnualPlanRemoveCommand}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>