Teil 3: Der erste Meilenstein: Aktuelle Temperatur am aktuellen Ort – Den Code verstehen

Hallo und willkommen zurück zur Serie!

In Teil 2 haben wir unsere Werkzeuge geschärft und die Flutter-Entwicklungsumgebung auf deinem Windows-PC eingerichtet. Wir haben sogar die Standard-Flutter-App zum Laufen gebracht. Super!

Heute tauchen wir endlich in den eigentlichen Code unserer Wetter-App ein. Wir überspringen das manuelle Tippen jedes Zeichens und konzentrieren uns stattdessen darauf, den Code für unseren ersten Meilenstein zu verstehen: Die App soll die aktuelle Temperatur für deinen aktuellen GPS-Standort anzeigen.

Das Ziel für heute:

  • Wir schauen uns den vorbereiteten Code für Teil 3 an (den du aus einem Repository herunterladen kannst).
  • Wir zerlegen die Projektstruktur und verstehen, welche Datei wo hingehört und warum.
  • Wir verfolgen den Datenfluss: Wie kommt die Temperatur vom Internet auf deinen Bildschirm?
  • Wir beleuchten die wichtigsten Konzepte: Architektur, State Management mit Riverpod, Fehlerbehandlung und mehr.

Warum dieser Ansatz?

Eine App zu bauen ist wie ein Haus zu bauen. Man könnte einfach loslegen, aber ein guter Architekt plant zuerst. Wir haben den Code für diesen ersten Schritt bereits nach einem bewährten Plan (einer „sauberen Architektur“) strukturiert. Indem wir diesen fertigen, aber einfachen Code untersuchen, lernst du nicht nur, wie man eine Funktion implementiert, sondern auch, wie man Code organisiert, damit er später leicht erweitert, getestet und gewartet werden kann. Das ist entscheidend, um selbst gute Apps zu schreiben!

Schritt 1: Den Code holen

Der gesamte Code für diesen Teil der Serie ist in einem Git-Repository vorbereitet.

  1. Repository klonen (falls noch nicht geschehen):
    Öffne eine Eingabeaufforderung oder ein Terminal und navigiere zu dem Ordner, in dem du deine Projekte speichern möchtest (z.B. C:\dev\flutter_projekte\). Führe dann folgenden Befehl aus:
    git clone https://github.com/hschewe/flutter_weather_app_blog.git
    Wechsle in das neu erstellte Verzeichnis:
    bash cd flutter_weather_app_blog
  2. Den richtigen Stand auschecken: Für jeden Teil der Serie gibt es einen Git-Tag. Um den Code-Stand für Teil 3 zu bekommen, führe aus:
    git checkout part3-current-temp-gps.
    (Git meldet möglicherweise, dass du dich in einem ‚detached HEAD‘-Zustand befindest. Das ist normal und bedeutet, du schaust dir einen spezifischen Punkt in der Vergangenheit an. Du kannst den den Code untersuchen, ausführen und sogar temporäre Änderungen vornehmen. Wenn du zur normalen Entwicklung zurückkehren willst, kannst du einfach wieder den main-Branch auschecken (git checkout main)).
  3. Projekt in VS Code öffnen: Öffne VS Code und wähle File > Open Folder... und navigiere zum flutter_weather_app_blog-Ordner.
  4. Abhängigkeiten installieren: Öffne ein Terminal in VS Code (Terminal > New Terminal) und führe aus:
    flutter pub get
  5. Code generieren: Da wir Riverpod mit Code-Generierung verwenden, müssen wir diesen Schritt ausführen:
    dart run build_runner build --delete-conflicting-outputs
    Dieser Befehl liest spezielle Anmerkungen (@riverpod) im Code und erstellt automatisch benötigte Hilfsdateien (die auf .g.dart enden).

Jetzt ist der Code bereit zur Untersuchung!

Schritt 2: Der große Überblick – Die Architektur

Bevor wir in einzelne Dateien schauen, betrachten wir den Bauplan. Unsere App folgt einer Schichtenarchitektur, inspiriert von „Clean Architecture“. Stell dir vor, wir bauen Schichten wie bei einer Zwiebel:

