Dashboard-ViewModel: Services kombinieren und Gruppen-Layout (Teil 18)
Das Dashboard ist die erste Seite, die der Benutzer nach dem Start sieht. Es zeigt Neuerscheinungen, Favoriten, angefangene Serien und zuletzt gehörte Folgen — alles auf einen Blick. Hinter dieser Übersicht steckt ein ViewModel, das Daten aus mehreren DataServices zusammenführt und für die Anzeige aufbereitet. Dabei zeigt sich ein wiederkehrendes Muster: Wie koordiniert man mehrere Datenquellen auf einer einzigen Seite, ohne dass der Code unübersichtlich wird?
Mehrere Services, eine Seite
Das Dashboard braucht Daten aus drei verschiedenen Quellen. Welche Serien hat der Nutzer abonniert? Das beantwortet ISeriesDataService.GetSubscribedAsync(). Welche Episoden gibt es pro Serie? Dafür ist IEpisodeDataService.GetBySeriesIdAsync() zuständig. Und wie weit ist der Nutzer mit jeder Episode? Das liefert IPlaybackStateDataService.GetByEpisodeIdAsync(). Diese drei Datenquellen werden in DashboardViewModel.LoadAsync() nacheinander abgefragt. Der Scope-Mechanismus aus den DataServices sorgt dafür, dass SQLite dabei nicht in Konflikte gerät — jeder Aufruf öffnet seinen eigenen Datenbankzugriff.
Fünf Abschnitte auf einer Seite
Das Dashboard zeigt fünf Bereiche, die jeweils nur erscheinen, wenn sie Daten enthalten. Neuerscheinungen zeigt Folgen aller abonnierten Serien, die über die iTunes API im konfigurierbaren Zeitfenster gefunden wurden. Standardmäßig sind das 90 Tage ab dem letzten App-Start. Die Ergebnisse sind monatlich gruppiert: ganz oben „Angekündigt“ für Episoden mit einem Erscheinungsdatum in der Zukunft, darunter die Monate absteigend. Badges zeigen den Status an — „Angekündigt“ in Blau und „Neu“ in Grün für Folgen, die maximal sieben Tage alt sind. Die Ergebnisse werden in SQLite gecacht und beim App-Start sofort angezeigt.
Favoriten zeigt alle als Favorit markierten Serien für schnellen Zugriff. Weiterhören listet Serien, bei denen der Nutzer mindestens eine Folge gehört hat, aber noch nicht alle — pro Serie eine Kachel mit der Anzahl ungehörter Folgen. Läuft gerade zeigt Episoden mit angefangenem, aber nicht abgeschlossenem Playback. Und Zuletzt gehört listet die letzten Serien nach Wiedergabezeitpunkt.
Die Klassifizierung in „erschienen“ vs. „angekündigt“ findet im ViewModel statt, nicht in der Datenbank. Aus der flachen Liste der gecachten iTunes-Ergebnisse werden Kacheln erzeugt und nach Jahr und Monat gruppiert. „Neuerscheinung“ bedeutet dabei nicht „ungehört“ — eine Episode muss über die iTunes API gefunden werden und ihr Erscheinungsdatum im konfigurierbaren Zeitfenster liegen. Gehörte Folgen werden herausgefiltert.
Warum ein Zeitfenster statt „alle ungehörten Folgen“?
Ohne Zeitfenster würde das Dashboard bei vielen Serien hunderte Ergebnisse anzeigen. Der Nutzer will sehen, was seit seinem letzten Besuch neu erschienen ist. Das Zeitfenster (NewReleaseDays, Standard 90 Tage) ist in den Einstellungen konfigurierbar:
DateTime cutoffDate = (appSettings.LastAppStart ?? DateTime.UtcNow).AddDays(-appSettings.NewReleaseDays);
Episoden ohne ReleaseDate werden nicht als Neuerscheinung angezeigt — nur iTunes-Ergebnisse mit gültigem Datum landen im Cache. Der Abschnitt „Weiterhören“ berechnet pro Serie die Differenz zwischen Gesamtfolgen und vollständig gehörten Folgen. Ein Klick auf die Kachel navigiert direkt zur Seriendetailseite.
Cover-Priorität: Lokal, iTunes, Serie
Alle Episoden-Kacheln zeigen bevorzugt das Episoden-Cover, nicht das Serien-Cover. Bei Neuerscheinungen kommt ein zusätzlicher Fallback hinzu: das iTunes-Album-Cover. Die Reihenfolge ist klar definiert. Zuerst wird nach einem lokalen Episoden-Cover gesucht — in der CoverImages-Tabelle, als cover.jpg im Ordner oder im ID3-Tag. Ist keines vorhanden, wird das iTunes-Album-Cover aus der gecachten CoverUrl verwendet, auf 600×600 Pixel hochskaliert. Als letzter Fallback dient das Serien-Cover.
Der zentrale Anlaufpunkt für Cover ist der CoverService in der App-Schicht, registriert als Singleton. Er liest und schreibt Cover in der CoverImages-Tabelle, die als dedizierte Tabelle Cover-Binärdaten von den Entitäten entkoppelt. Beim App-Start lädt der BackgroundCoverService vorhandene lokale Cover in diese Tabelle, sodass sie danach ohne Dateisystemzugriff verfügbar sind. ViewModels rufen CoverService.GetEpisodeCoverImageAsync() bzw. GetSeriesCoverImageAsync() auf, statt direkt auf Entitäts-Properties zuzugreifen.
Außerdem zeigt jede Neuerscheinungs-Kachel die Folgennummer (z.B. „Folge 229″), wenn die Episode eine EpisodeNumber in der Datenbank hat. Episoden, die vollständig gehört wurden (PlaybackStatus.Finished), zeigen oben links auf dem Cover einen grünen Haken. Der Haken aktualisiert sich automatisch, wenn der Nutzer eine Folge über das Kontextmenü als gehört markiert.
Neuerscheinungen: Monatlich gruppiert
Die Neuerscheinungen werden nach Monat gruppiert. Ganz oben steht „Angekündigt“ für zukünftige Episoden, darunter die Monate absteigend — etwa „März 2026″, „Februar 2026″. Pro Monat gibt es eine horizontale Kachelreihe. Auf jeder Kachel sind Serienname, Folgennummer, Datum und ein Badge sichtbar. Das Datenmodell dafür ist NewEpisodesGroupViewModel mit GroupLabel (z.B. „März 2026″) und SortKey für die Reihenfolge. Im XAML ist das ein verschachteltes ItemsControl — das äußere iteriert über die Monatsgruppen, das innere horizontal über die Episoden jedes Monats.
Jede Kachelreihe liegt in einem horizontalen ScrollViewer mit Pfeil-Buttons zum Scrollen. Horizontale ScrollViewer in WinUI 3 schlucken vertikale Mausrad-Events — ein PointerWheelChanged-Handler im Code-Behind leitet das Scroll-Delta an den äußeren Haupt-ScrollViewer weiter.
Kacheln als eigene ViewModels
Jede Episodenkachel auf dem Dashboard ist ein eigenes NewEpisodeCardViewModel. Das mag zunächst übertrieben wirken, aber es hat einen klaren Grund: Jede Kachel hat eigene Aktionen.
public ICommand PlayCommand { get; }
public ICommand MarkAsPlayedCommand { get; }
public ICommand MarkAsUnplayedCommand { get; }
Diese Commands brauchen Zugriff auf den Datenbankservice, um den Wiedergabestatus zu aktualisieren, auf den Fehler-Dialog für Hinweise und auf den Player-Service, um die Wiedergabe zu starten. All das wird im Konstruktor übergeben, nicht über globale Zustände. Das DashboardViewModel erstellt die Kacheln und reicht dabei seine eigenen Service-Referenzen weiter. So müssen die Services nicht mehrfach registriert werden, und die Kacheln können trotzdem vollständig autonom handeln.
Favoriten direkt auf dem Dashboard verwalten
Das Dashboard zeigt favorisierte Serien als Kachelreihe. Jede Kachel hat ein Kontextmenü mit dem Eintrag „Aus Favoriten entfernen“. Nach Bestätigung wird die Serie aus den Favoriten genommen und die Kachel verschwindet sofort aus der Liste. Das funktioniert über ein Event-Muster: Das FavoriteSeriesCardViewModel löst RemovedFromFavorites aus, nachdem der Nutzer bestätigt hat. Das DashboardViewModel abonniert dieses Event und baut die Favoritenliste ohne die entfernte Serie neu auf:
card.RemovedFromFavorites += OnSeriesRemovedFromFavorites;
Dieses Muster vermeidet, dass die Kachel das übergeordnete ViewModel kennen muss. Die Kachel signalisiert nur, dass etwas passiert ist — das Dashboard entscheidet, was daraus folgt.
Bestätigungs-Dialoge trennen
„Als gehört markieren“ und „Als ungehört markieren“ zeigen beide vorher einen Bestätigungs-Dialog. Das ist ein anderer Dialog-Typ als ein Fehler-Dialog: Er hat zwei Schaltflächen (Ja/Abbrechen) und gibt zurück, was der Benutzer gewählt hat. Deshalb gibt es ein eigenes Interface für diese Funktion:
public interface IConfirmationDialogService
{
Task<bool> ConfirmAsync(string title, string message);
}
Hätte man IErrorDialogService erweitert, wäre das Interface für zwei verschiedene Zwecke zuständig. Das macht Interfaces schwerer testbar und schwerer verständlich. Ein Interface, eine Aufgabe — das ist hier die richtige Entscheidung. Der Rückgabewert bool gibt direkt an, ob der Benutzer bestätigt hat. Das ViewModel entscheidet dann, was passiert:
bool confirmed = await _confirmationDialogService.ConfirmAsync(
"Als gehört markieren?",
"Diese Episode wird als vollständig gehört gespeichert.");
if (!confirmed)
{
return;
}
// ... PlaybackState aktualisieren
Sichtbarkeit ohne Converter
In WinUI 3 wandelt x:Bind einen bool-Wert nicht automatisch in einen Sichtbarkeits-Status um. Das muss man explizit lösen. Im Dashboard wurde das durch eine eigene Property vom Typ Visibility im ViewModel gelöst:
public Visibility NewEpisodeGroupsVisibility =>
_newEpisodeGroups.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
In WPF gibt es dafür eingebaute Converter. In WinUI 3 muss man sich entscheiden: entweder schreibt man einen eigenen Converter (eine Klasse, die IValueConverter implementiert) oder man legt die Sichtbarkeit direkt im ViewModel fest. Die zweite Variante ist einfacher, auch wenn Visibility eine UI-spezifische Klasse ist. Das ist in WinUI-3-Projekten eine akzeptable Abwägung, solange die Entscheidungslogik im ViewModel bleibt.
Fortschrittsbalken: Wann wird er angezeigt?
Unterhalb jeder Kachel liegt ein ProgressBar, der nur sichtbar ist, wenn die Episode begonnen, aber noch nicht abgeschlossen wurde. Die Sichtbarkeit wird ebenfalls über eine Visibility-Property im Kachel-ViewModel gesteuert:
public Visibility ProgressBarVisibility =>
_status == PlaybackStatus.InProgress ? Visibility.Visible : Visibility.Collapsed;
Der Wert des Balkens (ProgressPercent) ist eine Zahl zwischen 0 und 100, die aus LastPosition / Duration * 100 berechnet wird. Diese Berechnung passiert beim Laden in DashboardViewModel, nicht im Kachel-ViewModel — weil an diesem Punkt beide Werte bekannt sind und die Kachel sie nur noch anzeigen muss.
Neuerscheinungen: DB-Cache und Hintergrund-Update
Die iTunes-Abfrage für Neuerscheinungen dauert bei vielen Serien mehrere Minuten wegen Rate-Limiting. Deshalb werden die Ergebnisse in einer SQLite-Tabelle (CachedNewReleases) gecacht. Beim App-Start werden die gecachten Ergebnisse sofort angezeigt. Im Hintergrund startet ein Update, das nur alle 24 Stunden die iTunes API abfragt:
// Schritt 1: Gecachte Ergebnisse sofort anzeigen
await BuildNewReleaseTilesFromCacheAsync(subscribedSeries);
// Schritt 2: Im Hintergrund aktualisieren (fire-and-forget)
_ = RefreshNewReleaseCacheAsync(subscribedSeries, cutoffDate);
Wenn kein Internet verfügbar ist, bleibt der Neuerscheinungen-Abschnitt auf dem Stand des letzten erfolgreichen Checks. Kein Fehlerdialog, kein Absturz — die App funktioniert offline mit den gecachten Daten weiter.
Verschiebbare Dialoge
WinUI-3-ContentDialogs sind standardmäßig zentriert und fest positioniert. Das ist problematisch, wenn der Nutzer hinter dem Dialog etwas sehen muss — etwa bei der Cover-Auswahl, wo das aktuelle Cover von der Kachel verdeckt wird. EchoPlay macht alle Dialoge per Drag verschiebbar. Ein statischer Helper ContentDialogDragHelper.MakeDraggable(dialog) wird vor ShowAsync() aufgerufen und registriert Pointer-Events auf dem Dialog. PointerPressed im oberen Bereich (erste 60 Pixel, der Titelbereich) startet den Drag, PointerMoved verschiebt den Dialog per TranslateTransform, und PointerReleased beendet den Vorgang.
Der Helper wird in den beiden zentralen Dialog-Services (ErrorDialogService und ConfirmationDialogService) und in allen manuell erstellten Dialogen der Pages aufgerufen. So ist sichergestellt, dass jeder Dialog in der App verschiebbar ist — ohne dass einzelne Stellen vergessen werden können.
Seitennavigation löst Laden aus
Das Laden passiert nicht im Konstruktor des ViewModels, sondern in OnNavigatedTo der Seite:
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
_ = ViewModel.LoadAsync();
}
Das _ = ist kein Versehen. Da OnNavigatedTo kein async-Rückgabetyp hat, kann man await nicht direkt verwenden. Das Fire-and-Forget-Muster ist hier bewusst eingesetzt, weil der globale Exception-Handler der App unbehandelte Fehler abfängt. Der Ladeindikator (ProgressRing) überlagert den Inhalt, solange IsLoading wahr ist.
Drag & Drop: Favoriten-Reihenfolge per Maus umsortieren
Die Favoriten-Kacheln auf dem Dashboard lassen sich per Drag & Drop umsortieren. Die Neuerscheinungen werden dagegen automatisch nach Monat sortiert und sind nicht verschiebbar. Die Persistenz nutzt eine DashboardPositions-Tabelle mit dem Section-Schlüssel „Favoriten“.
Beim Übertragen des Musters gab es ein Problem: Die Favoriten-Kacheln waren ursprünglich als 160×160 Pixel große Buttons implementiert — ein Button, der das gesamte Cover als Inhalt zeigt und bei Klick zur Seriendetailseite navigiert. Ein Button in voller Kachelgröße schluckt aber alle Pointer-Events. Das ListView empfängt dadurch nie ein PointerPressed-Event auf dem ListViewItem und kann den Drag-Vorgang nicht starten.
Die Lösung besteht aus zwei Teilen. Erstens wird das Cover direkt als Image-Element im DataTemplate platziert, nicht mehr in einem Button verpackt. Ein Image ist nicht interaktiv — es fängt keine Pointer-Events ab. Die Navigation zur Detailseite erfolgt stattdessen über ListView.IsItemClickEnabled und das ItemClick-Event. Das ListView unterscheidet intern zwischen einem kurzen Klick (ItemClick) und einem langen Drücken mit Bewegung (Drag & Drop). Zweitens liegt ein kleines Gripper-Symbol als halbtransparentes Overlay oben links auf jeder Kachel, das dem Nutzer signalisiert, dass die Kachel verschiebbar ist.
Der Kontextmenü-Button oben rechts bleibt ein Button und fängt weiterhin Pointer-Events ab. Das ist gewollt: Der Nutzer soll das Menü öffnen können, ohne versehentlich einen Drag auszulösen. Da der Button nur 28×28 Pixel groß ist und in der Ecke liegt, bleibt genügend Fläche auf der Kachel, um den Drag zu starten.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu