Skip to Content
Mobile Apps4 - FlutterNavigation und API
Folienvorschau
Dauer
35 Minuten
Ziel
Eine Liste anzeigen, Screens verbinden und echte Daten mit einem Coding Agent anbinden

Themen

  • Listen mit ListView
  • Navigation mit Navigator.push
  • Laden beim Öffnen eines Screens
  • API-Anbindung mit einem Coding Agent
  • Paket http und JSON
Klicken für Tastaturnavigation · F für Vollbild
Präsentation starten · 35 Minuten ↗

Navigation und API

Die App bekommt eine Städteliste, die wir mit dem Navigator und der Wetteranzeige verbinden. Danach ersetzen wir die Fake-Daten durch echte Wetterdaten. Die API-Anbindung übernimmt ein Coding Agent; unsere Aufgabe ist, seinen Plan und seinen Code zu prüfen.

Listen mit ListView

ListView.builder erzeugt Einträge erst, wenn sie sichtbar werden. Damit bleiben auch lange Listen performant.

ListView.separated( itemCount: cities.length, itemBuilder: (context, index) { final city = cities[index]; return ListTile( title: Text(city.name), onTap: () { /* Navigation, siehe unten */ }, ); }, separatorBuilder: (context, index) => const Divider(height: 1), )

Neue Datei für die Städteliste. Die Navigation in onTap erklärt der nächste Abschnitt; WeatherScreen(city: city) meldet einen Fehler, bis WeatherScreen das Feld city hat (siehe Laden beim Öffnen).

Code: lib/ui/city_list_screen.dart (schritt-08)