+-------------------------------------------------+
| Presentation (UI) Layer                         | <--- Das, was der Nutzer sieht (Widgets, Screens)
|    - Widgets                                    |      Interagiert mit dem Application Layer
|    - Screens                                    |
|    - State Management (Notifier/Provider)       |
+-------------------------------------------------+
      ^                                      | Dependency Rule
      | Calls                                | (Innere Schichten kennen Äußere nicht)
+-------------------------------------------------+
| Application / Domain Layer                      | <--- Die Logik der App
|    - State Notifier (Logik-Orchestrierung)      |      Definiert, WAS die App tut
|    - Repository Interface (Datenvertrag)        |      Kennt nur Entities
|    - Entities (App-Datenstrukturen)             |
+-------------------------------------------------+
      ^                                      |
      | Implements / Calls                   |
+-------------------------------------------------+
| Data Layer                                      | <--- Datenbeschaffung & -speicherung
|    - Repository Implementation                  |      Implementiert den Vertrag
|    - Data Sources (API, GPS, DB)                |      Spricht mit der Außenwelt
|    - Models (API/DB-Datenstrukturen)            |
+-------------------------------------------------+
      ^ Depends on                             ^ Depends on
      |                                        |
+-------------------------------------------------+
| Core Layer                                      | <--- App-übergreifende Helfer
|    - Utils (Logger, Formatter)                  |      Von allen Schichten nutzbar
|    - Error Handling                             |
|    - Networking Client                          |
+-------------------------------------------------+
  • Core: Enthält grundlegende Helferlein, die überall gebraucht werden könnten (wie unser Logger).
  • Data: Kümmert sich darum, woher die Daten kommen (API, GPS) und wie sie technisch abgefragt werden. Kennt die genaue Struktur der externen Daten.
  • Domain/Application: Das Herzstück. Definiert, welche Daten die App braucht (Entities) und welche Operationen möglich sind (Repository Interface), aber nicht, wie sie beschafft werden. Hier sitzt auch die Logik, die auf Nutzeraktionen reagiert (Notifier). Diese Schicht sollte unabhängig von UI-Details oder spezifischen Datenbanken/APIs sein.
  • Presentation (UI): Zeigt die Daten an (Screens, Widgets) und nimmt Nutzereingaben entgegen. Sie spricht nur mit dem Application Layer (über den Notifier), um Daten zu bekommen oder Aktionen auszulösen.

Die goldene Regel: Abhängigkeiten zeigen immer nach innen! Die UI kennt die Application/Domain Layer, aber nicht die Data Layer. Die Domain Layer kennt niemanden außerhalb (außer Core). Das macht das System flexibel und testbar.

Schritt 3: Ein Rundgang durch die Ordner (lib/src/)

