Projekt D · Teil 3 · Stufe 3 – Fortgeschritten

Rezept-App mit Online-API (Teil 3)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil machst du aus der Rezept-App eine runde Anwendung: mit einer Kategorieauswahl aus der API, einer Online-Suche mit Debouncing, „Ziehen zum Aktualisieren“, einem Zufallsrezept, Favoriten in einem geteilten Store über die Umgebung und präzisen Fehlermeldungen.

Ausgangslage

Nach Teil 2 hat die App eine Liste von Desserts und eine schöne Detailansicht. Jetzt kommen die Funktionen, die Nutzer erwarten:

  1. Kategorien wechseln, statt nur Desserts zu sehen,
  2. Suchen über alle Rezepte,
  3. Aktualisieren per Ziehen,
  4. ein Zufallsrezept für Unentschlossene,
  5. Favoriten merken,
  6. verständliche Fehler.

Schritt 1: Kategorien laden

Fortgeschritten

Aufgabe

  1. Leg ein Modell Category (Decodable, Identifiable, Hashable) mit id, name und thumbnail an, mit CodingKeys für idCategory, strCategory und strCategoryThumb, sowie eine Antwort CategoryResponse mit categories: [Category].
  2. Ergänze MealAPI um func categories() async throws -> [Category].
LösungErst selbst probieren!
Swift
struct CategoryResponse: Decodable {
    let categories: [Category]
}

struct Category: Decodable, Identifiable, Hashable {
    let id: String
    let name: String
    let thumbnail: String?

    enum CodingKeys: String, CodingKey {
        case id = "idCategory"
        case name = "strCategory"
        case thumbnail = "strCategoryThumb"
    }
}

In MealAPI:

Swift
func categories() async throws -> [Category] {
    let response: CategoryResponse = try await fetch("categories.php")
    return response.categories
}

Hier ist categories nicht optional: Die API liefert immer eine Liste, und ohne Kategorien wäre ohnehin etwas grundsätzlich falsch.

Schritt 2: Kategorie wählen

Fortgeschritten

Aufgabe

Erweitere MealListViewModel:

  • categories: [Category] (Startwert leer),
  • func loadCategories() async, die Fehler stillschweigend ignoriert: Ohne Kategorien bleibt einfach die aktuelle Auswahl,
  • func select(_ category: String) async, die category setzt und neu lädt.

Zeig in der Toolbar von MealListView ein Menu mit einem Picker über alle Kategorien (Lektion 2.18). Wird eine andere gewählt, lädt die Liste neu.

LösungErst selbst probieren!

Im ViewModel:

Swift
var categories: [Category] = []

func loadCategories() async {
    guard categories.isEmpty else { return }
    categories = (try? await api.categories()) ?? []
}

In der View am content:

Swift
.toolbar {
    Menu("Kategorie", systemImage: "line.3.horizontal.decrease.circle") {
        Picker("Kategorie", selection: $viewModel.category) {
            ForEach(viewModel.categories) { category in
                Text(category.name).tag(category.name)
            }
        }
    }
}
.task {
    await viewModel.loadCategories()
}
.task(id: viewModel.category) {
    await viewModel.load()
}

Statt einer eigenen select-Methode reicht hier .task(id: viewModel.category) (Lektion 3.7): Ändert der Picker die Kategorie über das Binding, wird automatisch neu geladen, und ein noch laufender Ladevorgang abgebrochen. Ersetze damit auch das bisherige .task, das nur bei .idle geladen hat.

Weil MealListView das ViewModel mit @State besitzt, liefert $viewModel.category direkt ein Binding (Lektion 3.2).

Schritt 3: Suche

Jetzt eine Suche über alle Rezepte, unabhängig von der Kategorie. Läuft eine Suche, zeigt die Liste die Suchergebnisse. Ist das Suchfeld leer, wieder die Kategorie.

Profi

Aufgabe

  1. Ergänze MealAPI um search(_ query: String) async throws -> [Meal] mit search.php.
  2. Ergänze das ViewModel um searchText und eine Methode refresh() async, die je nach searchText sucht oder die Kategorie lädt.
  3. Setze in der View .searchable und ersetze .task(id: viewModel.category) durch ein .task(id:), das auf Kategorie und Suchtext reagiert, mit 400 ms Pause (Lektion 3.12).
  4. Behandle Abbrüche, damit beim schnellen Tippen keine Fehler aufblitzen.

Tipp: Für .task(id:) brauchst du einen einzigen Equatable-Wert. Eine kleine Struct mit beiden Werten eignet sich.

LösungErst selbst probieren!

In MealAPI:

Swift
func search(_ query: String) async throws -> [Meal] {
    let response: MealResponse = try await fetch("search.php", query: ["s": query])
    return response.meals ?? []
}

Im ViewModel:

Swift
var searchText = ""

struct Query: Equatable {
    let category: String
    let search: String
}

var query: Query {
    Query(category: category, search: searchText.trimmingCharacters(in: .whitespaces))
}

var title: String {
    query.search.isEmpty ? category : "Suche"
}