lib/ui/city_list_screen.dart
import 'package:flutter/material.dart'; import '../model/city.dart'; import 'weather_screen.dart'; class CityListScreen extends StatelessWidget { const CityListScreen({super.key, required this.cities}); final List<City> cities; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Städte')), body: ListView.separated( itemCount: cities.length, itemBuilder: (context, index) { final city = cities[index]; return ListTile( title: Text(city.name), subtitle: Text( '${city.latitude.toStringAsFixed(2)}° N, ' '${city.longitude.toStringAsFixed(2)}° O', ), // Navigieren = neue Route auf den Stack legen onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => WeatherScreen(city: city), ), ), ); }, separatorBuilder: (context, index) => const Divider(height: 1), ), ); } }

main.dart startet jetzt mit der Städteliste:

Code: lib/main.dart (schritt-08)

lib/main.dart
import 'package:flutter/material.dart'; import 'data/fake_data.dart'; import 'ui/city_list_screen.dart'; void main() { runApp(const WeatherApp()); } class WeatherApp extends StatelessWidget { const WeatherApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Wetter', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), ), home: const CityListScreen(cities: fakeCities), ); } }

ListView.separated ist eine Variante von ListView.builder, die zwischen zwei Einträgen einen Trenner einfügt, hier einen Divider. Ein ListView(children: [...]) würde wie eine Column alle Einträge auf einmal erzeugen. Für wenige, feste Elemente reicht das, für lange Listen nicht.

ListTile ist die fertige Material-Zeile mit Titel, Untertitel und onTap, wie ListItem in Compose. Der Untertitel zeigt die Koordinaten. Zwei String-Literale direkt hintereinander verbindet Dart automatisch.

Einen key wie in der LazyColumn braucht die Liste nicht, solange sie sich nicht ändert. Bei veränderlichen Listen hilft ein ValueKey Flutter, Einträge wiederzuerkennen.

Der Navigator verwaltet einen Stapel von Screens (Routes).

  • Navigieren: Route auf den Stapel legen, Navigator.push(…)
  • Zurück: oberste Route entfernen, Navigator.pop(context)
  • Angezeigt wird immer die oberste Route
onTap: () => Navigator.push( context, MaterialPageRoute(builder: (context) => WeatherScreen(city: city)), ),

MaterialApp bringt den Navigator schon mit, eine zusätzliche Abhängigkeit braucht es nicht. Navigator.push findet ihn über den context, die Position des Widgets im Baum. MaterialPageRoute erzeugt den Screen und animiert den Übergang passend zur Plattform.

Der Zurück-Pfeil in der AppBar erscheint automatisch, sobald eine Route unter der aktuellen liegt. Zurück-Pfeil und Zurück-Geste des Systems rufen beide Navigator.pop auf.

Anders als bei Navigation 3 ist der Stapel hier kein State der App, sondern steckt im Navigator. Navigiert wird imperativ mit Befehlen wie push und pop. Für Deep Links oder Web-URLs gibt es zusätzlich eine deklarative Variante; am bekanntesten ist das Paket go_router , das Routen als Pfade wie /city/loerrach beschreibt. Für unsere zwei Screens reicht push.

Navigation 3 (Compose)Navigator (Flutter)
Back Stack als Liste im StateStapel im Navigator
backStack.add(CityWeather(city))Navigator.push(context, MaterialPageRoute(…))
backStack.removeLastOrNull()Navigator.pop(context)
Routen als @Serializable-TypenRoute mit builder, Argumente über den Konstruktor
Zurück-Pfeil selbst einbauenZurück-Pfeil automatisch

Hier navigiert CityListScreen selbst. In der Android-App entscheidet der Aufrufer über onCityClick; das ginge auch hier, wäre aber für eine App mit zwei Screens mehr Code, als es nützt.

Laden beim Öffnen

Jeder Wetter-Screen erzeugt sein eigenes ViewModel und lädt das Wetter in initState.

class _WeatherScreenState extends State<WeatherScreen> { final _viewModel = WeatherViewModel(); @override void initState() { super.initState(); _viewModel.loadWeather(widget.city); } // dispose() und build() wie bisher }

WeatherScreen und WeatherContent bekommen dafür ein neues Feld City city. Statt „Wetter“ zeigt die AppBar jetzt city.name; so steht der Name der Stadt schon während des Ladens da. Die Stadt für die Karte kommt deshalb auch nicht mehr aus WeatherSuccess, das Pattern holt nur noch weather heraus.

Der obere Teil der Datei (geänderte Zeilen hervorgehoben), UnitToggle und CurrentWeatherCard bleiben unverändert:

Code: oberer Teil von lib/ui/weather_screen.dart (schritt-08)

lib/ui/weather_screen.dart
import 'package:flutter/material.dart'; import '../model/city.dart'; import '../model/current_weather.dart'; import 'weather_condition_ui.dart'; import 'weather_ui_state.dart'; import 'weather_view_model.dart'; // Stateful: besitzt das ViewModel und den UI-State für °C/°F class WeatherScreen extends StatefulWidget { const WeatherScreen({super.key, required this.city}); final City city; @override State<WeatherScreen> createState() => _WeatherScreenState(); } class _WeatherScreenState extends State<WeatherScreen> { final _viewModel = WeatherViewModel(); // UI-Element-State: bleibt im Widget, gehört nicht ins ViewModel bool _useCelsius = true; @override void initState() { super.initState(); _viewModel.loadWeather(widget.city); } @override void dispose() { _viewModel.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Baut neu, sobald das ViewModel notifyListeners() aufruft return ListenableBuilder( listenable: _viewModel, builder: (context, _) => WeatherContent( city: widget.city, uiState: _viewModel.uiState, useCelsius: _useCelsius, onToggleUnit: () => setState(() => _useCelsius = !_useCelsius), ), ); } } // Stateless: zeigt nur an, was es bekommt class WeatherContent extends StatelessWidget { const WeatherContent({ super.key, required this.city, required this.uiState, required this.useCelsius, required this.onToggleUnit, }); final City city; final WeatherUiState uiState; final bool useCelsius; final VoidCallback onToggleUnit; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(city.name), actions: [UnitToggle(useCelsius: useCelsius, onToggle: onToggleUnit)], ), body: switch (uiState) { WeatherLoading() => const Center(child: CircularProgressIndicator()), // Scrollbar, damit die Karte im Querformat nicht überläuft WeatherSuccess(:final weather) => SingleChildScrollView( child: CurrentWeatherCard( city: city, weather: weather, useCelsius: useCelsius, ), ), WeatherError(:final message) => Center(child: Text(message)), }, ); } } // UnitToggle und CurrentWeatherCard unverändert

Vollständige Datei: ui/weather_screen.dart in schritt-08

initState wird einmal aufgerufen, wenn der State entsteht, also beim Öffnen des Screens. Der Konstruktor des WeatherViewModel, der bisher Lörrach geladen hat, lädt deshalb nichts mehr.

Das ist sauberer als in der Android-App, wo ein gemeinsames ViewModel beim Antippen geladen wird: Das ViewModel lebt genau so lange wie der Screen. Wechselt man schnell zwischen Städten, kann keine alte Antwort im neuen Screen landen.

Eine Stolperfalle bleibt: Verlässt man den Screen, während noch geladen wird, gibt dispose() das ViewModel frei. Kommt die Antwort danach an, ruft loadWeather notifyListeners() auf einem freigegebenen ChangeNotifier auf, und Flutter meldet im Debug-Modus einen Fehler („A WeatherViewModel was used after being disposed“). Ein Future lässt sich nicht abbrechen. Deshalb merkt sich das ViewModel, ob es freigegeben wurde:

lib/ui/weather_view_model.dart
bool _disposed = false; @override void dispose() { _disposed = true; super.dispose(); } // am Ende von loadWeather: if (!_disposed) notifyListeners();

Nachstellen: die Abfrage if (!_disposed) entfernen, eine Stadt öffnen und sofort zurückgehen.

Das ViewModel nach diesem Schritt. Der Import von fake_data.dart entfällt mit dem Laden im Konstruktor:

Code: lib/ui/weather_view_model.dart (schritt-08)

lib/ui/weather_view_model.dart
import 'package:flutter/foundation.dart'; import '../data/fake_weather_repository.dart'; import '../data/weather_repository.dart'; import '../model/city.dart'; import 'weather_ui_state.dart'; class WeatherViewModel extends ChangeNotifier { WeatherViewModel({WeatherRepository? repository}) : _repository = repository ?? FakeWeatherRepository(); final WeatherRepository _repository; WeatherUiState _uiState = const WeatherLoading(); WeatherUiState get uiState => _uiState; // Wird der Screen geschlossen, während noch geladen wird, darf danach // notifyListeners() nicht mehr aufgerufen werden bool _disposed = false; @override void dispose() { _disposed = true; super.dispose(); } Future<void> loadWeather(City city) async { _uiState = const WeatherLoading(); notifyListeners(); try { final weather = await _repository.getCurrentWeather(city); _uiState = WeatherSuccess(city, weather); } catch (e) { _uiState = WeatherError('$e'); } if (!_disposed) notifyListeners(); } }

Code: schritt-08 · Änderungen 

API-Anbindung mit einem Coding Agent

Aufgabe für den Agent: ein ApiWeatherRepository für Open-Meteo bauen und im ViewModel einsetzen.

  • Plan anfordern und prüfen, bevor Code entsteht
  • umsetzen lassen, flutter analyze, App testen
  • Diff lesen und erklären können
  • erst dann committen

Die Datei AGENTS.md im Repository gibt dem Agent Regeln vor, z. B. das Paket http statt Dio, kein Provider/Riverpod, kein Code-Generator, die UI nicht anfassen, nicht selbst committen. Gängige Agents lesen sie automatisch, z. B. Claude Code oder GitHub Copilot in VS Code.

Hinein gehört nur, was der Agent nicht aus dem Code ablesen kann: Abweichungen vom Üblichen und Regeln für die Zusammenarbeit. Eine Beschreibung des Projekts oder der Architektur hilft dagegen kaum, denn der Agent liest den Code ohnehin. Jede Zeile kostet bei jeder Anfrage Tokens.

Die Datei liegt im Projektordner, neben pubspec.yaml:

AGENTS.md
# AGENTS.md Lern-Projekt: Die Personen hier lernen gerade Flutter-Entwicklung. Erkläre deine Änderungen so, dass sie nachvollziehbar sind. ## Regeln - HTTP mit dem Paket `http` und `dart:convert`. Kein Dio, kein Provider/Riverpod/Bloc, kein Code-Generator (json_serializable, freezed). - Keine weiteren Pakete, ohne zu begründen, warum, und nachzufragen. - API-Antworten als eigene DTO-Klassen abbilden und auf `model/` mappen. Das Datenmodell nicht an die API anpassen. - Änderungen an der Datenschicht (`data/`) dürfen die UI (`ui/`) nicht verändern. Wenn das doch nötig scheint, vorher nachfragen. - State-Management mit `ChangeNotifier` und `ListenableBuilder` (ViewModel pro Screen). - Texte in der App auf Deutsch. ## Arbeitsweise - Vor größeren Änderungen einen kurzen Plan vorlegen und auf Bestätigung warten. - Nach jeder Änderung prüfen: `dart format lib` und `flutter analyze` - Nicht selbst committen; die Person prüft den Diff und committet.

Ein möglicher Prompt:

Baue ein ApiWeatherRepository, das das aktuelle Wetter einer Stadt von Open-Meteo lädt (Temperatur, Wettercode, Windgeschwindigkeit; Koordinaten aus City). Setze es als Default im WeatherViewModel ein. Zeig mir zuerst deinen Plan.

Die API braucht keinen Schlüssel. Eine Antwort lässt sich vorab im Browser ansehen: api.open-meteo.com/v1/forecast?latitude=47.62&longitude=7.66&current=temperature_2m,weather_code,wind_speed_10m 

Das Paket http

Pakete kommen von pub.dev  und werden mit flutter pub add eingebunden.

Den Code im Referenz-Repository hat ein Coding Agent geschrieben. Wer ohne Agent nachbaut, kann die folgenden Dateien direkt übernehmen.

Neue Datei, erster Teil. Die Extension toCurrentWeather und die Funktion _weatherConditionFromWmoCode am Ende der Datei folgen im Abschnitt DTO und Datenmodell:

lib/data/api_weather_repository.dart
import 'dart:convert'; import 'package:http/http.dart' as http; import '../model/city.dart'; import '../model/current_weather.dart'; import 'api/open_meteo_dto.dart'; import 'weather_repository.dart'; class ApiWeatherRepository implements WeatherRepository { ApiWeatherRepository({http.Client? client}) : _client = client ?? http.Client(); final http.Client _client; @override Future<CurrentWeather> getCurrentWeather(City city) async { final uri = Uri.https('api.open-meteo.com', '/v1/forecast', { 'latitude': '${city.latitude}', 'longitude': '${city.longitude}', 'current': 'temperature_2m,weather_code,wind_speed_10m', 'timezone': 'auto', }); final response = await _client.get(uri); if (response.statusCode != 200) { throw Exception('HTTP-Fehler ${response.statusCode}'); } final json = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>; return ForecastResponse.fromJson(json).current.toCurrentWeather(); } }

ApiWeatherRepository bekommt einen http.Client als optionalen Parameter, wie das ViewModel sein Repository. as http gibt dem Paket ein Präfix, damit Namen wie Client eindeutig bleiben. Was der Agent im Referenz-Repository außerdem geändert hat:

  • flutter pub add http trägt das Paket in pubspec.yaml ein (http: ^1.6.0) und lädt es herunter.
  • INTERNET-Permission im Android-Manifest, direkt unter dem öffnenden <manifest …>-Tag:
android/app/src/main/AndroidManifest.xml
<!-- Im Debug-Build automatisch vorhanden, für die Release-APK nötig --> <uses-permission android:name="android.permission.INTERNET"/>

Ohne diese Zeile funktioniert die App beim Entwickeln trotzdem, weil Flutter die Permission im Debug-Build automatisch ergänzt (android/app/src/debug/AndroidManifest.xml). Erst die Release-APK lädt dann nichts mehr. Ein typischer Fehler, der beim Review auffallen sollte.

Anders als Ktor wirft http bei HTTP-Fehlern (4xx, 5xx) keine Exception. Deshalb prüft der Code statusCode selbst.

jsonDecode aus dart:convert macht aus dem JSON-Text eine Map<String, dynamic>. dynamic heißt: Typ unbekannt, erst zur Laufzeit geprüft. Die Umwandlung in typisierte Objekte übernehmen die DTOs. utf8.decode(response.bodyBytes) sorgt dafür, dass Umlaute richtig ankommen, auch wenn der Server keinen Zeichensatz angibt.

Alternative zu http ist Dio  mit mehr Komfort, z. B. Interceptors. Gute Übung: den Agent fragen, wie dasselbe mit Dio aussähe, und die beiden Varianten vergleichen.

DTO und Datenmodell

Die JSON-Antwort bekommt eigene Klassen (DTOs). Sie werden auf unser Datenmodell abgebildet.

class CurrentDto { // const-Konstruktor und final-Felder ... factory CurrentDto.fromJson(Map<String, dynamic> json) => CurrentDto( temperature: (json['temperature_2m'] as num).toDouble(), weatherCode: json['weather_code'] as int, windSpeed: (json['wind_speed_10m'] as num).toDouble(), ); }

Die DTOs kommen in eine neue Datei im Unterordner data/api. CurrentDto steckt in einer Hülle ForecastResponse für die gesamte Antwort, weil die API die Werte im Feld current liefert:

lib/data/api/open_meteo_dto.dart
// DTOs bilden die JSON-Antwort von Open-Meteo ab, nicht unser Datenmodell class ForecastResponse { const ForecastResponse({required this.current}); factory ForecastResponse.fromJson(Map<String, dynamic> json) => ForecastResponse( current: CurrentDto.fromJson(json['current'] as Map<String, dynamic>), ); final CurrentDto current; } class CurrentDto { const CurrentDto({ required this.temperature, required this.weatherCode, required this.windSpeed, }); // JSON-Zahlen kommen als num: 14 und 14.2 sind beide möglich factory CurrentDto.fromJson(Map<String, dynamic> json) => CurrentDto( temperature: (json['temperature_2m'] as num).toDouble(), weatherCode: json['weather_code'] as int, windSpeed: (json['wind_speed_10m'] as num).toDouble(), ); final double temperature; final int weatherCode; final double windSpeed; }

Ein factory-Konstruktor erzeugt das Objekt nicht direkt, sondern gibt es zurück. fromJson ist die übliche Konvention dafür. Anders als mit @Serializable in Kotlin schreiben wir die Zuordnung von Hand; JSON-Zahlen kommen als num (14 oder 14.2), deshalb toDouble(). Das Paket json_serializable  erzeugt diesen Code automatisch, braucht dafür aber einen Code-Generator. AGENTS.md schließt das für diese App aus.

Eine Extension im Repository, unter der Klasse ApiWeatherRepository, bildet das DTO auf das Datenmodell ab:

lib/data/api_weather_repository.dart
extension on CurrentDto { CurrentWeather toCurrentWeather() => CurrentWeather( temperature: temperature, windSpeed: windSpeed, condition: _weatherConditionFromWmoCode(weatherCode), ); }

Eine Extension ohne Namen ist nur in ihrer Datei sichtbar, wie eine private-Erweiterungsfunktion in Kotlin.

Warum nicht gleich CurrentWeather.fromJson? Weil das Datenmodell dann vom Format der API abhängt. Mit DTOs bleibt eine Änderung der API auf die Datenschicht beschränkt; die Oberfläche merkt nichts davon.

Die Wetterlage liefert die API als Zahl (WMO-Code). Ein switch mit Patterns bildet sie auf WeatherCondition ab. Die Funktion kommt ganz ans Ende der Datei:

lib/data/api_weather_repository.dart
// WMO-Wettercodes, siehe https://open-meteo.com/en/docs (Abschnitt "WMO Weather interpretation codes") WeatherCondition _weatherConditionFromWmoCode(int code) => switch (code) { 0 => WeatherCondition.clear, 1 || 2 => WeatherCondition.partlyCloudy, 3 => WeatherCondition.cloudy, 45 || 48 => WeatherCondition.fog, (>= 51 && <= 67) || (>= 80 && <= 82) => WeatherCondition.rain, (>= 71 && <= 77) || 85 || 86 => WeatherCondition.snow, >= 95 && <= 99 => WeatherCondition.thunderstorm, _ => WeatherCondition.cloudy, };

|| verbindet Alternativen, >= 51 && <= 67 ist ein Bereich wie in 51..67 in Kotlin, _ entspricht else.

Der Nutzen der Architektur

Für echte Daten wurde kein einziges Widget geändert.

$ git diff --stat schritt-08 schritt-09 android/app/src/main/AndroidManifest.xml | 2 ++ lib/data/api/open_meteo_dto.dart | 31 ++++++++++ lib/data/api_weather_repository.dart | 52 ++++++++++++++++ lib/ui/weather_view_model.dart | 4 +- pubspec.lock | 32 ++++++++++ pubspec.yaml | 1 +

Im ViewModel haben sich nur ein Import und der Default geändert:

lib/ui/weather_view_model.dart
import '../data/api_weather_repository.dart'; // statt fake_weather_repository.dart
lib/ui/weather_view_model.dart
WeatherViewModel({WeatherRepository? repository}) : _repository = repository ?? ApiWeatherRepository();

pubspec.lock hält die genauen Versionen aller Pakete fest, auch der indirekten. Die Datei gehört bei Apps ins Repository, damit alle mit denselben Versionen bauen.

Fehler wie fehlendes Internet landen automatisch im Zustand WeatherError, weil das ViewModel die Exceptions des Repositorys bereits abfängt.

Ein Blick beim Review lohnt sich trotzdem: Angezeigt wird '$e', und das kommt jetzt vom Paket http, z. B. „ClientException with SocketException: Failed host lookup …“. Das ist Englisch und für Nutzer unverständlich, obwohl AGENTS.md deutsche Texte verlangt. Am einfachsten im Emulator mit Flugmodus ausprobieren (siehe Übungen).

Code: schritt-09 · Änderungen 

Übungen

Selbst oder mit einem Coding Agent umsetzen, jeweils Plan und Diff prüfen.

  • Stadtsuche über die Geocoding-API von Open-Meteo
  • 7-Tage-Vorhersage im Wetter-Screen
  • Button „Erneut versuchen“ bei Fehlern
  • Pull-to-Refresh im Wetter-Screen
  • Unit-Tests für das ViewModel mit FakeWeatherRepository
  • Favoriten: Städte auf dem Gerät speichern

Hinweise zu den Übungen:

  • Stadtsuche: Endpunkt https://geocoding-api.open-meteo.com/v1/search?name=…&count=5&language=de. Die Städteliste wird dafür zu State in einem eigenen ViewModel, und CityListScreen wird zum StatefulWidget, das ViewModel und einen TextEditingController für das Suchfeld besitzt (beide in dispose() freigeben). Fehlt das Feld results in der Antwort, wurde nichts gefunden. Negative Koordinaten (Süd, West) sollten als „33.87° S“ statt „-33.87° N“ erscheinen. Wer schnell hintereinander sucht, kann Antworten in falscher Reihenfolge bekommen; eine laufende Nummer pro Suche verhindert, dass eine ältere Antwort die neuere überschreibt. Eine neue Stadt kommt mit _cities.add(city) in die Liste; sichtbar wird sie erst nach notifyListeners(). Damit niemand die Liste an notifyListeners() vorbei ändert, gibt das ViewModel sie nur als List.unmodifiable(_cities) heraus. Eine Referenzlösung liegt im Tag schritt-11 (Änderungen ). Die Dateien zum Kopieren: data/api/geocoding_dto.dart, data/city_repository.dart, data/api_city_repository.dart, ui/city_list_view_model.dart, ui/city_list_screen.dart und main.dart. Statt einer eigenen UiState-Klasse hält das ViewModel dort mehrere Felder (cities, searchResults, isSearching, errorMessage), weil diese Zustände gleichzeitig auftreten können. Weiterführend, nicht in der Referenzlösung: Mehrere Städte heißen gleich (es gibt mehrere „Hamburg“). Die Felder admin1 (Bundesland) und country der Antwort unterscheiden sie in der Trefferliste, dafür braucht City weitere Felder.
  • 7-Tage-Vorhersage: Parameter daily=weather_code,temperature_2m_max,temperature_2m_min, neues Datenmodell DailyForecast, Liste im Wetter-Screen.
  • Erneut versuchen: WeatherContent bekommt einen Callback onRetry, _WeatherScreenState übergibt () => _viewModel.loadWeather(widget.city).
  • Pull-to-Refresh: RefreshIndicator um den Inhalt legen; onRefresh erwartet ein Future, loadWeather liefert eines. Der Inhalt muss dafür scrollbar sein, z. B. ein ListView.
  • Fehlermeldungen auf Deutsch: Im ViewModel statt '$e' eigene Texte setzen, z. B. bei http.ClientException (kein Netz) „Keine Internetverbindung“, sonst „Wetterdaten konnten nicht geladen werden“.
  • Favoriten: Für wenige Werte reicht shared_preferences , ein Key-Value-Speicher für alle Plattformen. Die Liste der Städte wird dafür z. B. mit jsonEncode als String gespeichert und mit jsonDecode wieder gelesen. Für mehr Daten gibt es SQLite, siehe das Cookbook zu SQLite . Pakete mit flutter pub add hinzufügen; AGENTS.md verlangt, dass ein Agent vorher nachfragt.
  • Unit-Tests: Tests liegen in test/ und laufen mit flutter test; flutter_test ist schon eingebunden. formatTemperature lässt sich direkt testen. Für das ViewModel mit FakeWeatherRepository genügt await viewModel.loadWeather(city) und danach eine Prüfung von viewModel.uiState. Einen Main-Dispatcher wie in Android gibt es nicht.

Weiterführendes Material