Schauen wir uns an, wie diese Architektur in unserer Ordnerstruktur abgebildet ist:

  • lib/main.dart: Der allererste Startpunkt. Initialisiert das Logging, WidgetsFlutterBinding (wichtig für Plugins) und startet die App innerhalb einer ProviderScope (notwendig für Riverpod).
  • lib/app.dart: Enthält das MaterialApp-Widget, das die grundlegende Struktur, das Theme (Aussehen) und die Startseite (WeatherScreen) unserer App definiert.
  • lib/src/core/: Unser Fundament.
    • error/: Enthält exceptions.dart (spezifische technische Fehler wie NetworkException) und failure.dart (abstraktere Fehler wie NetworkFailure, die die Logik verstehen kann). Diese Trennung hilft, Fehler sauber zu behandeln.
    • location/: location_service.dart kapselt die Interaktion mit dem geolocator-Paket. Alle GPS- und Berechtigungs-Anfragen laufen hierüber. Wird über Riverpod bereitgestellt (locationServiceProvider).
    • networking/: http_client.dart stellt eine globale Instanz des http.Client bereit (über httpClientProvider). Das macht es einfach, ihn in Tests durch einen Mock zu ersetzen.
    • utils/: Enthält Helfer wie logger.dart (für strukturierte Logs) und date_formatter.dart (um z.B. die Uhrzeit anzuzeigen).
    • constants/: app_constants.dart sammelt zentrale Konstanten (hier erstmal nur myLocationLabel).
  • lib/src/features/weather/: Alles, was mit der Wetterfunktion zu tun hat.
    • data/: Datenbeschaffung.
      • models/: current_weather_model.dart, forecast_response_model.dart. Diese Dart-Klassen spiegeln exakt die Struktur des JSON wider, das wir von der Open-Meteo API bekommen. Sie enthalten fromJson-Methoden, um JSON in Dart-Objekte umzuwandeln.
      • datasources/: weather_api_service.dart. Spricht über den http.Client mit der Open-Meteo API (getCurrentWeather-Methode). Parst die JSON-Antwort mithilfe der Models und wirft spezifische Exceptions (ApiException, NetworkException, DataParsingException). Wird über Riverpod bereitgestellt (weatherApiServiceProvider).
      • repositories/: weather_repository_impl.dart. Die konkrete Implementierung unseres Datenvertrags. Diese Klasse kennt den WeatherApiService und den LocationService. Sie ruft deren Methoden auf, fängt deren Exceptions ab und wandelt sie in Failures um (z.B. wird NetworkException zu NetworkFailure). Sie wandelt auch die API-Models in die App-internen Entities um. Wird über Riverpod bereitgestellt (weatherRepositoryProvider).
    • domain/: Das Kernstück der App-Logik.
      • entities/: location_info.dart, current_weather_data.dart. Einfache Dart-Klassen, die die Daten repräsentieren, wie sie die App intern benötigt, unabhängig von der API. Nutzen Equatable für einfache Vergleiche.
      • repositories/: weather_repository.dart. Das ist nur ein „Interface“ (abstrakte Klasse). Es definiert, was man mit Wetterdaten tun können muss (z.B. getWeatherForLocation, getCurrentLocationCoordinates), aber nicht wie. Das ist der Vertrag, den die WeatherRepositoryImpl erfüllen muss.
    • application/: Die Orchestrierung.
      • weather_state.dart: Definiert, wie der Zustand des Wetter-Features aussieht: ein enum WeatherStatus (initial, loading, success, failure), die eigentlichen CurrentWeatherData, der selectedLocation und ein optionales Failure-Objekt. Nutzt Equatable und copyWith.
      • weather_notifier.dart: Die Steuerzentrale. Ein StateNotifier, der den WeatherState hält und aktualisiert. Er bekommt das WeatherRepository übergeben (Dependency Injection durch Riverpod). Enthält die Logik für fetchWeatherForCurrentLocation und refreshWeatherData. Er ruft Methoden im Repository auf, behandelt das Either<Failure, Success>-Ergebnis und aktualisiert den state entsprechend.
    • presentation/: Die Benutzeroberfläche.
      • providers/: weather_providers.dart. Definiert den weatherNotifierProvider, der den WeatherNotifier erstellt und der UI zur Verfügung stellt.
      • widgets/: Kleine, wiederverwendbare UI-Teile. location_header.dart zeigt den Ortsnamen, current_temperature_display.dart zeigt die Temperatur und Zeit. Sie bekommen ihre Daten von außen übergeben.
      • screens/: weather_screen.dart. Der Hauptbildschirm. Ein ConsumerStatefulWidget, das über ref.watch(weatherNotifierProvider) den aktuellen WeatherState bekommt. Basierend auf dem status im State, zeigt es entweder einen Ladeindikator, die Wetter-Widgets oder eine Fehlermeldung (_buildErrorWidget). Es nutzt ref.read(weatherNotifierProvider.notifier) um Aktionen im Notifier auszulösen (initiales Laden, Refresh). Der RefreshIndicator ermöglicht Pull-to-Refresh. Der AnimatedSwitcher sorgt für weiche Übergänge.

Schritt 4: Den Datenfluss verstehen

