Build a Weather App with Flutter
Build a multi-screen Flutter app that fetches live weather forecasts.
Introduction
30 Second Summary
Checking the weather is something everyone does daily. Behind every weather app is code that fetches live data from the internet and displays it in a clean, easy-to-read format.
In this project, you will build a weather forecast app using Flutter. You'll create a two-screen mobile app that fetches real weather data for any city in the world.
What You'll Build
A Flutter mobile app with a city search screen and a weather detail screen that displays current conditions and a 7-day forecast from the Open-Meteo API.
By the end of this project, you'll have:
- A multi-screen Flutter app with navigation between a city search page and a weather detail page.
- A weather service class that makes async HTTP requests to a live API and parses JSON responses into typed Dart objects.
- A polished UI with weather icons, temperature display, and a scrollable daily forecast.
- Secret Mission: A "Recent Searches" feature using local storage so users can quickly re-check previous cities.
Are there any prerequisites?
You'll need Flutter SDK, VS Code (with the Flutter and Dart extensions), Android Studio (for the Android emulator), and Git installed on your machine. No prior Flutter experience is required.
Create and Run Your Flutter Project
Every Flutter app starts as a project scaffold generated by the Flutter CLI. Before writing any weather logic, you need to confirm that your tools work end-to-end by creating a fresh project and running it on the emulator.
In this step, you will create your weather_app project, verify it runs on the Android emulator, and add the packages your app needs for HTTP calls and date formatting.
In this step, get ready to:
- Create a new Flutter project and open it in VS Code.
- Run the default app on the Android emulator.
- Add the HTTP and date-formatting dependencies your weather app needs.
Create a new Flutter project
The flutter create command generates a complete project structure with all the boilerplate you need to start building.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Flutter project by running this command:
flutter create weather_app
What does this command do?
This generates a full Flutter project folder called weather_app with default template files for Android, iOS, web, and desktop platforms.
You will see Flutter print a list of all created files. At the end, it tells you how to run the app.
- Move into your new project folder by running:
cd weather_app
- Open this folder in VS Code by running:
code .
- Confirm the project was created by checking the Explorer sidebar in VS Code.
- You should see folders like lib/, android/, and files like pubspec.yaml.
Don't see the project files?
If the code . command is not recognized, open VS Code manually. Click File in the top menu bar, then Open Folder, and navigate to Desktop > weather_app.
help me open my Flutter project in VS Code
Run the default app on the Android emulator
Before writing any custom code, you want to confirm the full toolchain works. Running the default counter app proves that Flutter, VS Code, and the Android emulator are connected properly.
- Open Android Studio.
- From the welcome screen, click More Actions, then select Virtual Device Manager.
- Click the Play button (triangle icon) next to your emulator to start it.
- Wait for the emulator to fully boot. You will see the Android home screen.
What is the Android emulator?
The Android emulator is a virtual Android phone that runs on your computer. It lets you test mobile apps without needing a physical device.
- Back in VS Code, open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
- Run the default Flutter app on your emulator by running this command:
flutter run
What should I see?
Flutter will compile the app and install it on the emulator. This can take 1-2 minutes the first time.
Once it finishes, you should see a blue counter app on the emulator with a floating "+" button. Tapping the button increments the counter.
- Confirm the counter app appears on your emulator screen.
- Stop the app by pressing q in the VS Code terminal.
App not launching on the emulator?
If you see "No supported devices connected", your emulator may not have fully booted. Wait until you see the Android home screen, then try flutter run again.
If you see a Gradle or build error, make sure Android Studio has finished downloading all SDK components. Run flutter doctor to check for unresolved issues.
help me fix my Flutter emulator issue
Add project dependencies and configure the app entry point
Your weather app needs two packages: http for making API calls to the Open-Meteo weather service, and intl for formatting dates in the 7-day forecast.
- Open pubspec.yaml in the VS Code Explorer sidebar. This file lives at the top level of your weather_app folder.
- Select all the existing content and replace it with the following:
name: weather_app
description: A Flutter weather forecast app using Open-Meteo API.
publish_to: 'none'
version: 1.0.0+1
# Dart SDK version constraint
environment:
sdk: ^3.6.0
# Packages the app depends on at runtime
dependencies:
flutter:
sdk: flutter
http: ^1.6.0
intl: ^0.20.2
# Packages used only during development
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
# Flutter-specific configuration
flutter:
uses-material-design: true
What does this file do?
- http: ^1.6.0 is the official Dart HTTP package for making GET requests to web APIs.
- intl: ^0.20.2 provides date formatting so you can display day names like "Monday" in the forecast.
- The ^ symbol means "this version or any compatible higher version".
- Save pubspec.yaml.
- Download the new packages by running this command in the VS Code terminal:
flutter pub get
- You should see output confirming the packages were resolved and downloaded.
Seeing dependency errors?
Check that your YAML indentation uses exactly 2 spaces (not tabs). YAML is whitespace-sensitive, so even a single extra space can cause parsing failures.
help me fix pubspec.yaml errors
Now you will replace the default counter app with your own app entry point. This sets up the MaterialApp widget that will wrap your entire weather app.
- Open lib/main.dart in VS Code. You can find it inside the lib/ folder in the Explorer sidebar.
- Select all the existing content and replace it with the following:
import 'package:flutter/material.dart';
import 'screens/search_screen.dart';
// Entry point of the app
void main() {
runApp(const WeatherApp());
}
// Root widget that configures the app's theme and starting screen
class WeatherApp extends StatelessWidget {
const WeatherApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Weather App',
theme: ThemeData(
// Use blue as the primary color seed for weather vibes
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
// SearchScreen is the first screen users see
home: const SearchScreen(),
);
}
}
What does this code do?
- runApp() is the function that starts your Flutter app. It takes a widget and makes it the root of the widget tree.
- MaterialApp provides the Material Design foundation (theme, navigation, text direction) that all your screens inherit.
- home: const SearchScreen() tells the app to show the search screen when it first opens. You will build this screen in the next step.
What is a widget?
In Flutter, everything on screen is a widget. Buttons, text, layouts, and even the entire app itself are widgets nested inside each other.
- Save lib/main.dart.
The app will not compile yet
You may see an error about screens/search_screen.dart not existing. That is expected. You will create the search screen file in the next step.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full pubspec.yaml:
name: weather_app
description: A Flutter weather forecast app using Open-Meteo API.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.6.0
dependencies:
flutter:
sdk: flutter
http: ^1.6.0
intl: ^0.20.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
And the full lib/main.dart:
import 'package:flutter/material.dart';
import 'screens/search_screen.dart';
void main() {
runApp(const WeatherApp());
}
class WeatherApp extends StatelessWidget {
const WeatherApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Weather App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const SearchScreen(),
);
}
}
Your project skeleton is locked in and the dependencies are ready. Next up, you will build the search screen where users type in a city name to look up the weather.
Build the City Search Screen
Your Flutter project is running and the dependencies are installed. Now it's time to build the first screen your users will interact with.
The search screen is the entry point of your weather app. It needs a text field where users type a city name, a button to trigger the search, and a loading indicator so users know something is happening while data loads.
In this step, get ready to:
- Create a search screen with a text field and search button.
- Handle user input to capture the city name.
- Add a loading indicator for when data is being fetched.
Create the search screen file
Flutter organizes screens into their own files. Your main.dart already imports from screens/search_screen.dart, but that file doesn't exist yet.
- In VS Code's Explorer sidebar, right-click the lib folder.
- Select New Folder and name it screens.
- Right-click the new screens folder and select New File.
- Name the file search_screen.dart.
Your file tree should now show lib/screens/search_screen.dart.
- Add the following imports and StatefulWidget class to search_screen.dart:
import 'package:flutter/material.dart';
import '../services/weather_service.dart';
import 'weather_detail_screen.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
What does this code do?
A StatefulWidget is a widget that can change over time. Unlike a StatelessWidget (which never changes), a stateful widget rebuilds its UI when internal data changes.
Your search screen needs to track whether data is loading and what the user typed. That makes it a perfect fit for StatefulWidget.
Why do the imports show errors?
The imports for weather_service.dart and weather_detail_screen.dart reference files you haven't created yet. You'll build these in Steps 3 and 4.
For now, VS Code will show red underlines on those imports. That's expected and will resolve itself as you progress through the project.
Handle user input with a TextEditingController
Flutter uses a TextEditingController to read and manage what users type into text fields. The controller holds the current value of the text field so your code can grab it when the user taps the search button.
- Below the SearchScreen class, add the state class with its fields and the _searchCity() method:
class _SearchScreenState extends State<SearchScreen> {
// Controller to read text from the search field
final _controller = TextEditingController();
// Service that will fetch weather data (created in Step 3)
final _weatherService = WeatherService();
// Tracks whether a search is in progress
bool _isLoading = false;
Future<void> _searchCity() async {
// Grab the text and remove extra whitespace
final cityName = _controller.text.trim();
if (cityName.isEmpty) return;
setState(() {
_isLoading = true;
});
try {
final weatherData = await _weatherService.getWeather(cityName);
// Check the widget is still on screen before navigating
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WeatherDetailScreen(weatherData: weatherData),
),
);
} catch (e) {
if (!mounted) return;
// Show the error message in a snackbar at the bottom
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
// Clean up the controller when the widget is removed
_controller.dispose();
super.dispose();
}
What does this code do?
- _controller stores whatever the user types. When they tap search, you read it with _controller.text.
- _searchCity() is an async method. It sets loading to true, calls the weather service, then navigates to the detail screen on success or shows an error on failure.
- The mounted check prevents crashes. If the user navigates away before the API call finishes, mounted returns false and the code stops early.
- dispose() frees the controller's memory when the screen is removed from the widget tree.
Add a loading indicator that shows while fetching data
Users need visual feedback when they tap search. Without it, they won't know if the app is working or frozen. The pattern is straightforward: when _isLoading is true, show a spinner inside the button instead of the text.
- Add the build() method below the dispose() method, inside the same _SearchScreenState class. Include the closing brace for the class:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Weather App'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Large cloud icon as a visual anchor
const Icon(
Icons.cloud,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
// Text field bound to _controller
TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Enter city name',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onSubmitted: (_) => _searchCity(),
),
const SizedBox(height: 16),
// Full-width button with loading indicator swap
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isLoading ? null : _searchCity,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Search'),
),
),
],
),
),
);
}
}
What does this code do?
- The TextField is connected to _controller. The onSubmitted callback triggers _searchCity() when the user presses Enter on the keyboard.
- The FilledButton uses a ternary to swap between a CircularProgressIndicator (when loading) and the text label (when idle).
- Setting onPressed to null while loading disables the button. This prevents duplicate search requests.
- Save search_screen.dart.
Your emulator won't show the search screen yet because the imported files (weather_service.dart and weather_detail_screen.dart) don't exist. You'll create them in the next steps and see this screen come alive.
Seeing unexpected red underlines?
Red underlines on WeatherService and WeatherDetailScreen are expected. Those classes will exist after Steps 3 and 4.
If you see errors on other lines (like TextEditingController or StatefulWidget), check that your first import line is exactly import 'package:flutter/material.dart';.
Help me debug red underlines in my search_screen.dart file
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete search_screen.dart file:
import 'package:flutter/material.dart';
import '../services/weather_service.dart';
import 'weather_detail_screen.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _controller = TextEditingController();
final _weatherService = WeatherService();
bool _isLoading = false;
Future<void> _searchCity() async {
final cityName = _controller.text.trim();
if (cityName.isEmpty) return;
setState(() {
_isLoading = true;
});
try {
final weatherData = await _weatherService.getWeather(cityName);
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WeatherDetailScreen(weatherData: weatherData),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Weather App'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.cloud,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Enter city name',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onSubmitted: (_) => _searchCity(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isLoading ? null : _searchCity,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Search'),
),
),
],
),
),
);
}
}
Your search screen is built and ready. Next up, you'll create the weather service that connects to a real weather API and fetches live data when the user searches for a city.
Connect to the Open-Meteo API
Your search screen is ready to accept city names and trigger a weather lookup. Now you need the code that actually talks to the internet and fetches real weather data.
The Open-Meteo API is free, requires no API key, and returns weather data as JSON. Your app will make two API calls: one to convert a city name into coordinates, and another to fetch the forecast for those coordinates.
In this step, get ready to:
- Create a Dart model class that parses JSON weather responses into typed objects.
- Build a weather service that converts city names to coordinates using the Geocoding API.
- Fetch current weather and a 7-day daily forecast from the Forecast API.
Create the weather data model
Before building the service that calls the API, you need a place to store the data that comes back. A model class gives the JSON response a structured shape that the rest of your app can work with.
- In VS Code's Explorer sidebar, right-click the lib folder and select New Folder.
- Name the folder models.
- Right-click the new models folder and select New File.
- Name the file weather_data.dart.
- Add the WeatherData and DailyForecast classes by pasting this code:
class WeatherData {
final String cityName;
final double currentTemperature;
final int currentWeatherCode;
final double currentWindSpeed;
final List<DailyForecast> dailyForecast;
WeatherData({
required this.cityName,
required this.currentTemperature,
required this.currentWeatherCode,
required this.currentWindSpeed,
required this.dailyForecast,
});
// Factory constructor that parses the Open-Meteo JSON response
factory WeatherData.fromJson(Map<String, dynamic> json, String cityName) {
final current = json['current'] as Map<String, dynamic>;
final daily = json['daily'] as Map<String, dynamic>;
// Extract each daily array from the JSON
final times = (daily['time'] as List).cast<String>();
final maxTemps = (daily['temperature_2m_max'] as List).cast<num>();
final minTemps = (daily['temperature_2m_min'] as List).cast<num>();
final weatherCodes = (daily['weather_code'] as List).cast<int>();
// Build a list of DailyForecast objects from the parallel arrays
final forecasts = <DailyForecast>[];
for (var i = 0; i < times.length; i++) {
forecasts.add(DailyForecast(
date: times[i],
maxTemperature: maxTemps[i].toDouble(),
minTemperature: minTemps[i].toDouble(),
weatherCode: weatherCodes[i],
));
}
return WeatherData(
cityName: cityName,
currentTemperature: (current['temperature_2m'] as num).toDouble(),
currentWeatherCode: current['weather_code'] as int,
currentWindSpeed: (current['wind_speed_10m'] as num).toDouble(),
dailyForecast: forecasts,
);
}
}
class DailyForecast {
final String date;
final double maxTemperature;
final double minTemperature;
final int weatherCode;
DailyForecast({
required this.date,
required this.maxTemperature,
required this.minTemperature,
required this.weatherCode,
});
}
What does this code do?
WeatherData holds everything the UI needs: the city name, current temperature, a weather code (an integer representing conditions like rain or sun), wind speed, and a list of daily forecasts.
The fromJson factory constructor does the heavy lifting of parsing nested JSON. Open-Meteo returns daily data as parallel arrays (one array of dates, one of max temps, one of min temps). The loop zips those arrays together into DailyForecast objects.
- Below the DailyForecast class, add these two helper functions that map WMO weather codes to descriptions and icons:
// Maps WMO weather interpretation codes to human-readable text
String getWeatherDescription(int code) {
switch (code) {
case 0:
return 'Clear sky';
case 1:
return 'Mainly clear';
case 2:
return 'Partly cloudy';
case 3:
return 'Overcast';
case 45:
case 48:
return 'Foggy';
case 51:
case 53:
case 55:
return 'Drizzle';
case 61:
case 63:
case 65:
return 'Rain';
case 66:
case 67:
return 'Freezing rain';
case 71:
case 73:
case 75:
return 'Snowfall';
case 77:
return 'Snow grains';
case 80:
case 81:
case 82:
return 'Rain showers';
case 85:
case 86:
return 'Snow showers';
case 95:
return 'Thunderstorm';
case 96:
case 99:
return 'Thunderstorm with hail';
default:
return 'Unknown';
}
}
// Maps WMO codes to Material Icons for the UI
IconData getWeatherIcon(int code) {
if (code == 0) return Icons.wb_sunny;
if (code <= 3) return Icons.cloud_queue;
if (code <= 48) return Icons.foggy;
if (code <= 57) return Icons.grain;
if (code <= 67) return Icons.water_drop;
if (code <= 77) return Icons.ac_unit;
if (code <= 82) return Icons.shower;
if (code <= 86) return Icons.ac_unit;
return Icons.thunderstorm;
}
What are WMO weather codes?
Open-Meteo returns weather conditions as integer codes defined by the World Meteorological Organization. Code 0 means clear sky, codes 61-65 mean rain, code 95 means thunderstorm, and so on.
These two helper functions translate those codes into text descriptions and matching icons so the UI can display something meaningful to users.
- Save weather_data.dart.
Seeing red squiggly lines in this file?
If VS Code shows errors on IconData or Icons, these will resolve once the file is imported by other parts of the app that bring in Flutter's material library. You can safely continue to the next substep.
help me fix errors in my weather_data.dart file
✔️ Awesome, I've got everything!
Great. Make sure you have saved weather_data.dart before moving on.
ⓧ I'd like to double check the full code
class WeatherData {
final String cityName;
final double currentTemperature;
final int currentWeatherCode;
final double currentWindSpeed;
final List<DailyForecast> dailyForecast;
WeatherData({
required this.cityName,
required this.currentTemperature,
required this.currentWeatherCode,
required this.currentWindSpeed,
required this.dailyForecast,
});
factory WeatherData.fromJson(Map<String, dynamic> json, String cityName) {
final current = json['current'] as Map<String, dynamic>;
final daily = json['daily'] as Map<String, dynamic>;
final times = (daily['time'] as List).cast<String>();
final maxTemps = (daily['temperature_2m_max'] as List).cast<num>();
final minTemps = (daily['temperature_2m_min'] as List).cast<num>();
final weatherCodes = (daily['weather_code'] as List).cast<int>();
final forecasts = <DailyForecast>[];
for (var i = 0; i < times.length; i++) {
forecasts.add(DailyForecast(
date: times[i],
maxTemperature: maxTemps[i].toDouble(),
minTemperature: minTemps[i].toDouble(),
weatherCode: weatherCodes[i],
));
}
return WeatherData(
cityName: cityName,
currentTemperature: (current['temperature_2m'] as num).toDouble(),
currentWeatherCode: current['weather_code'] as int,
currentWindSpeed: (current['wind_speed_10m'] as num).toDouble(),
dailyForecast: forecasts,
);
}
}
class DailyForecast {
final String date;
final double maxTemperature;
final double minTemperature;
final int weatherCode;
DailyForecast({
required this.date,
required this.maxTemperature,
required this.minTemperature,
required this.weatherCode,
});
}
String getWeatherDescription(int code) {
switch (code) {
case 0:
return 'Clear sky';
case 1:
return 'Mainly clear';
case 2:
return 'Partly cloudy';
case 3:
return 'Overcast';
case 45:
case 48:
return 'Foggy';
case 51:
case 53:
case 55:
return 'Drizzle';
case 61:
case 63:
case 65:
return 'Rain';
case 66:
case 67:
return 'Freezing rain';
case 71:
case 73:
case 75:
return 'Snowfall';
case 77:
return 'Snow grains';
case 80:
case 81:
case 82:
return 'Rain showers';
case 85:
case 86:
return 'Snow showers';
case 95:
return 'Thunderstorm';
case 96:
case 99:
return 'Thunderstorm with hail';
default:
return 'Unknown';
}
}
IconData getWeatherIcon(int code) {
if (code == 0) return Icons.wb_sunny;
if (code <= 3) return Icons.cloud_queue;
if (code <= 48) return Icons.foggy;
if (code <= 57) return Icons.grain;
if (code <= 67) return Icons.water_drop;
if (code <= 77) return Icons.ac_unit;
if (code <= 82) return Icons.shower;
if (code <= 86) return Icons.ac_unit;
return Icons.thunderstorm;
}
Convert city names to coordinates
Open-Meteo's Forecast API requires latitude and longitude, not city names. You need a separate Geocoding API call first to convert a city name like "Tokyo" into coordinates.
- In the Explorer sidebar, right-click the lib folder and select New Folder.
- Name the folder services.
- Right-click the new services folder and select New File.
- Name the file weather_service.dart.
- Add the imports, class shell, and the private geocoding method by pasting this code:
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/weather_data.dart';
class WeatherService {
// Converts a city name to latitude/longitude using the Geocoding API
Future<Map<String, String>> _getCoordinates(String cityName) async {
final geocodingUrl = Uri.parse(
'https://geocoding-api.open-meteo.com/v1/search?name=$cityName&count=1&language=en',
);
final response = await http.get(geocodingUrl);
if (response.statusCode != 200) {
throw Exception('Failed to search for city');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final results = json['results'] as List<dynamic>?;
if (results == null || results.isEmpty) {
throw Exception('City not found. Please check the spelling and try again.');
}
final location = results[0] as Map<String, dynamic>;
return {
'latitude': location['latitude'].toString(),
'longitude': location['longitude'].toString(),
'name': location['name'] as String,
};
}
}
What does this code do?
- _getCoordinates calls the Open-Meteo Geocoding API at https://geocoding-api.open-meteo.com/v1/search with the city name the user typed.
- The count=1 parameter limits results to the top match.
- If no results come back, the method throws an exception with a user-friendly message that the search screen displays as a SnackBar.
- On success, it returns a map with the latitude, longitude, and resolved city name.
Why two API calls instead of one?
Open-Meteo's Forecast API only accepts coordinates. Many weather APIs work this way because coordinates are unambiguous, while city names can match multiple places (there are over 30 cities named "Springfield" in the US alone).
The geocoding step resolves that ambiguity first, then the forecast step uses the exact coordinates.
- Save weather_service.dart. You will not see a visible change yet. The next substep adds the forecast method that completes this class.
Fetch the weather forecast
Now that _getCoordinates can resolve city names, you need the public method that chains both calls together: geocode first, then fetch the forecast.
- In weather_service.dart, find the line that says class WeatherService {.
- Add the getWeather method directly below that line, above the _getCoordinates method:
// Public method that chains geocoding + forecast fetch
Future<WeatherData> getWeather(String cityName) async {
final coordinates = await _getCoordinates(cityName);
final latitude = coordinates['latitude']!;
final longitude = coordinates['longitude']!;
final resolvedName = coordinates['name']!;
final weatherUrl = Uri.parse(
'https://api.open-meteo.com/v1/forecast'
'?latitude=$latitude'
'&longitude=$longitude'
'¤t=temperature_2m,weather_code,wind_speed_10m'
'&daily=temperature_2m_max,temperature_2m_min,weather_code'
'&timezone=auto',
);
final weatherResponse = await http.get(weatherUrl);
if (weatherResponse.statusCode != 200) {
throw Exception('Failed to fetch weather data');
}
final weatherJson = jsonDecode(weatherResponse.body) as Map<String, dynamic>;
return WeatherData.fromJson(weatherJson, resolvedName);
}
What does this code do?
- getWeather is the public method that SearchScreen calls when the user taps Search.
- It first calls _getCoordinates to get the latitude and longitude for the city.
- Then it builds a URL to the Open-Meteo Forecast API requesting current temperature, weather code, wind speed, and 7-day daily max/min temperatures.
- The timezone=auto parameter tells the API to automatically use the local time zone for the requested coordinates.
- Finally, it decodes the JSON and passes it to WeatherData.fromJson to create a typed object.
- Save weather_service.dart.
Seeing errors about WeatherDetailScreen?
Your search_screen.dart file imports weather_detail_screen.dart which does not exist yet. This is expected. You will create that screen in the next step. The errors will disappear then.
help me understand the errors in my Flutter weather app
✔️ Awesome, I've got everything!
Great. Double check you have saved both weather_service.dart and weather_data.dart.
ⓧ I'd like to double check the full code
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/weather_data.dart';
class WeatherService {
Future<WeatherData> getWeather(String cityName) async {
final coordinates = await _getCoordinates(cityName);
final latitude = coordinates['latitude']!;
final longitude = coordinates['longitude']!;
final resolvedName = coordinates['name']!;
final weatherUrl = Uri.parse(
'https://api.open-meteo.com/v1/forecast'
'?latitude=$latitude'
'&longitude=$longitude'
'¤t=temperature_2m,weather_code,wind_speed_10m'
'&daily=temperature_2m_max,temperature_2m_min,weather_code'
'&timezone=auto',
);
final weatherResponse = await http.get(weatherUrl);
if (weatherResponse.statusCode != 200) {
throw Exception('Failed to fetch weather data');
}
final weatherJson = jsonDecode(weatherResponse.body) as Map<String, dynamic>;
return WeatherData.fromJson(weatherJson, resolvedName);
}
Future<Map<String, String>> _getCoordinates(String cityName) async {
final geocodingUrl = Uri.parse(
'https://geocoding-api.open-meteo.com/v1/search?name=$cityName&count=1&language=en',
);
final response = await http.get(geocodingUrl);
if (response.statusCode != 200) {
throw Exception('Failed to search for city');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final results = json['results'] as List<dynamic>?;
if (results == null || results.isEmpty) {
throw Exception('City not found. Please check the spelling and try again.');
}
final location = results[0] as Map<String, dynamic>;
return {
'latitude': location['latitude'].toString(),
'longitude': location['longitude'].toString(),
'name': location['name'] as String,
};
}
}
Your data model and weather service are complete. Next up, you will build the weather detail screen that takes this data and displays it beautifully to the user.
Build the Weather Detail Screen
Your app can now fetch real weather data from the Open-Meteo API. The search screen is wired up, and the weather service parses the JSON response into a typed Dart object.
The next challenge is presenting that data in a clear, user-friendly screen. In this step, you will build the weather detail screen that shows the current temperature, weather condition, wind speed, and a 7-day daily forecast.
In this step, get ready to:
- Display the current temperature, weather condition, and wind speed.
- Build a scrollable list showing the 7-day daily forecast with max/min temperatures.
- Map WMO weather codes to human-readable descriptions and icons.
Display current temperature, condition, and wind speed
When a user searches for a city, the app needs a dedicated screen to display the results. This screen receives the parsed WeatherData object and renders it visually.
- Create a new file called weather_detail_screen.dart inside lib/screens/.
- Add the following code to build the current conditions section:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/weather_data.dart';
class WeatherDetailScreen extends StatelessWidget {
final WeatherData weatherData;
const WeatherDetailScreen({super.key, required this.weatherData});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// Show the city name in the app bar
title: Text(weatherData.cityName),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 16),
// Large weather icon based on current conditions
Icon(
getWeatherIcon(weatherData.currentWeatherCode),
size: 80,
color: Colors.blue,
),
const SizedBox(height: 8),
// Current temperature in large bold text
Text(
'${weatherData.currentTemperature.round()}°C',
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
// Weather description below the temperature
Text(
getWeatherDescription(weatherData.currentWeatherCode),
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 8),
// Wind speed row with icon
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.air, size: 20, color: Colors.grey),
const SizedBox(width: 4),
Text(
'${weatherData.currentWindSpeed} km/h',
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
],
),
),
);
}
}
What does this code do?
This creates a StatelessWidget that receives a WeatherData object through its constructor. The widget does not manage any internal state because it only displays data passed to it.
The SingleChildScrollView wrapper ensures the screen scrolls if the content is taller than the device. Inside, a Column stacks the weather icon, temperature, description, and wind speed vertically.
- Save weather_detail_screen.dart.
- Hot reload your app by pressing r in the terminal where flutter run is active. Type a city name like London and tap Search. You should see a new screen with a weather icon, the temperature in large bold text, a grey description, and the wind speed.
Seeing a red error screen?
Check that the import path at the top of the file exactly matches ../models/weather_data.dart. A typo in the import path prevents Flutter from finding your model class.
Also confirm the constructor parameter is required this.weatherData. If the parameter name does not match what search_screen.dart passes, the navigation will fail.
help me debug my weather detail screen
Build the scrollable 7-day forecast list
Current conditions are useful, but users also want to see what the week ahead looks like. The dailyForecast list in your WeatherData object contains 7 days of forecast data ready to display.
- In weather_detail_screen.dart, find the closing ], of the children list inside the Column widget. It comes right after the wind speed Row closing parenthesis.
- Add the following items to the children list, just before that closing ],:
const SizedBox(height: 32),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'7-Day Forecast',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
...weatherData.dailyForecast.map(
(day) => _buildDayRow(day),
),
- Now add the _buildDayRow helper method below the build method but still inside the WeatherDetailScreen class:
Widget _buildDayRow(DailyForecast day) {
// Parse the date string and format it as a full day name
final date = DateTime.parse(day.date);
final dayName = DateFormat('EEEE').format(date);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
// Day name in a fixed-width container for alignment
SizedBox(
width: 100,
child: Text(
dayName,
style: const TextStyle(fontSize: 16),
),
),
// Weather icon for this day
Icon(
getWeatherIcon(day.weatherCode),
color: Colors.blue,
),
const Spacer(),
// Max temperature in bold
Text(
'${day.maxTemperature.round()}°',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
// Min temperature in grey
Text(
'${day.minTemperature.round()}°',
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
What does this code do?
The spread operator (...) unpacks each item from the dailyForecast list directly into the Column's children. Each day becomes a row built by _buildDayRow.
Inside _buildDayRow, DateFormat('EEEE') from the intl package converts a date string like 2026-05-12 into a full day name like Tuesday. The Spacer widget pushes the temperature values to the right side of the row.
- Save weather_detail_screen.dart and hot reload.
- Search for a city again. Below the current conditions, you should now see a 7-Day Forecast heading followed by 7 rows. Each row shows a day name on the left, a weather icon, and max/min temperatures on the right.
Forecast not showing up?
Make sure the new items are inside the children: [...] list, not outside the Column. A misplaced bracket can move them outside the widget tree.
Also confirm the _buildDayRow method is placed after the closing brace of build() but before the final closing brace of the class.
help me fix my 7-day forecast layout
Understand the WMO weather code mapping
You might have noticed that the weather icon and description text change depending on the city you search. These come from the getWeatherIcon and getWeatherDescription functions you created in weather_data.dart.
What are WMO weather codes?
The Open-Meteo API returns weather conditions as WMO interpretation codes. These are integers defined by the World Meteorological Organization. For example, code 0 means clear sky, codes 61-65 mean rain, and code 95 means thunderstorm.
Your getWeatherDescription function translates these numbers into strings like Partly cloudy. Your getWeatherIcon function picks a Material Icon based on the code range. Together, they turn raw API numbers into a visual experience.
- Try searching for different cities to see how the icons and descriptions change. Search for Tokyo, Reykjavik, or Mumbai to compare weather across different climates.
✔️ Awesome, I've got everything!
Double check you have saved weather_detail_screen.dart and your app is running without errors.
ⓧ I'd like to double check the full code
Here is the complete weather_detail_screen.dart file:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/weather_data.dart';
class WeatherDetailScreen extends StatelessWidget {
final WeatherData weatherData;
const WeatherDetailScreen({super.key, required this.weatherData});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(weatherData.cityName),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 16),
Icon(
getWeatherIcon(weatherData.currentWeatherCode),
size: 80,
color: Colors.blue,
),
const SizedBox(height: 8),
Text(
'${weatherData.currentTemperature.round()}°C',
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
Text(
getWeatherDescription(weatherData.currentWeatherCode),
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.air, size: 20, color: Colors.grey),
const SizedBox(width: 4),
Text(
'${weatherData.currentWindSpeed} km/h',
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
const SizedBox(height: 32),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'7-Day Forecast',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
...weatherData.dailyForecast.map(
(day) => _buildDayRow(day),
),
],
),
),
);
}
Widget _buildDayRow(DailyForecast day) {
final date = DateTime.parse(day.date);
final dayName = DateFormat('EEEE').format(date);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(
dayName,
style: const TextStyle(fontSize: 16),
),
),
Icon(
getWeatherIcon(day.weatherCode),
color: Colors.blue,
),
const Spacer(),
Text(
'${day.maxTemperature.round()}°',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Text(
'${day.minTemperature.round()}°',
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
}
Your weather detail screen is complete. It shows current conditions at a glance and a full week of forecast data below. Next up, you will wire the navigation between screens so the whole flow works seamlessly.
Add Navigation Between Screens
You have a search screen, a weather service, and a detail screen. Now it is time to connect them all together and see the full flow in action.
In Flutter, screens are connected using a Navigator that manages a stack of routes. In this step, you will trace how your app pushes the weather detail screen onto the stack, passes data between screens, and provides a back button automatically.
In this step, get ready to:
- Understand how Navigator.push navigates from the search screen to the detail screen.
- See how weather data passes to the detail screen and displays the city name in the AppBar.
- Test the full end-to-end flow including error handling and the back button.
Trace the Navigator.push call
When the learner taps Search, the _searchCity() method fetches weather data, then pushes a new screen onto the navigation stack. This is the core navigation pattern in Flutter.
- In VS Code, open lib/screens/search_screen.dart.
- Find the Navigator.push call inside the try block of _searchCity():
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WeatherDetailScreen(weatherData: weatherData),
),
);
What does Navigator.push do?
Flutter maintains a stack of screens (called routes). Navigator.push adds a new route on top of the stack, which slides the new screen into view.
MaterialPageRoute defines how the transition looks. Its builder function creates the destination widget. Here, it creates a WeatherDetailScreen and passes the weatherData object to it.
Why check mounted before navigating?
The line if (!mounted) return; appears before the navigation call. Since getWeather() is asynchronous, the user could leave the screen while the network request is in progress. Checking mounted ensures the widget is still alive before using its context.
Review how data passes to the detail screen
Flutter passes data between screens through constructor parameters. The WeatherDetailScreen receives the full WeatherData object and uses it to populate the UI.
- Open lib/screens/weather_detail_screen.dart.
- Find the constructor and the appBar at the top of the build method:
class WeatherDetailScreen extends StatelessWidget {
final WeatherData weatherData;
const WeatherDetailScreen({super.key, required this.weatherData});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(weatherData.cityName),
),
How does the city name appear in the AppBar?
The weatherData field stores the object passed through the constructor. The AppBar title uses weatherData.cityName to display the resolved city name from the API.
Flutter automatically adds a back arrow to the AppBar when a screen is pushed onto the navigation stack. Tapping it calls Navigator.pop behind the scenes, which removes the current screen and returns to the search screen.
Test the full app end-to-end
All the pieces are in place. Your search screen fetches data, navigates to the detail screen, passes the weather data, and displays it with a back button. Time to verify the full flow.
- Make sure your Android emulator is running and the app is loaded.
- If the app is not running, start it by pressing F5 in VS Code or running this command in the terminal:
flutter run
- Tap the text field on the search screen and type London.
- Tap the Search button.
You should see the loading indicator briefly appear, then the app navigates to the weather detail screen showing London's current temperature, weather condition, wind speed, and 7-day forecast.
- Confirm the AppBar displays London as the title with a back arrow on the left.
- Tap the back arrow (or use the system back button) to return to the search screen.
What should I see?
The weather detail screen shows a large temperature reading with a weather icon, a description like "Partly cloudy", and wind speed in km/h. Below that, the 7-day forecast lists each day of the week with high and low temperatures.
- Now test error handling by typing a nonsense word like xyzqwk into the search field and tapping Search.
A SnackBar should appear at the bottom of the screen with the message "City not found. Please check the spelling and try again." The app stays on the search screen instead of crashing.
App not navigating or crashing?
If the button does nothing, check that your emulator has internet access. Open the emulator's browser and visit any website to confirm.
If you see a red error screen, check the VS Code debug console for the specific error message. A common cause is a typo in the import path for weather_detail_screen.dart or weather_service.dart.
help me debug my Flutter navigation
- Try searching for another city like Tokyo or New York to confirm the app works with different locations.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is the full search_screen.dart with the navigation logic:
import 'package:flutter/material.dart';
import '../services/weather_service.dart';
import 'weather_detail_screen.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final _controller = TextEditingController();
final _weatherService = WeatherService();
bool _isLoading = false;
Future<void> _searchCity() async {
final cityName = _controller.text.trim();
if (cityName.isEmpty) return;
setState(() {
_isLoading = true;
});
try {
final weatherData = await _weatherService.getWeather(cityName);
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WeatherDetailScreen(weatherData: weatherData),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Weather App'),
centerTitle: true,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.cloud,
size: 80,
color: Colors.blue,
),
const SizedBox(height: 24),
TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Enter city name',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onSubmitted: (_) => _searchCity(),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isLoading ? null : _searchCity,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Search'),
),
),
],
),
),
);
}
}
And here is the full weather_detail_screen.dart with the AppBar and city name:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/weather_data.dart';
class WeatherDetailScreen extends StatelessWidget {
final WeatherData weatherData;
const WeatherDetailScreen({super.key, required this.weatherData});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(weatherData.cityName),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 16),
Icon(
getWeatherIcon(weatherData.currentWeatherCode),
size: 80,
color: Colors.blue,
),
const SizedBox(height: 8),
Text(
'${weatherData.currentTemperature.round()}°C',
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
),
),
Text(
getWeatherDescription(weatherData.currentWeatherCode),
style: const TextStyle(fontSize: 18, color: Colors.grey),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.air, size: 20, color: Colors.grey),
const SizedBox(width: 4),
Text(
'${weatherData.currentWindSpeed} km/h',
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
const SizedBox(height: 32),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'7-Day Forecast',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
...weatherData.dailyForecast.map(
(day) => _buildDayRow(day),
),
],
),
),
);
}
Widget _buildDayRow(DailyForecast day) {
final date = DateTime.parse(day.date);
final dayName = DateFormat('EEEE').format(date);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
SizedBox(
width: 100,
child: Text(
dayName,
style: const TextStyle(fontSize: 16),
),
),
Icon(
getWeatherIcon(day.weatherCode),
color: Colors.blue,
),
const Spacer(),
Text(
'${day.maxTemperature.round()}°',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Text(
'${day.minTemperature.round()}°',
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
}
Your weather app is fully functional. You can search for any city in the world, see current conditions and a 7-day forecast, and navigate back to search again. Next up, you will tackle the Secret Mission and add recent searches with local storage.
Secret mission
Add Recent Searches with Local Storage
Your weather app works great, but every time you restart it, you have to type the same cities again. In this secret mission, you will add a "Recent Searches" feature that remembers previously searched cities using local storage. Users can tap any recent city to instantly re-fetch its weather.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses a free API, so there are no ongoing costs.
Resources you used:
- The weather_app Flutter project folder on your Desktop.
- The Android emulator running through Android Studio.
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with the app further.
- Your project folder stays on your Desktop ready for the next session.
- The Android emulator will close when you shut down Android Studio or your computer.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your project files stay intact for when you return.
- Stop the Flutter app by pressing q in the terminal where it is running.
- Close the Android emulator by clicking the X button on the emulator window.
- Close VS Code. Your project folder remains on your Desktop.
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- Stop the Flutter app by pressing q in the terminal where it is running.
- Close the Android emulator.
- Delete the project folder by running the command for your operating system below.
macOS
rm -rf ~/Desktop/weather_app
Windows
rmdir /s /q %USERPROFILE%\Desktop\weather_app
- Confirm the folder is gone by checking your Desktop. You should no longer see a weather_app folder.
Nice Work!
Nice Work!
You just built a real weather app with Flutter. Users can search for any city in the world and see live forecast data, all running on your Android emulator.
Here's what you learned:
- Built a multi-screen Flutter app from scratch with navigation between a search screen and a weather detail screen.
- Made async HTTP requests to the Open-Meteo API and parsed JSON responses into typed Dart model classes.
- Used stateful widgets to manage loading indicators, user input, and screen state.
- Added persistent recent searches using local storage so users can quickly re-fetch weather for previously searched cities.
Ready to quiz yourself?