func refresh() async {
    do {
        let meals = if query.search.isEmpty {
            try await api.meals(in: category)
        } else {
            try await api.search(query.search)
        }
        state = .loaded(meals)
    } catch is CancellationError {
        return
    } catch let error as URLError where error.code == .cancelled {
        return
    } catch {
        print(error)
        state = .failed(message(for: error))
    }
}

func load() async {
    state = .loading
    await refresh()
}

Die Methode message(for:) folgt in Schritt 6. Bis dahin kannst du einen festen Text verwenden.

In der View:

Swift
content
    .navigationTitle(viewModel.title)
    .searchable(text: $viewModel.searchText, prompt: "Alle Rezepte durchsuchen")
    .task(id: viewModel.query) {
        if !viewModel.query.search.isEmpty {
            try? await Task.sleep(for: .milliseconds(400))
            guard !Task.isCancelled else { return }
        }
        await viewModel.load()
    }

Die Pause gibt es nur beim Suchen. Beim Wechsel der Kategorie soll sofort geladen werden.

Und für leere Suchergebnisse im content:

Swift
case .loaded(let meals) where meals.isEmpty:
    if viewModel.query.search.isEmpty {
        ContentUnavailableView("Keine Rezepte", systemImage: "fork.knife")
    } else {
        ContentUnavailableView.search(text: viewModel.query.search)
    }

Schritt 4: Aktualisieren

Anfänger

Aufgabe

Ergänze an der Liste .refreshable, das viewModel.refresh() aufruft. Weil refresh() den Zustand nicht auf .loading setzt, bleibt die Liste beim Ziehen sichtbar.

LösungErst selbst probieren!
Swift
case .loaded(let meals):
    List(meals) { meal in
        NavigationLink(value: meal) {
            MealRow(meal: meal)
        }
    }
    .listStyle(.plain)
    .refreshable {
        await viewModel.refresh()
    }

Genau dafür haben wir in Schritt 3 load() und refresh() getrennt: load() zeigt den großen Ladekreis, refresh() aktualisiert still im Hintergrund (Lektion 3.11).

Schritt 5: Favoriten in der Umgebung

Favoriten sollen in der Liste und in der Detailansicht sichtbar und änderbar sein. Das ist ein klassischer Fall für einen geteilten Store in der Umgebung (Lektionen 3.1 und 3.3).

Fortgeschritten

Aufgabe

  1. Leg eine @Observable-Klasse FavoritesStore an, die die IDs der Favoriten in einem Set<String> speichert, mit contains(_ meal:) und toggle(_ meal:). Die Klasse braucht id als Parameter, damit sie mit Meal und MealDetail funktioniert. Mach daher contains(id:) und toggle(id:).
  2. Erzeuge sie in der App-Struct und leg sie mit .environment(...) in die Umgebung.
  3. Zeig in MealRow ein kleines gefülltes Herz, wenn das Rezept ein Favorit ist.
  4. Ergänze in der Toolbar der Detailansicht einen Herz-Button, der den Favoriten umschaltet.
LösungErst selbst probieren!
Swift
import Observation

@Observable
class FavoritesStore {
    private(set) var ids: Set<String> = []

    func contains(id: String) -> Bool {
        ids.contains(id)
    }

    func toggle(id: String) {
        if ids.contains(id) {
            ids.remove(id)
        } else {
            ids.insert(id)
        }
    }
}

In RezepteApp.swift:

Swift
@main
struct RezepteApp: App {
    @State private var favorites = FavoritesStore()

    var body: some Scene {
        WindowGroup {
            MealListView()
                .environment(favorites)
        }
    }
}

In MealRow:

Swift
@Environment(FavoritesStore.self) private var favorites

// im HStack, nach dem Namen:
Spacer()
if favorites.contains(id: meal.id) {
    Image(systemName: "heart.fill")
        .foregroundStyle(.pink)
}

In MealDetailView am content:

Swift
@Environment(FavoritesStore.self) private var favorites

.toolbar {
    let isFavorite = favorites.contains(id: meal.id)
    Button(isFavorite ? "Favorit entfernen" : "Als Favorit merken",
           systemImage: isFavorite ? "heart.fill" : "heart") {
        favorites.toggle(id: meal.id)
    }
    .tint(.pink)
    .sensoryFeedback(.success, trigger: isFavorite)
}

Denk an die Previews: Jede View, die den Store aus der Umgebung liest, braucht dort .environment(FavoritesStore()), sonst stürzt die Preview ab (Lektion 3.3).

Achtung

Die Favoriten sind nach einem Neustart der App wieder weg, weil sie nur im Speicher liegen. In der nächsten Lektion lernst du @AppStorage. Damit kannst du sie mit wenigen Zeilen dauerhaft speichern. Eine Bonus-Aufgabe dort greift genau das auf.

Schritt 6: Präzise Fehlermeldungen

Fortgeschritten

Aufgabe

Ergänze das ViewModel um eine private Methode message(for error: Error) -> String, die unterscheidet:

  • URLError.notConnectedToInternet: Keine Internetverbindung
  • URLError.timedOut: Server antwortet nicht
  • APIError.badStatus: Problem beim Server
  • DecodingError: Daten konnten nicht gelesen werden
  • alles andere: allgemeine Meldung