Wie hängt das alles zusammen, wenn die App startet?

  1. Start (main.dart -> app.dart -> WeatherScreen): Die App wird initialisiert, ProviderScope wird erstellt. WeatherScreen wird angezeigt.
  2. Initial Load (WeatherScreen.initState): Der Screen merkt, dass er neu ist (initialState.status == WeatherStatus.initial) und triggert über ref.read(weatherNotifierProvider.notifier).fetchWeatherForCurrentLocation() den Ladevorgang im Notifier.
  3. Loading State (WeatherNotifier): Der Notifier setzt sofort seinen state auf status: WeatherStatus.loading.
  4. UI Reaction (WeatherScreen): Da der Screen via ref.watch auf den State hört, wird er neu gebaut und zeigt jetzt (im _buildContent) einen Ladeindikator an.
  5. Get Coordinates (WeatherNotifier -> WeatherRepository -> LocationService):
    • Notifier ruft _weatherRepository.getCurrentLocationCoordinates() auf.
    • Das Repo (WeatherRepositoryImpl) ruft _locationService.getCurrentPosition() auf.
    • Der LocationService interagiert mit dem geolocator, fragt ggf. nach Berechtigungen und holt die Position.
    • Wenn erfolgreich, gibt der Service die Position zurück. Wenn ein Fehler auftritt (z.B. Berechtigung verweigert), wirft er eine LocationException.
    • Das Repo fängt die Exception, wandelt sie ggf. in eine Failure (PermissionFailure, LocationFailure) um und gibt Left(failure) zurück. Bei Erfolg erstellt es LocationInfo und gibt Right(locationInfo) zurück.
  6. Handle Coordinates Result (WeatherNotifier):
    • Der Notifier erhält das Either. Bei Left(failure) setzt er den state auf status: WeatherStatus.failure mit dem failure-Objekt -> Die UI zeigt die Fehlermeldung.
    • Bei Right(locationInfo) geht es weiter. Der Notifier versucht noch, den Namen via _weatherRepository.getLocationDisplayName zu holen (was in Teil 3 noch nicht viel tut) und ruft dann _fetchWeatherDataAndUpdateState(finalLocationInfo) auf.
  7. Get Weather Data (WeatherNotifier -> WeatherRepository -> WeatherApiService):
    • Notifier ruft _weatherRepository.getWeatherForLocation(locationInfo) auf.
    • Das Repo (WeatherRepositoryImpl) ruft _apiService.getCurrentWeather(...) auf.
    • Der WeatherApiService baut die URL, macht den http.get-Aufruf, parst das JSON in ForecastResponseModel, extrahiert CurrentWeatherModel. Bei Fehlern (Netzwerk, API-Status, Parsing) wirft er NetworkException, ApiException, DataParsingException.
    • Das Repo fängt diese Exceptions, wandelt sie in Failures (NetworkFailure, ServerFailure) um und gibt Left(failure) zurück. Bei Erfolg wandelt es CurrentWeatherModel in CurrentWeatherData (unser App-Entity) um und gibt Right(weatherData) zurück.
  8. Handle Weather Result (WeatherNotifier):
    • Der Notifier erhält das Either. Bei Left(failure) setzt er den state auf status: WeatherStatus.failure mit dem failure-Objekt -> Die UI zeigt die Fehlermeldung.
    • Bei Right(weatherData) setzt er den state auf status: WeatherStatus.success, speichert weatherData und locationInfo im State und löscht den Fehler (clearError: true).
  9. UI Reaction (WeatherScreen): Der Screen hört wieder auf die State-Änderung (ref.watch). Er wird neu gebaut, _buildContent erkennt WeatherStatus.success und zeigt jetzt LocationHeader und CurrentTemperatureDisplay mit den Daten aus dem state an.

