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 ui/city_list_screen.dart. CityListScreen ist ein
StatelessWidget mit dem Feld cities und einem Scaffold mit
AppBar(title: const Text('Städte')); main.dart setzt
const CityListScreen(cities: fakeCities) als home ein.
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. Jeder Eintrag zeigt zusätzlich die
Koordinaten:
subtitle: Text(
'${city.latitude.toStringAsFixed(2)}° N, '
'${city.longitude.toStringAsFixed(2)}° O',
),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.
Navigation mit Navigator
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 State | Stapel im Navigator |
backStack.add(CityWeather(city)) | Navigator.push(context, MaterialPageRoute(…)) |
backStack.removeLastOrNull() | Navigator.pop(context) |
Routen als @Serializable-Typen | Route mit builder, Argumente über den Konstruktor |
| Zurück-Pfeil selbst einbauen | Zurü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.
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:
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.
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.
Ein möglicher Prompt:
Baue ein
ApiWeatherRepository, das das aktuelle Wetter einer Stadt von Open-Meteo lädt (Temperatur, Wettercode, Windgeschwindigkeit; Koordinaten ausCity). Setze es als Default imWeatherViewModelein. 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¤t=temperature_2m,weather_code,wind_speed_10m
Das Paket http
Pakete kommen von pub.dev und werden mit flutter pub add eingebunden.
@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();
}Neue Datei data/api_weather_repository.dart. Die Methode gehört zur
Klasse ApiWeatherRepository implements WeatherRepository, die einen
http.Client als optionalen Parameter bekommt, wie das ViewModel sein
Repository. Was der Agent im Referenz-Repository außerdem geändert hat:
flutter pub add httpträgt das Paket inpubspec.yamlein (http: ^1.6.0) und lädt es herunter.INTERNET-Permission inandroid/app/src/main/AndroidManifest.xml:
<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 stehen in der neuen Datei data/api/open_meteo_dto.dart.
CurrentDto steckt in einer Hülle ForecastResponse für die gesamte
Antwort, weil die API die Werte im Feld current liefert.
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 in api_weather_repository.dart bildet das DTO auf das
Datenmodell ab:
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:
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 hat sich nur der Default geändert:
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
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, undCityListScreenwird zumStatefulWidget, das ViewModel und einenTextEditingControllerfür das Suchfeld besitzt (beide indispose()freigeben). Fehlt das Feldresultsin 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 Referenzlösung liegt im Tagschritt-11(Änderungen ). Statt einer eigenenUiState-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 Felderadmin1(Bundesland) undcountryder Antwort unterscheiden sie in der Trefferliste, dafür brauchtCityweitere Felder. - 7-Tage-Vorhersage: Parameter
daily=weather_code,temperature_2m_max,temperature_2m_min, neues DatenmodellDailyForecast, Liste im Wetter-Screen. - Erneut versuchen:
WeatherContentbekommt einen CallbackonRetry,_WeatherScreenStateübergibt() => _viewModel.loadWeather(widget.city). - Pull-to-Refresh:
RefreshIndicatorum den Inhalt legen;onRefresherwartet einFuture,loadWeatherliefert eines. Der Inhalt muss dafür scrollbar sein, z. B. einListView. - Fehlermeldungen auf Deutsch: Im ViewModel statt
'$e'eigene Texte setzen, z. B. beihttp.ClientException(kein Netz) „Keine Internetverbindung“, sonst „Wetterdaten konnten nicht geladen werden“. - Unit-Tests: Tests liegen in
test/und laufen mitflutter test;flutter_testist schon eingebunden.formatTemperaturelässt sich direkt testen. Für das ViewModel mitFakeWeatherRepositorygenügtawait viewModel.loadWeather(city)und danach eine Prüfung vonviewModel.uiState. Einen Main-Dispatcher wie in Android gibt es nicht.