LösungErst selbst probieren!
Swift
private func message(for error: Error) -> String {
    switch error {
    case let urlError as URLError where urlError.code == .notConnectedToInternet:
        "Keine Internetverbindung. Prüfe WLAN oder mobile Daten."
    case let urlError as URLError where urlError.code == .timedOut:
        "Der Server antwortet gerade nicht. Versuch es gleich noch einmal."
    case APIError.badStatus:
        "Beim Rezept-Server ist ein Problem aufgetreten. Versuch es später erneut."
    case is DecodingError:
        "Die Rezepte konnten nicht gelesen werden."
    default:
        "Die Rezepte konnten nicht geladen werden."
    }
}

Ein switch über einen Fehler mit Mustern wie as URLError where ..., APIError.badStatus und is DecodingError vereint alles, was du über Enums, Typumwandlung und Bedingungen gelernt hast. Weil jeder Fall nur einen Wert liefert, wird der switch als Ausdruck verwendet (Lektion 1.4).

Schritt 7: Zufallsrezept

Fortgeschritten

Aufgabe

Ergänze MealAPI um random() async throws -> Meal? (random.php). Füg in der Toolbar von MealListView einen Würfel-Button (dice) hinzu. Ein Tipp lädt ein zufälliges Rezept und öffnet sofort seine Detailansicht.

Tipp: Wertbasierte Navigation per Code geht mit einem NavigationStack(path:) und einem Array von Meals. Hängst du ein Meal an das Array an, öffnet sich die Detailansicht. Ausführlich lernst du das in Lektion 3.20.

LösungErst selbst probieren!

In der View:

Swift
@State private var path: [Meal] = []

var body: some View {
    NavigationStack(path: $path) {
        content
            // … wie bisher …
            .toolbar {
                ToolbarItem(placement: .topBarLeading) {
                    Button("Zufallsrezept", systemImage: "dice") {
                        Task {
                            if let meal = try? await MealAPI().random() {
                                path.append(meal)
                            }
                        }
                    }
                }
            }
    }
}

random.php liefert zwar die vollständigen Details, aber unser Meal liest daraus einfach nur id, name und thumbnail. Die Detailansicht lädt den Rest wie gewohnt nach.

Die fertige App

Erwartetes Ergebnis

Die App startet mit Desserts. Über das Filter-Symbol wechselst du zu Hähnchen, Pasta oder Vegetarisch. Oben suchst du über alle Rezepte, die Ergebnisse erscheinen kurz nach dem Tippen. Ziehen aktualisiert. Der Würfel öffnet ein Überraschungsrezept. Favoriten haben ein Herz. Und ohne Internet erklärt die App freundlich, was los ist.

Screenshot folgt: Fertige Rezept-App mit Suche und Kategorien

Was du in diesem Projekt angewendet hast

BausteinWo im ProjektLektion
@Observable, UmgebungFavoritesStore3.1, 3.3
@Bindable-Bindings auf ViewModelKategorie, Suchtext3.2
ViewModel nach BedarfListe mit, Details ohne3.5
async/await, .task, .task(id:)Laden, Neuladen, Debouncing3.6, 3.7
Abbruch und @MainActorSuche, ViewModel3.7, 3.8
Codable, eigener DecoderModelle, Zutaten3.9
URLSession, generische APIMealAPI3.10
LoadState, FehlermeldungenListe, Details3.11
searchable, refreshableSuche, Aktualisieren3.12

Geschafft

Herzlichen Glückwunsch! Deine App spricht mit dem Internet, und zwar so, wie es eine professionelle App tun sollte: mit Ladezuständen, Fehlerbehandlung, Abbruch veralteter Anfragen und einer sauberen Trennung von Netzwerk, Logik und Oberfläche. Diese Grundlage brauchst du in fast jeder echten App.

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Favoriten-Tab

Zeig unter der Kategorieliste eine Section „Favoriten“ mit allen gemerkten Rezepten. Weil der Store nur IDs kennt, musst du die Details dafür nachladen. Ein Weg: im FavoritesStore zusätzlich die Namen speichern.

Profi

Bonus 2: Kategorie-Raster

Ersetze das Kategorie-Menü durch einen eigenen Startbildschirm mit allen Kategorien als Kacheln mit Bild. Ein Tipp öffnet die Rezeptliste der Kategorie. Grids lernst du in Lektion 3.17. Wenn du vorgreifen willst: LazyVGrid.

Zusammenfassung

  • .task(id:) mit einem Wert aus Kategorie und Suchtext lädt neu, sobald sich eines davon ändert, und entprellt die Suche.
  • load() zeigt den großen Ladezustand, refresh() aktualisiert still, ideal für .refreshable.
  • Ein geteilter Store in der Umgebung macht Favoriten in Liste und Details verfügbar.
  • Ein switch über Fehler übersetzt technische Fehler in verständliche Meldungen.
  • NavigationStack(path:) ermöglicht Navigation per Code, etwa zum Zufallsrezept.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.