Schritt 5: Schlüsselkonzepte im Code

  • Riverpod für State Management & Dependency Injection:
    • ProviderScope (in main.dart): Macht Provider global verfügbar.
    • @riverpod / ...Provider (z.B. in location_service.dart, weather_repository_impl.dart): Definiert, wie eine Instanz eines Service oder Repositories erstellt wird. Riverpod kümmert sich darum, dass nur eine Instanz erstellt und wiederverwendet wird. Das ist Dependency Injection: Komponenten bekommen ihre Abhängigkeiten (wie den http.Client oder den LocationService) „injiziert“, statt sie selbst zu erstellen.
    • StateNotifierProvider (weather_providers.dart): Ein spezieller Provider für unseren WeatherNotifier, der dessen Zustand (WeatherState) verwaltet.
    • ConsumerWidget / ConsumerStatefulWidget (WeatherScreen): Widgets, die auf Provider „hören“ können.
    • ref.watch() (im build von WeatherScreen): Liest den Wert eines Providers und baut das Widget neu, wenn sich der Wert (hier der WeatherState) ändert.
    • ref.read() (im initState oder in Callbacks wie onPressed von WeatherScreen): Liest den Wert eines Providers einmalig, ohne auf Änderungen zu hören. Wird verwendet, um Methoden im Notifier aufzurufen.
  • Fehlerbehandlung (Either, Failure, Exception):
    • Services (LocationService, WeatherApiService) werfen spezifische Exceptions bei technischen Problemen.
    • Das Repository (WeatherRepositoryImpl) fängt diese Exceptions und wandelt sie in allgemeinere Failures um. Es gibt das Ergebnis als Either<Failure, Success> zurück – ein klarer Weg, um Erfolg oder Misserfolg zu signalisieren, ohne Exceptions durch die ganze App zu werfen.
    • Der Notifier (WeatherNotifier) behandelt das Either-Ergebnis und aktualisiert den WeatherState entsprechend (status: WeatherStatus.failure oder success).
    • Die UI (WeatherScreen) reagiert auf den status und das error-Feld im State und zeigt die passende Ansicht.
  • Asynchronität (Future, async, await): Netzwerk- und GPS-Anfragen dauern eine Weile. Future repräsentiert einen Wert, der irgendwann verfügbar sein wird. async markiert eine Funktion, die await verwenden kann. await pausiert die Ausführung der Funktion, bis der Future abgeschlossen ist, ohne die gesamte App zu blockieren. Wir sehen das intensiv im Notifier, Repository und den Services.
  • Immutability & Equatable: Der WeatherState wird nie direkt geändert. Stattdessen wird mit copyWith eine neue Instanz mit den geänderten Werten erstellt. Das macht den Zustandsfluss vorhersagbar. Equatable hilft Riverpod (und uns beim Testen), effizient zu erkennen, ob sich der Zustand wirklich geändert hat, indem es Objekte anhand ihrer Eigenschaften vergleicht, nicht nur anhand ihrer Speicheradresse.

Schritt 6: Ausführen und Experimentieren!

Jetzt, wo du eine Vorstellung davon hast, wie der Code aufgebaut ist und funktioniert:

  1. Starte die App: Wähle dein Gerät in VS Code und drücke F5.
  2. Beobachte: Verfolge die Log-Ausgaben im „DEBUG CONSOLE“-Fenster von VS Code. Du solltest die Meldungen von AppLogger aus den verschiedenen Schichten sehen.
  3. Experimentiere:
    • Ändere Texte in den Widgets.
    • Setze Haltepunkte (Breakpoints) in VS Code (klicke links neben die Zeilennummer) in verschiedenen Methoden (z.B. im Notifier, im Repo, im Service) und starte die App im Debug-Modus (F5). Steppe durch den Code (F10, F11), um den Fluss live zu sehen.
    • Simuliere Fehler: Wirf testweise eine Exception im WeatherApiService oder gib Left(NetworkFailure()) im Repository zurück, um zu sehen, wie die Fehlerbehandlung in der UI greift.

Zusammenfassung und Nächste Schritte

Das war ein tiefer Einblick in den Code unseres ersten Meilensteins! Du hast gesehen, wie wir mit einer klaren Architektur und Werkzeugen wie Riverpod eine skalierbare und testbare Grundlage geschaffen haben, auch für eine zunächst einfache Funktion. Du verstehst jetzt (hoffentlich!) besser:

  • Die Aufteilung in Schichten (Presentation, Domain/Application, Data, Core).
  • Die Verantwortlichkeiten der einzelnen Komponenten (Widgets, Notifier, Repositories, Services).
  • Den Daten- und Kontrollfluss durch die App.
  • Die Grundprinzipien von State Management, Fehlerbehandlung und Asynchronität in Flutter.

Im nächsten Teil (Teil 4) bauen wir darauf auf und machen die App interaktiver: Wir fügen die Adresssuche hinzu!

Bleib dran und viel Spaß beim Erkunden des Codes!