Wetterdienst

This commit is contained in:
2026-08-23 20:51:19 +02:00
parent cc973418f3
commit 1b32166ce0
21 changed files with 1186 additions and 3 deletions
+1
View File
@@ -222,6 +222,7 @@ public static class AppBootstrapper
var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings);
services.AddSingleton(_ => new SyncAuthService(new HttpClient()));
services.AddSingleton(sp => new SchoolWeatherService(new HttpClient(), syncSettings));
// War dieses Gerät schon eingeloggt, aber sync.key fehlt(e), wurde gerade eben (unten)
// stillschweigend ein neuer, unabhängiger Schlüssel erzeugt - bisher unter dem ALTEN
// Schlüssel synchronisierte Server-Daten sind für dieses Gerät dann nicht mehr lesbar.
@@ -0,0 +1,73 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using LehrerApp.Core.Models;
namespace LehrerApp.Desktop.Services;
public sealed class SchoolWeatherException(string message) : Exception(message);
/// <summary>Client für das serverseitige Schulstandort-/Wetterprofil. Liest URL und Token je
/// Aufruf, damit ein Login oder Serverwechsel in den Einstellungen sofort berücksichtigt wird.</summary>
public sealed class SchoolWeatherService(HttpClient http, SyncSettingsService syncSettings)
{
public bool IsAvailable => !string.IsNullOrWhiteSpace(syncSettings.ServerUrl) && syncSettings.IsLoggedIn;
public async Task<SchoolLocationProfile?> GetLocationAsync(CancellationToken cancellationToken = default)
{
using var response = await SendAsync(HttpMethod.Get, "/api/school/location", null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<SchoolLocationProfile>(cancellationToken);
}
public async Task<SchoolLocationProfile> SaveLocationAsync(SchoolLocationRequest location,
CancellationToken cancellationToken = default)
{
using var response = await SendAsync(HttpMethod.Put, "/api/school/location", location, cancellationToken);
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<SchoolLocationProfile>(cancellationToken)
?? throw new SchoolWeatherException("Der Server hat keine Standortdaten zurückgegeben.");
}
public async Task<WeatherSnapshot?> GetWeatherAsync(CancellationToken cancellationToken = default)
{
using var response = await SendAsync(HttpMethod.Get, "/api/school/weather", null, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<WeatherSnapshot>(cancellationToken);
}
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(syncSettings.ServerUrl) || !syncSettings.IsLoggedIn)
throw new SchoolWeatherException("Bitte zuerst im Reiter „Synchronisation“ am Server anmelden.");
using var request = new HttpRequestMessage(method, new Uri(new Uri(syncSettings.ServerUrl), path));
var token = syncSettings.GetToken();
if (token is not null)
request.Headers.Authorization = new("Bearer", token);
if (body is not null) request.Content = JsonContent.Create(body);
try { return await http.SendAsync(request, cancellationToken); }
catch (HttpRequestException)
{
throw new SchoolWeatherException("Der LehrerApp-Server ist nicht erreichbar.");
}
}
private static async Task EnsureSuccessAsync(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
if (response.StatusCode == HttpStatusCode.Unauthorized)
throw new SchoolWeatherException("Die Server-Anmeldung ist abgelaufen. Bitte erneut anmelden.");
try
{
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
if (document.RootElement.TryGetProperty("detail", out var detail) &&
!string.IsNullOrWhiteSpace(detail.GetString()))
throw new SchoolWeatherException(detail.GetString()!);
}
catch (JsonException) { }
throw new SchoolWeatherException("Der Schulstandort konnte nicht gespeichert werden.");
}
}
@@ -36,6 +36,7 @@ public partial class DashboardViewModel : ObservableObject
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly ISubstitutionEntryRepository _substitutions;
private readonly IAnnualPlanEventRepository? _annualPlanEvents;
private readonly SchoolWeatherService? _schoolWeather;
private const int OpenExcuseMaxAgeDays = 21;
private const int SupportPlanDueWithinDays = 14;
@@ -56,6 +57,11 @@ public partial class DashboardViewModel : ObservableObject
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private bool _isDashboardSettingsOpen;
[ObservableProperty] private bool _isWeatherPanelVisible;
[ObservableProperty] private string _weatherSummary = "";
[ObservableProperty] private string _weatherDetails = "";
[ObservableProperty] private string _weatherStatus = "";
[ObservableProperty] private bool _hasWeatherWarnings;
public string CalendarMonthLabel => CalendarMonth.ToString("MMMM yyyy", De);
@@ -72,6 +78,7 @@ public partial class DashboardViewModel : ObservableObject
public ObservableCollection<DashboardAlertItem> Alerts { get; } = [];
public ObservableCollection<CalendarEventItem> SelectedDayEvents { get; } = [];
public ObservableCollection<DashboardCardOption> DashboardCards { get; } = [];
public ObservableCollection<DashboardWeatherWarningItem> WeatherWarnings { get; } = [];
public static string[] CalendarWeekdayHeaders { get; } = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
// Navigation-Callback wird von App.axaml.cs verdrahtet
@@ -111,7 +118,7 @@ public partial class DashboardViewModel : ObservableObject
DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays,
PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings,
ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null,
AnnualPlanSyncService? annualPlanSync = null)
AnnualPlanSyncService? annualPlanSync = null, SchoolWeatherService? schoolWeather = null)
{
_groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks;
_examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships;
@@ -122,6 +129,7 @@ public partial class DashboardViewModel : ObservableObject
_schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings;
_substitutions = substitutions;
_annualPlanEvents = annualPlanEvents;
_schoolWeather = schoolWeather;
if (annualPlanSync is not null)
{
annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar);
@@ -142,6 +150,7 @@ public partial class DashboardViewModel : ObservableObject
CurrentDate = now.ToString("dddd, d. MMMM yyyy", De);
CurrentSchoolYear = _sy.CurrentSchoolYear();
Greeting = now.Hour < 12 ? "Guten Morgen" : now.Hour < 18 ? "Guten Tag" : "Guten Abend";
_ = LoadWeatherAsync();
TodaysLessons.Clear();
var groups = _groups.GetBySchoolYear(_sy.CurrentSchoolYear()).ToDictionary(g => g.Id);
@@ -197,6 +206,57 @@ public partial class DashboardViewModel : ObservableObject
LoadAlerts(groups, today);
}
private async Task LoadWeatherAsync()
{
if (_schoolWeather?.IsAvailable != true) return;
try
{
var snapshot = await _schoolWeather.GetWeatherAsync();
if (snapshot is null) return;
var current = snapshot.Forecast
.Where(x => x.ValidAt.ToUniversalTime() >= DateTime.UtcNow.AddHours(-1))
.MinBy(x => Math.Abs((x.ValidAt.ToUniversalTime() - DateTime.UtcNow).TotalMinutes));
WeatherWarnings.Clear();
foreach (var warning in snapshot.Warnings)
WeatherWarnings.Add(new DashboardWeatherWarningItem(warning));
HasWeatherWarnings = WeatherWarnings.Count > 0;
if (current is not null)
{
var temperature = current.TemperatureC is { } t ? $"{t:0.#} °C" : "Temperatur unbekannt";
WeatherSummary = $"{temperature} · {WeatherDescription(current.WeatherCode)}";
var details = new List<string>();
if (current.WindSpeedKmh is { } wind) details.Add($"Wind {wind:0.#} km/h");
if (current.WindGustKmh is { } gust) details.Add($"Böen {gust:0.#} km/h");
if (current.PrecipitationMm is > 0) details.Add($"Niederschlag {current.PrecipitationMm:0.#} mm");
details.Add($"DWD-Station {snapshot.StationName} ({snapshot.StationDistanceKm:0.#} km)");
WeatherDetails = string.Join(" · ", details);
}
WeatherStatus = snapshot.IsStale
? $"Letzter verfügbarer Stand vom {snapshot.RetrievedAt.ToLocalTime():dd.MM., HH:mm} Uhr"
: "";
IsWeatherPanelVisible = current is not null || HasWeatherWarnings;
}
catch (SchoolWeatherException ex)
{
WeatherStatus = ex.Message;
IsWeatherPanelVisible = true;
}
}
private static string WeatherDescription(int? code) => code switch
{
0 => "klar",
>= 1 and <= 3 => "bewölkt",
45 or 48 => "Nebel",
>= 51 and <= 67 => "Regen",
>= 71 and <= 77 => "Schnee",
>= 80 and <= 82 => "Regenschauer",
85 or 86 => "Schneeschauer",
>= 95 and <= 99 => "Gewitter",
_ => "Wettervorhersage",
};
// ── Fehlzeiten-Warnung (5.2.3) ────────────────────────────────────────────
private void LoadAttendanceWarnings(DateOnly today)
@@ -761,6 +821,32 @@ public partial class DashboardViewModel : ObservableObject
}
}
public sealed class DashboardWeatherWarningItem
{
public string Headline { get; }
public string Description { get; }
public string Instruction { get; }
public string PeriodDisplay { get; }
public string SeverityColor { get; }
public DashboardWeatherWarningItem(WeatherWarning warning)
{
Headline = string.IsNullOrWhiteSpace(warning.Headline) ? warning.Event : warning.Headline;
Description = warning.Description;
Instruction = warning.Instruction;
PeriodDisplay = (warning.Onset, warning.Expires) switch
{
({ } onset, { } expires) => $"{onset.ToLocalTime():dd.MM., HH:mm}{expires.ToLocalTime():HH:mm} Uhr",
({ } onset, null) => $"ab {onset.ToLocalTime():dd.MM., HH:mm} Uhr",
_ => "",
};
SeverityColor = warning.Severity switch
{
"Extreme" => "#7E0023", "Severe" => "#D32F2F", "Moderate" => "#F59E0B", _ => "#FDD835",
};
}
}
public class LessonItem
{
public Guid LessonId { get; set; }
@@ -162,6 +162,16 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _newHolidayEndText = "";
[ObservableProperty] private string _holidayNameError = "";
[ObservableProperty] private string _holidayDateError = "";
[ObservableProperty] private string _schoolName = "";
[ObservableProperty] private string _schoolStreet = "";
[ObservableProperty] private string _schoolPostalCode = "";
[ObservableProperty] private string _schoolCity = "";
[ObservableProperty] private string _schoolNameError = "";
[ObservableProperty] private string _schoolStreetError = "";
[ObservableProperty] private string _schoolPostalCodeError = "";
[ObservableProperty] private string _schoolCityError = "";
[ObservableProperty] private string _schoolLocationStatus = "";
[ObservableProperty] private string _resolvedSchoolAddress = "";
public List<string> StateOptions { get; } = GermanStateDisplay.Options.ToList();
public ObservableCollection<SchoolHolidayItem> SchoolHolidayEntries { get; } = [];
@@ -283,6 +293,7 @@ public partial class SettingsViewModel : ObservableObject
private readonly ISchoolHolidayRepository _schoolHolidays;
private readonly SchoolCalendarSettingsService _calendarSettings;
private readonly SchoolWeatherService? _schoolWeather;
private readonly PeriodScheduleService _periodSchedule;
private readonly ISupervisionDutyRepository _supervisionDuties;
private readonly AiSettingsService _aiSettings;
@@ -318,7 +329,8 @@ public partial class SettingsViewModel : ObservableObject
AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery,
AppearanceSettingsService appearance, TrashViewModel trashTab,
SnapshotService? snapshotService = null, SyncEngine? syncEngine = null,
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null)
UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null,
SchoolWeatherService? schoolWeather = null)
{
_logger = logger;
_syncKeyRecovery = syncKeyRecovery;
@@ -341,6 +353,7 @@ public partial class SettingsViewModel : ObservableObject
_shorthandCodes = shorthandCodes;
_schoolHolidays = schoolHolidays;
_calendarSettings = calendarSettings;
_schoolWeather = schoolWeather;
_periodSchedule = periodSchedule;
_supervisionDuties = supervisionDuties;
_letterTemplates = letterTemplates;
@@ -930,6 +943,60 @@ public partial class SettingsViewModel : ObservableObject
partial void OnSelectedStateNameChanged(string value) =>
_calendarSettings.SetState(GermanStateDisplay.FromLabel(value));
[RelayCommand]
private async Task LoadSchoolLocation()
{
if (_schoolWeather is null) return;
try
{
var location = await _schoolWeather.GetLocationAsync();
if (location is null) return;
SchoolName = location.SchoolName;
SchoolStreet = location.Street;
SchoolPostalCode = location.PostalCode;
SchoolCity = location.City;
SelectedStateName = GermanStateDisplay.Label(location.State);
ResolvedSchoolAddress = location.ResolvedAddress;
}
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
}
[RelayCommand]
private async Task SaveSchoolLocation()
{
SchoolNameError = ""; SchoolStreetError = ""; SchoolPostalCodeError = "";
SchoolCityError = ""; SchoolLocationStatus = "";
var valid = true;
if (string.IsNullOrWhiteSpace(SchoolName))
{ SchoolNameError = "Name der Schule erforderlich."; valid = false; }
if (string.IsNullOrWhiteSpace(SchoolStreet))
{ SchoolStreetError = "Straße und Hausnummer erforderlich."; valid = false; }
if (!System.Text.RegularExpressions.Regex.IsMatch(SchoolPostalCode.Trim(), @"^\d{5}$"))
{ SchoolPostalCodeError = "Bitte eine fünfstellige PLZ angeben."; valid = false; }
if (string.IsNullOrWhiteSpace(SchoolCity))
{ SchoolCityError = "Ort erforderlich."; valid = false; }
if (!valid) return;
if (_schoolWeather is null)
{
SchoolLocationStatus = "Der Wetterdienst ist nicht verfügbar.";
return;
}
SchoolLocationStatus = "Adresse wird geprüft …";
try
{
var profile = await _schoolWeather.SaveLocationAsync(new SchoolLocationRequest
{
SchoolName = SchoolName.Trim(), Street = SchoolStreet.Trim(),
PostalCode = SchoolPostalCode.Trim(), City = SchoolCity.Trim(),
State = GermanStateDisplay.FromLabel(SelectedStateName),
});
ResolvedSchoolAddress = profile.ResolvedAddress;
SchoolLocationStatus = "Schulstandort gespeichert. Wetterdaten werden serverseitig abgerufen.";
}
catch (SchoolWeatherException ex) { SchoolLocationStatus = ex.Message; }
}
private void LoadSchoolHolidays()
{
SchoolHolidayEntries.Clear();
@@ -54,6 +54,46 @@
</ItemsControl>
</Border>
<!-- Serverseitig gecachte DWD-Daten für den in den Einstellungen hinterlegten Schulstandort. -->
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="8"
Padding="16" IsVisible="{Binding IsWeatherPanelVisible}">
<StackPanel Spacing="10">
<Grid ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="WETTER AM SCHULSTANDORT" FontSize="11" FontWeight="Bold" Opacity="0.5"/>
<TextBlock Text="{Binding WeatherSummary}" FontSize="20" FontWeight="SemiBold" Margin="0,5,0,0"
IsVisible="{Binding WeatherSummary, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Text="{Binding WeatherDetails}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
IsVisible="{Binding WeatherDetails, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Button Grid.Column="1" Content="Aktualisieren" FontSize="11" Padding="8,3"
Command="{Binding RefreshCommand}" VerticalAlignment="Top"/>
</Grid>
<TextBlock Text="{Binding WeatherStatus}" FontSize="11" Opacity="0.65" TextWrapping="Wrap"
IsVisible="{Binding WeatherStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding WeatherWarnings}" IsVisible="{Binding HasWeatherWarnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DashboardWeatherWarningItem">
<Border BorderBrush="{Binding SeverityColor}" BorderThickness="4,0,0,0"
Background="#14D32F2F" Padding="10" Margin="0,3" CornerRadius="4">
<StackPanel Spacing="3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="{Binding Headline}" FontSize="13" FontWeight="SemiBold" TextWrapping="Wrap"/>
<TextBlock Grid.Column="1" Text="{Binding PeriodDisplay}" FontSize="10" Opacity="0.65"
Margin="10,0,0,0"/>
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Instruction}" FontSize="11" Opacity="0.75" TextWrapping="Wrap"
IsVisible="{Binding Instruction, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Wetterdaten © Deutscher Wetterdienst" FontSize="10" Opacity="0.5"/>
</StackPanel>
</Border>
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Heutige Stunden -->
@@ -415,6 +415,45 @@
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="480">
<TextBlock Text="Schulstandort &amp; Wetter" FontSize="16" FontWeight="SemiBold"/>
<TextBlock Text="Die Adresse wird beim Speichern einmalig auf dem LehrerApp-Server geocodiert. Der Server ruft damit Wettervorhersagen und amtliche Warnungen des DWD ab."
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
<StackPanel Spacing="4">
<TextBox Text="{Binding SchoolName}" PlaceholderText="Name der Schule"/>
<TextBlock Text="{Binding SchoolNameError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolNameError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBox Text="{Binding SchoolStreet}" PlaceholderText="Straße und Hausnummer"/>
<TextBlock Text="{Binding SchoolStreetError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolStreetError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Grid ColumnDefinitions="120,8,*">
<TextBox Grid.Column="0" Text="{Binding SchoolPostalCode}" PlaceholderText="PLZ" MaxLength="5"/>
<TextBox Grid.Column="2" Text="{Binding SchoolCity}" PlaceholderText="Ort"/>
</Grid>
<Grid ColumnDefinitions="120,8,*">
<TextBlock Grid.Column="0" Text="{Binding SchoolPostalCodeError}" Foreground="Red" FontSize="11"
TextWrapping="Wrap"
IsVisible="{Binding SchoolPostalCodeError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Grid.Column="2" Text="{Binding SchoolCityError}" Foreground="Red" FontSize="11"
IsVisible="{Binding SchoolCityError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
<Button Content="Adresse prüfen und speichern" Command="{Binding SaveSchoolLocationCommand}"
HorizontalAlignment="Left" Margin="0,4,0,0"/>
<TextBlock Text="{Binding SchoolLocationStatus}" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding SchoolLocationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}" CornerRadius="5" Padding="10"
IsVisible="{Binding ResolvedSchoolAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<StackPanel Spacing="3">
<TextBlock Text="Gefundener Standort" FontSize="11" FontWeight="SemiBold" Opacity="0.7"/>
<TextBlock Text="{Binding ResolvedSchoolAddress}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</Border>
<TextBlock Text="Geocodierung © OpenStreetMap-Mitwirkende · Wetterdaten © Deutscher Wetterdienst"
FontSize="10" Opacity="0.55" TextWrapping="Wrap"/>
</StackPanel>
<Separator Margin="0,4"/>
<StackPanel Spacing="4">
<TextBlock Text="Bundesland (für Feiertage)" FontSize="13" FontWeight="SemiBold"/>
<ComboBox ItemsSource="{Binding StateOptions}" SelectedItem="{Binding SelectedStateName}"
@@ -31,6 +31,7 @@ public partial class SettingsView : UserControl
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
_ = vm.LoadSchoolLocationCommand.ExecuteAsync(null);
}
}