Build a Weather App with Flutter
Build a Flutter weather app that fetches live data from a REST API.
Introduction
30 Second Summary
You check the weather on your phone multiple times a day. Every time you tap search and see real data appear, a mobile app is quietly calling an API, parsing the response, and painting it on screen.
In this project, you will build a weather lookup app with Flutter that fetches live data from the OpenWeatherMap API and displays it in a clean mobile interface running on an Android emulator.
What You'll Build
Type any city name into your app, tap Search, and watch the current temperature, weather condition, and humidity appear on a dedicated results screen pulled from a real API.
By the end of this project, you'll have:
- A live weather search where you type a city name like "London" and instantly see the temperature in Celsius, a weather description, and the humidity percentage pulled from a real API.
- A two-screen app with navigation you can demo to anyone: search on one screen, view formatted results on another, and tap back to search again.
- Graceful loading and error handling that shows a spinner while data loads and displays a friendly message if the city does not exist.
- Secret Mission: A scrollable 5-day forecast below the current weather, giving your app the depth of a production weather tool.
Are there any prerequisites?
This project assumes you have basic programming experience with an object-oriented language like Java. Concepts like classes, types, and try-catch will carry over directly into Dart.
You also need the Flutter SDK already installed on your machine. This project covers setting up the Flutter plugin for IntelliJ and your Android emulator from scratch.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This project takes you from an empty Flutter project to a multi-screen weather app that fetches live data from a real API.
Set Up Your Flutter Development Environment
You have the Flutter SDK on your machine, but you cannot see your code run anywhere yet. Without the Flutter plugin in IntelliJ IDEA and a running Android emulator, there is no way to preview the app you are about to build.
In this step, you will install the Flutter plugin to get Flutter-aware editing in IntelliJ, then create an Android Virtual Device so you have a phone screen to test on.
In this step, get ready to:
- Install the Flutter and Dart plugins in IntelliJ IDEA.
- Create and launch an Android emulator.
- Verify the full toolchain with flutter doctor.
Install the Flutter and Dart plugins in IntelliJ
The Flutter plugin gives IntelliJ the ability to create Flutter projects, run them on emulators, and provide Dart code assistance. Without it, IntelliJ treats your Flutter files as plain text.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ and press Enter to open it.
- Open Settings by pressing Cmd+, (macOS) or Ctrl+Alt+S (Windows).
- In the left sidebar, click Plugins.
- Click the Marketplace tab at the top.
- Type flutter in the search box.
- Click Install next to the Flutter plugin. The Dart plugin installs automatically alongside it.
- Click Restart IDE when prompted, then confirm the restart.
After IntelliJ restarts, you should see it reload with no errors.
- Open Settings again (Cmd+, or Ctrl+Alt+S) and click Plugins.
- Switch to the Installed tab. You should see both Flutter and Dart listed and enabled.
Plugin not showing as installed?
Make sure you restarted IntelliJ after clicking Install. The plugin does not activate until the IDE restarts.
If the install button is grayed out, check that your IntelliJ version is up to date. The Flutter plugin requires a recent IntelliJ release.
Help me troubleshoot my Flutter plugin installation in IntelliJ.
Set up an Android emulator
An Android emulator simulates a phone on your computer. You will use it to preview your weather app without needing a physical device.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Android Studio and press Enter to open it.
- From the Welcome screen, click More Actions (the three-dot menu).
- Select Virtual Device Manager.
- Click the + button to create a new virtual device.
- Select Phone as the form factor.
- Pick Pixel 6 from the device list, then click Next.
Why Pixel 6?
Any phone definition works, but Pixel 6 has a common screen size that matches most real devices. It gives you a realistic preview of how your app looks to users.
- Select an x86_64 system image for Android API 36. If you see a Download link next to it, click it and wait for the download to finish.
- Click Next after selecting the image.
- Expand Show Advanced Settings at the bottom of the configuration page.
- Under Emulated Performance, set Graphics to Hardware - GLES 2.0.
- Click Finish.
- Back in the Device Manager, click the Run icon (triangle) next to your new Pixel 6 device.
The emulator window opens and boots into the Android home screen. This may take a minute on first launch.
Emulator not launching?
If you see a "HAXM is not installed" or virtualization error, you need to enable hardware virtualization in your BIOS/UEFI settings. Search your computer model's documentation for how to enable VT-x (Intel) or AMD-V (AMD).
If the emulator is extremely slow, confirm you selected an x86_64 image (not an ARM image) and that hardware graphics acceleration is enabled.
Help me troubleshoot my Android emulator launch issue.
Verify the full toolchain
With the plugin installed and the emulator running, everything should be connected. Time to verify that Flutter can see all the pieces.
Before you run the next command, what do you think flutter doctor will report? Will it find your emulator as a connected device?
- Open a terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Verify your Flutter setup by running:
flutter doctor
You should see checkmarks next to Flutter, Android toolchain, and Connected device. The connected device is your running emulator.
- Confirm your emulator is listed by running:
flutter emulators
You should see your Pixel 6 emulator listed in the output.
Seeing an X next to Android toolchain?
Run flutter doctor --android-licenses and accept all licenses by typing y when prompted. Then run flutter doctor again.
If the connected device line shows no devices, make sure your emulator is still running in the Device Manager window.
Help me fix my flutter doctor issues.
Your development environment is fully wired up. Next, you will create your first Flutter project and see it running live on the emulator.
Create and Run Your First Flutter App
Your development environment is locked in. Flutter, Dart, and an Android emulator are all ready to go.
Before you can build a weather app, you need a running Flutter project on the emulator. Seeing the default starter app proves your entire toolchain works end-to-end. In this step, you will create a project, run it, and test hot reload to confirm code changes appear instantly on screen.
In this step, get ready to:
- Create a new Flutter project called weather_app.
- Build and run the default counter app on the Android emulator.
- Test hot reload by changing text and saving.
Create a new Flutter project
You need a project structure before you can write any code. IntelliJ IDEA's Flutter wizard generates all the boilerplate files for you.
- Switch back to IntelliJ IDEA.
- Click New Project from the Welcome window.
- Select Flutter from the Generators list on the left side.
- Confirm the Flutter SDK path field is populated. If it is empty, browse to your Flutter SDK folder.
- Enter weather_app in the Name field.
- Click Finish.
What happens after you click Finish?
IntelliJ initializes your workspace, creates the project file structure, and runs flutter pub get to fetch dependencies. You can track the progress at the bottom of the window. This takes a few seconds.
- Once the project finishes loading, look at the Project panel on the left side of IntelliJ.
- Expand the lib folder. You should see main.dart inside it.
Don't see the project files?
- Make sure you selected Flutter from the Generators list, not a plain Java or Kotlin project type.
- Check the Flutter SDK path field. If it was empty, IntelliJ cannot generate Flutter files.
- If the project panel shows nothing, wait a few more seconds for the indexing bar at the bottom to finish.
Help me troubleshoot why IntelliJ didn't create my Flutter project files.
Run the app on the emulator
Your Pixel 6 emulator from the previous step should still be running. Now you will build the app and deploy it to that emulator.
- In the IntelliJ toolbar, locate the Flutter Target Selector drop-down on the right-hand side. It shows a list of available devices.
- Select your Pixel 6 emulator from the list.
- Click the Play icon (green triangle) in the toolbar, or press Shift+F10.
Why does the first build take so long?
The first time you run a Flutter app, Gradle downloads Android build tools and compiles everything from scratch. This can take a minute or two. Subsequent builds are much faster because Gradle caches the results.
Before the build finishes, what do you expect to see on the emulator? Think about what a brand-new Flutter project shows by default.
The default Flutter counter app appears. It has an app bar titled "Flutter Demo Home Page", a text saying "You have pushed the button this many times: 0", and a floating action button with a + icon.
- Tap the + button on the emulator screen. The counter increments each time you tap it.
App not appearing on the emulator?
- Make sure the emulator is still running. If you closed it, relaunch it from Android Studio's Device Manager.
- Check the Run pane at the bottom of IntelliJ for error messages. A common issue is the Gradle build failing due to an SDK license not being accepted.
- If you see a license error, run flutter doctor --android-licenses in your terminal and accept all licenses.
Help me figure out why my Flutter app isn't launching on the Android emulator.
Try hot reload
Hot reload is Flutter's killer feature for development speed. When you save a Dart file, the framework injects your updated code into the running app without restarting it. The app state (like the counter value) stays intact.
- In IntelliJ, open lib/main.dart if it is not already open.
- Scroll down to find this line inside the build method of the _MyHomePageState class:
'You have pushed the button this many times:'
- Replace that string with:
'Button presses:'
- Save the file by pressing Ctrl+S (Windows/Linux) or Cmd+S (macOS).
The text on the emulator changes instantly to "Button presses:" without the app restarting. Your counter value stays the same, proving the app state was preserved.
What is hot reload?
Hot reload injects updated source code into the running Dart VM and rebuilds the widget tree. It preserves your app's current state, so you can iterate on UI changes without losing progress.
This means you can tweak colours, layout, and text, then save and see the result in under a second. No more waiting for full rebuilds while experimenting.
Text didn't update on save?
- Confirm you saved the file. Look for the dot or asterisk on the main.dart tab that disappears after saving.
- Make sure the app is still running (green indicator in the Run pane). If the process stopped, click Play again to restart it.
- If you changed code outside of a widget's build method (like a variable declaration), you may need a full restart. Click the green circular arrow (Hot Restart) in the Run pane toolbar.
Help me figure out why hot reload isn't working when I save my Dart file.
Your Flutter project is running on the emulator with hot reload confirmed. Next, you will replace this starter code with your own search screen UI for the weather app.
Build the Search Screen UI
You have a running Flutter app, but it still shows the default counter screen. To turn it into a weather app, you need a text input where the user can type a city name and a button to trigger the search.
This step replaces the starter code with your own custom UI. Along the way, you will learn how Flutter's widget tree works and how a StatefulWidget manages interactive state.
In this step, get ready to:
- Create a new search screen file with a text field and search button.
- Update the app entry point to display your custom screen.
- Verify the search UI renders on the emulator.
Create the search screen file
Flutter organises UI into widgets. Your search screen needs to hold user input (the city name) and track whether a request is loading. That makes it a StatefulWidget, which can redraw itself when its internal state changes.
- In IntelliJ's Project tool window on the left, right-click the lib folder.
- Select New then Dart File.
- Type search_screen and press Enter.
- Paste the following code into the new search_screen.dart file:
import 'package:flutter/material.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
// Stores whatever the user types in the text field
final TextEditingController _controller = TextEditingController();
// Tracks whether a network request is in progress
bool _isLoading = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
What does this code do?
SearchScreen is a StatefulWidget. Unlike a StatelessWidget (which never changes), a StatefulWidget can update what it displays when data inside it changes.
TextEditingController connects to a text field so you can read what the user typed. The dispose method cleans up the controller when the screen is removed from memory.
- Still in search_screen.dart, add the build method inside _SearchScreenState, directly below the dispose method:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Weather App'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Text input where the user types a city name
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Enter city name',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
),
- Directly below the TextField closing bracket, add the search button and close out the build method:
const SizedBox(height: 24),
// Full-width search button
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : () {},
child: const Text('Search'),
),
),
],
),
),
);
}
}
What's happening in this widget tree?
- Scaffold provides the basic screen structure with an AppBar at the top.
- Padding adds 24 pixels of space around the content so it does not touch the screen edges.
- Column stacks children vertically. mainAxisAlignment: MainAxisAlignment.center centres them on screen.
- InputDecoration gives the TextField a hint, a visible border, and a search icon on the left.
- The button's onPressed is an empty function for now. It gets disabled when _isLoading is true.
- Save search_screen.dart (Ctrl+S).
✔️ Awesome, I've got everything!
Great. Make sure you saved the file before moving on.
ⓧ I'd like to double check the full code
Your search_screen.dart should contain all three chunks above assembled in order. Scroll up and confirm you have:
- The import, SearchScreen class, _SearchScreenState with _controller, _isLoading, and dispose (first chunk).
- The build method opening with Scaffold, AppBar, Padding, Column, and TextField (second chunk).
- The SizedBox, ElevatedButton, and all closing braces (third chunk).
Update the app entry point
The app still points at the old counter widget. You need to import your new file and set SearchScreen as the home screen.
- Open lib/main.dart and replace all of its content with the following code:
import 'package:flutter/material.dart';
import 'search_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
// App title and blue color theme
title: 'Weather App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const SearchScreen(),
);
}
}
What changed from the starter code?
- import 'search_screen.dart' tells Dart where to find your new widget.
- The app title changed from 'Flutter Demo' to 'Weather App'.
- The color scheme seed changed from Colors.deepPurple to Colors.blue for a weather-appropriate palette.
- home now points to const SearchScreen() instead of the old counter page.
- Save main.dart (Ctrl+S).
✔️ Awesome, I've got everything!
Great. Double check you saved both files before verifying.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'search_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({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(),
);
}
}
Verify the search screen renders
Both files are saved. Before you look at the emulator, predict what you expect to see. Will the counter app still be there, or something new?
- Look at the Android emulator. Flutter's hot reload automatically applies the changes when you save.
- You should see a blue-themed app bar titled Weather App at the top of the screen.
- In the centre of the screen, you should see a text field with "Enter city name" placeholder text and a search icon.
- Below the text field, a full-width Search button should be visible.
- Tap the text field on the emulator and type a city name (e.g. London) to confirm the keyboard appears and input works.
- Tap the Search button. Nothing happens yet. That is expected because the button's onPressed is an empty function.
Don't see the search screen?
- Make sure you saved both search_screen.dart and main.dart. Unsaved changes are not picked up by hot reload.
- Check the Run tab at the bottom of IntelliJ for red error text. A common cause is a missing closing brace in search_screen.dart.
- If hot reload fails, click the green Run button (Shift+F10) to do a full restart.
Why isn't my search screen showing up after saving?
Your search screen is live on the emulator. Next up, you will connect the Search button to a real weather API so tapping it actually fetches data.
Fetch Weather Data from the API
Your search screen looks great, but the button does nothing yet. To turn this into a real weather app, you need to connect to a live data source that returns actual weather information for any city in the world.
In this step, you will call the OpenWeatherMap API using Dart's async/await pattern, parse the JSON response, and handle errors gracefully when a city is not found.
In this step, get ready to:
- Add the http package and create an OpenWeatherMap API key.
- Build a weather service class that fetches live data from the API.
- Wire the search button to call the service and display results in the debug console.
Add the http package and create your API key
Flutter does not include an HTTP client out of the box. The official Dart http package gives you a simple way to make GET requests to any URL.
- Open the IntelliJ terminal (Alt+F12) and make sure you are inside your weather_app project folder.
- Add the http package to your project by running this command:
flutter pub add http
You should see output confirming the package was added, including a line showing + http in your dependencies.
Command not recognised?
Make sure your terminal is pointing to the weather_app root folder (where pubspec.yaml lives). Run ls and confirm you can see pubspec.yaml in the listing.
help me fix flutter pub add http failing in my terminal
Next, you need an API key so OpenWeatherMap can authenticate your requests. The free tier gives you 60 calls per minute and 1,000,000 calls per month, which is more than enough for development.
- Open your browser and go to openweathermap.org.
- Click Sign In, then click Create an Account.
- Fill in your email, create a password, accept the terms, and complete the registration.
- After signing in, click the API keys tab in your account.
- Copy the default API key shown on that page.
Your API key may take up to 2 hours to activate
OpenWeatherMap activates new API keys automatically, but this can take up to a couple of hours after registration. If you get errors when testing later, wait a bit and try again.
Now create the service class that will use this key to call the weather API.
- In IntelliJ, right-click the lib folder in the project sidebar.
- Select New then Dart File.
- Name the file weather_service and press Enter.
- Paste the following code into weather_service.dart:
import 'dart:convert';
import 'package:http/http.dart' as http;
class WeatherService {
static const String _apiKey = 'your-api-key-here';
static const String _baseUrl = 'https://api.openweathermap.org/data/2.5';
Future<Map<String, dynamic>> fetchWeather(String city) async {
final url = Uri.parse('$_baseUrl/weather?q=$city&appid=$_apiKey&units=metric');
final response = await http.get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to fetch weather data');
}
}
}
What does this code do?
- _apiKey stores your OpenWeatherMap key so every request is authenticated.
- _baseUrl is the root URL for the v2.5 weather API.
- fetchWeather builds a URL with the city name and units=metric (so temperatures come back in Celsius), sends a GET request, and parses the JSON response.
- If the response status is not 200, it throws an exception that the caller can catch.
- Replace 'your-api-key-here' with the API key you copied from OpenWeatherMap.
- Save weather_service.dart.
Confirm the file appears in your lib/ folder in the IntelliJ sidebar. You should see main.dart, search_screen.dart, and weather_service.dart listed.
Wire up the search button
Your service class is ready. Now you need to call it from SearchScreen when the user taps the button, using Dart's async/await to wait for the network response without freezing the UI.
- Open lib/search_screen.dart.
- Add this import below the existing material.dart import at the top of the file:
import 'weather_service.dart';
- Find the line bool _isLoading = false; inside _SearchScreenState.
- Add the following method directly below it:
Future<void> _searchWeather() async {
final city = _controller.text.trim();
if (city.isEmpty) return;
setState(() {
_isLoading = true;
});
try {
final weatherService = WeatherService();
final data = await weatherService.fetchWeather(city);
print(data);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('City not found. Please try again.')),
);
} finally {
setState(() {
_isLoading = false;
});
}
}
What does this method do?
- async marks the method as asynchronous so you can use await inside it.
- setState(() { _isLoading = true; }) tells Flutter to rebuild the widget tree with the loading state active.
- await weatherService.fetchWeather(city) pauses execution until the network response arrives, without blocking the UI thread.
- print(data) outputs the raw JSON to IntelliJ's Run console so you can verify data is flowing.
- The catch block shows a SnackBar error message if the API call fails.
- finally always resets _isLoading to false so the button becomes tappable again.
Now update the widgets so the button actually calls this method and shows a loading spinner while the request is in flight.
- In the same file, find the children: [ array inside the Column widget.
- Replace the entire contents of that array (the TextField, SizedBox, and ElevatedButton block) with this updated version:
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Enter city name',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
onSubmitted: (_) => _searchWeather(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _searchWeather,
child: _isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Search'),
),
),
What changed in the widgets?
- onSubmitted: (_) => _searchWeather() lets the user press Enter on the keyboard to search, not just the button.
- onPressed now calls _searchWeather instead of the empty () {} from before.
- The button's child swaps between a CircularProgressIndicator spinner and the text 'Search' based on _isLoading.
- Save search_screen.dart (Ctrl+S).
- Hot reload fires automatically. On the emulator, type London into the text field and tap Search.
What do you expect to see happen when you tap the button?
The button briefly shows a small spinner. Switch to IntelliJ's Run tab at the bottom of the window. You should see a block of JSON output containing keys like main, weather, name, and temperature values in Celsius.
Seeing a 401 error in the console?
A 401 means your API key is not yet active. New keys can take up to 2 hours to activate after registration. Wait a few minutes and try again.
Also double-check that you replaced 'your-api-key-here' in weather_service.dart with your actual key (no extra spaces or quotes).
help me debug why my OpenWeatherMap API call returns 401 Unauthorized
Verify error handling with an invalid city
Your success path works. Now test what happens when a user types a city that does not exist.
- On the emulator, clear the text field and type xyznotacity.
- Before you tap Search: do you expect the app to crash, show an error, or do nothing?
- Tap Search.
A dark SnackBar slides up from the bottom of the screen with the message "City not found. Please try again." The app does not crash, and the button returns to its normal state.
Why does this work?
The WeatherService throws an exception when the API returns a non-200 status code. The catch block in _searchWeather intercepts that exception and displays a user-friendly SnackBar instead of letting the app crash.
✔️ Awesome, I've got everything!
Great. Make sure both weather_service.dart and search_screen.dart are saved.
ⓧ I'd like to double check the full code
Here is lib/weather_service.dart in full:
import 'dart:convert';
import 'package:http/http.dart' as http;
class WeatherService {
static const String _apiKey = 'your-api-key-here';
static const String _baseUrl = 'https://api.openweathermap.org/data/2.5';
Future<Map<String, dynamic>> fetchWeather(String city) async {
final url = Uri.parse('$_baseUrl/weather?q=$city&appid=$_apiKey&units=metric');
final response = await http.get(url);
if (response.statusCode == 200) {
return jsonDecode(response.body);
} else {
throw Exception('Failed to fetch weather data');
}
}
}
For lib/search_screen.dart, confirm these pieces are in place, in order:
- Two imports at the top: package:flutter/material.dart and weather_service.dart.
- The SearchScreen StatefulWidget class and its createState method.
- Inside _SearchScreenState: the _controller and _isLoading fields.
- The _searchWeather() async method with try/catch/finally.
- The dispose() method disposing _controller.
- The build() method with onSubmitted on the TextField and the conditional CircularProgressIndicator in the button.
Your app now fetches real weather data from the internet and handles failures without crashing. Next up, you will build a dedicated results screen to display the temperature, conditions, and humidity in a polished layout instead of just printing to the console.
Display Weather Data on a Results Screen
Your weather data is flowing from the API into the console, but the user never sees it. A weather app that only prints to a debug log is not much of an app.
In this step, you will create a dedicated results screen that displays the temperature, weather condition, and humidity. You will also learn how Flutter's Navigator passes data between screens.
In this step, get ready to:
- Create a WeatherScreen widget to display weather data.
- Add formatted temperature, icons, and a detail card to the results screen.
- Wire up navigation from the search screen to the results screen.
Create the results screen class
The results screen is a separate widget that receives weather data through its constructor. This is how Flutter passes information between screens.
- In IntelliJ, right-click the lib/ folder in the Project panel.
- Select New, then Dart File.
- Name the file weather_screen and press Enter.
- Paste the following code into the new file:
import 'package:flutter/material.dart';
class WeatherScreen extends StatelessWidget {
final String city;
final double temperature;
final String description;
final int humidity;
final double feelsLike;
final String iconCode;
const WeatherScreen({
super.key,
required this.city,
required this.temperature,
required this.description,
required this.humidity,
required this.feelsLike,
required this.iconCode,
});
IconData _getWeatherIcon(String code) {
if (code.startsWith('01')) return Icons.wb_sunny;
if (code.startsWith('02')) return Icons.cloud_queue;
if (code.startsWith('03') || code.startsWith('04')) return Icons.cloud;
if (code.startsWith('09') || code.startsWith('10')) return Icons.water_drop;
if (code.startsWith('11')) return Icons.flash_on;
if (code.startsWith('13')) return Icons.ac_unit;
if (code.startsWith('50')) return Icons.foggy;
return Icons.wb_cloudy;
}
What does this code do?
This creates a StatelessWidget that accepts all the weather data it needs through its constructor. Each field is marked as required, so Flutter enforces that every piece of data is passed in.
The _getWeatherIcon method maps the API's icon codes (like '01' for clear sky, '09' for rain) to Flutter's built-in Icons. This gives each weather condition a visual representation without needing image assets.
- Save weather_screen.dart.
- Check the editor for red underlines. You should see one error because the class has no build method yet. That is expected.
Add the weather display layout
The build method defines what the user actually sees. You will show a large weather icon, the temperature formatted with a degree symbol, and a detail card with humidity and "feels like" info.
- In weather_screen.dart, add the following build method directly below the closing brace of _getWeatherIcon:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(city),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 32),
Icon(
_getWeatherIcon(iconCode),
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'${temperature.toStringAsFixed(1)}\u00B0C',
style: Theme.of(context).textTheme.displayLarge,
),
const SizedBox(height: 8),
Text(
description[0].toUpperCase() + description.substring(1),
style: Theme.of(context).textTheme.headlineSmall,
),
- Directly below that last line, add the detail card and closing brackets:
const SizedBox(height: 32),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Feels like'),
Text('${feelsLike.toStringAsFixed(1)}\u00B0C'),
],
),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Humidity'),
Text('$humidity%'),
],
),
],
),
),
),
],
),
),
);
}
}
What does the build method do?
- The AppBar shows the city name as the screen title. Flutter automatically adds a back arrow because you navigated here with Navigator.push.
- The temperature uses toStringAsFixed(1) to show one decimal place, followed by the Unicode degree symbol (\u00B0).
- The description gets its first letter capitalized so "clear sky" displays as "Clear sky".
- The Card widget groups the "Feels like" temperature and humidity percentage into a clean, elevated container.
- Save weather_screen.dart.
- The red underline from earlier should disappear. If you still see errors, check that the closing braces at the bottom match the code above exactly.
Still seeing red underlines?
- Count your closing braces at the bottom of the file. The build method has deeply nested widgets, so a missing ) or ] is the most common issue.
- Make sure the import at the top says import 'package:flutter/material.dart'; exactly.
- Check that there are no extra blank lines or stray characters between the two code sections you pasted.
Navigate to the results screen
Your results screen exists but nobody can reach it. You need to replace the console print in your search screen with navigation code that pushes the user to WeatherScreen and passes the API data along.
- Open lib/search_screen.dart.
- Find the import section at the top of the file. Below the line that says import 'weather_service.dart';, add this import:
import 'weather_screen.dart';
- Scroll down to the _searchWeather() method. Find this line inside the try block:
print(data);
- Replace that entire print(data); line with the following navigation code:
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WeatherScreen(
city: data['name'],
temperature: data['main']['temp'].toDouble(),
description: data['weather'][0]['description'],
humidity: data['main']['humidity'],
feelsLike: data['main']['feels_like'].toDouble(),
iconCode: data['weather'][0]['icon'],
),
),
);
What does this navigation code do?
- The if (!mounted) return; check ensures the widget is still on screen before navigating. Since fetchWeather is async, the user might have left the screen while waiting.
- Navigator.push adds a new screen on top of the current one, like stacking cards. The back button is added automatically.
- Each field from the JSON response (data['main']['temp'], data['weather'][0]['icon'], etc.) is passed directly to the WeatherScreen constructor.
- Save search_screen.dart.
Before you run the app, what do you think will happen when you search for a city name now?
- Hot reload the app by pressing Ctrl+S or clicking the lightning bolt icon in IntelliJ's toolbar.
- Type a valid city name (like London) into the text field and tap Search.
The app navigates to a new screen showing the city name in the app bar, a large weather icon, the temperature in Celsius, the weather description, and a card with "Feels like" and "Humidity" details. Tap the back arrow in the top left to return to the search screen.
App crashes or shows a black screen?
- Check IntelliJ's Run tab for error messages. A common issue is a typo in the JSON keys like data['weather'][0]['description']. Make sure the keys match exactly.
- If you see a "type 'int' is not a subtype of type 'double'" error, make sure you have .toDouble() after data['main']['temp'] and data['main']['feels_like'].
- Verify that the import at the top of search_screen.dart says import 'weather_screen.dart'; exactly.
✔️ 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 complete lib/weather_screen.dart. Scroll up and confirm the three sections you pasted (class + constructor + icon method, build top, build bottom) are present in this order with no extra blank lines between them.
And here is the updated lib/search_screen.dart with the new import and navigation code:
import 'package:flutter/material.dart';
import 'weather_service.dart';
import 'weather_screen.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
@override
State<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends State<SearchScreen> {
final TextEditingController _controller = TextEditingController();
bool _isLoading = false;
Future<void> _searchWeather() async {
final city = _controller.text.trim();
if (city.isEmpty) return;
setState(() {
_isLoading = true;
});
try {
final weatherService = WeatherService();
final data = await weatherService.fetchWeather(city);
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => WeatherScreen(
city: data['name'],
temperature: data['main']['temp'].toDouble(),
description: data['weather'][0]['description'],
humidity: data['main']['humidity'],
feelsLike: data['main']['feels_like'].toDouble(),
iconCode: data['weather'][0]['icon'],
),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('City not found. Please try again.')),
);
} 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'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Enter city name',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.search),
),
onSubmitted: (_) => _searchWeather(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _isLoading ? null : _searchWeather,
child: _isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Search'),
),
),
],
),
),
);
}
}
Your weather app now has two screens connected by real data. Next up, you will tackle the Secret Mission and extend the app with a 5-day forecast.
Secret mission
Add a 5-Day Forecast
Your weather app shows the current conditions, but real weather apps show what is coming next. In this secret mission, you will call a different API endpoint, filter list data to extract one entry per day, and render a scrollable 5-day forecast using Flutter's ListView widget.
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 with a free-tier API, so there are no ongoing costs.
Resources you used:
- Flutter project folder weather_app on your Desktop.
- Android Virtual Device (emulator) stored locally via Android Studio's Device Manager.
- OpenWeatherMap free account and API key (no cost, no expiration).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the app further.
- Your weather_app project remains on your Desktop, ready for further development.
- Your Android emulator stays available in Android Studio's Device Manager.
- Your OpenWeatherMap API key stays active on the free tier with no charges.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and accounts stay intact for when you return.
- Close the Android emulator window to free up RAM.
- Stop any running Flutter processes in IntelliJ by clicking the red Stop button in the toolbar.
- Your project files and OpenWeatherMap account remain untouched.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/weather_app
Windows
rmdir /s /q %USERPROFILE%\Desktop\weather_app
- Remove the Android Virtual Device by opening Android Studio, clicking More Actions on the Welcome screen, selecting Virtual Device Manager, then clicking the overflow menu (three dots) next to your device and selecting Delete.
- Delete your OpenWeatherMap account by signing in at openweathermap.org, navigating to My profile then Privacy centre in your settings, and following the option to delete your account. This action is permanent.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete Flutter weather app from scratch that fetches live data from a REST API and displays it across multiple screens on an Android emulator.
You've learned how to:
- Built a multi-screen Flutter app with Navigator routing between a search screen and a weather results screen.
- Made HTTP GET requests to the OpenWeatherMap API and parsed JSON responses using Dart's async/await pattern.
- Managed loading states and error handling with setState so the app stays responsive and informative when things go wrong.
- Secret Mission: Added a 5-day forecast section using ListView.builder and API data filtering to display one forecast entry per day.
Ready to quiz yourself?