Build a Weather App with Flutter

Build a live Android weather app with city search using Flutter and Open-Meteo.

Introduction

30 Second Summary

Every morning you check the weather on your phone before deciding what to wear. Behind that simple tap is a real app fetching live data from servers and displaying it in a polished layout.

In this project, you will build a weather app with Flutter that displays live forecasts from the Open-Meteo API and lets you search for any city worldwide.

What You'll Build

Open the app on your Android emulator, see the current temperature and conditions for London, then type "Tokyo" and watch the entire screen update with real weather data in seconds.

By the end of this project, you'll have:

  • A live weather display showing real-time temperature, feels-like temperature, wind speed, and humidity for any city you choose.
  • A horizontal hourly forecast showing the next 24 hours of weather with condition icons you can scroll through.
  • A city search feature where you type a name, pick from results, and the weather updates immediately.
  • Secret Mission: Add a 7-day daily forecast with high/low temperatures below the hourly view.

Are there any prerequisites?

This project assumes you already have VS Code, Android Studio, and Git installed on Windows. You will install the Flutter SDK in Step 1.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Flutter and Create Your Project

You need the Flutter SDK and a running Android emulator before you can see any code come to life on a phone screen.

In this step, you will install Flutter through VS Code, spin up an Android emulator, and create your first Flutter project. By the end, you will have a demo app running on a virtual phone right on your desktop.

In this step, get ready to:
  • Install the Flutter SDK and VS Code extensions.
  • Set up an Android emulator.
  • Create and run your first Flutter project.
Install the Flutter SDK via VS Code

Flutter is Google's framework for building mobile apps from a single codebase. VS Code has a Flutter extension that handles downloading and configuring the SDK for you.

  • In VS Code, click the Extensions icon in the left sidebar (or press Ctrl+Shift+X).
  • Type Flutter in the search bar.
  • Click Install on the Flutter extension by Dart Code.

What does this install?

The Flutter extension automatically installs the Dart extension too. Together they give you syntax highlighting, code completion, and debugging tools for Flutter development.

Now you need the Flutter SDK itself. The extension can download it for you.

  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type flutter and select Flutter: New Project.
  • When prompted, select Download SDK.
  • Choose a short install path like C:\flutter.
  • Click Clone Flutter.
  • When the download finishes, click Add SDK to PATH.

Why a short path?

Flutter's official docs recommend avoiding paths like C:\Program Files\ because they require elevated permissions. A short path like C:\flutter avoids permission issues and keeps terminal commands short.

  • Close all open terminal windows in VS Code (click the trash icon on each terminal tab).
  • Restart VS Code entirely (close and reopen it).

Restarting ensures your terminal picks up the new PATH variable that points to the Flutter SDK.

Now check whether Flutter is ready. Open a new terminal in VS Code by pressing Ctrl+` and run this command:

flutter --version

✔️ I see version 3.44 or higher

Flutter is installed and on your PATH. Skip ahead to the next substep.

ⓧ I see an older version

You have Flutter installed but it is outdated. Update to the latest stable version by running this command:

flutter upgrade

This downloads the latest Flutter SDK on the stable channel. After it finishes, run flutter --version again to confirm you see 3.44 or higher.

ⓧ Command not found

Your terminal cannot find the flutter command. This means the SDK was not added to your PATH correctly.

  • Open the Command Palette (Ctrl+Shift+P) and type flutter.
  • Select Flutter: New Project.
  • If prompted to locate the SDK, click Locate SDK and browse to C:\flutter.
  • Click Add SDK to PATH when offered.
  • Close and reopen VS Code, then open a new terminal and try flutter --version again.
Set up the Android emulator

Android Studio provides the Android SDK and a virtual device manager that lets you run phone emulators on your computer. You already have Android Studio installed, so you just need to create a virtual device.

  • Open Android Studio.
  • On the welcome screen, click More Actions (or the three-dot menu).
  • Select Virtual Device Manager.
  • Click Create Virtual Device.
  • Choose Pixel 7 from the phone list and click Next.
  • Select a recent system image (the one marked Recommended) and click Next.
  • Click Finish to create the device.

What is an emulator?

An emulator is a virtual Android phone that runs on your computer. It behaves exactly like a real phone, so you can test your app without needing physical hardware.

  • In the Virtual Device Manager, click the Play button (triangle icon) next to your Pixel 7 device to launch it.

You should see the Android emulator window appear with the phone booting up to a home screen. Leave it running while you continue.

Emulator not starting?

  • Make sure hardware virtualization (Intel VT-x or AMD-V) is enabled in your BIOS settings.
  • Check that you have at least 8 GB of RAM available. The emulator needs about 2 GB.
  • If you see a HAXM error, install Intel HAXM from the Android Studio SDK Manager under SDK Tools.

Why won't my Android emulator start in Android Studio?

Create and run the Flutter project

With Flutter installed and the emulator running, you can create your weather app project.

  • Switch back to VS Code.
  • Open the Command Palette by pressing Ctrl+Shift+P.
  • Type flutter and select Flutter: New Project.
  • Select Application.
  • Choose a directory to save the project (your Desktop works well).
  • Type weather_app as the project name and press Enter.

VS Code will scaffold the project and open it automatically. You will see the project files appear in the Explorer sidebar.

What did Flutter just create?

The flutter create command generates a full project scaffold including lib/main.dart (your app code), pubspec.yaml (your dependencies), and platform folders for Android and iOS.

The starter app is a simple counter that increments when you tap a button. You will replace it with weather UI in the next step.

Now confirm that Flutter can see all the tools it needs. Open the terminal in VS Code and run this command:

flutter doctor -v

Before you look at the output, what do you expect to see? Since you just installed Flutter and have Android Studio running with an emulator, you should see green checkmarks for Flutter and the Android toolchain.

✔️ I see all green checkmarks

Everything is configured correctly. Flutter can find the SDK, the Android toolchain, and your connected emulator.

ⓧ I see issues with Android toolchain

A common issue is needing to accept Android licenses. Run this command to accept them:

flutter doctor --android-licenses
  • Type y and press Enter for each license prompt.
  • After accepting, run flutter doctor -v again to confirm the issues are resolved.

If you still see issues, make sure Android Studio has the Android SDK Command-line Tools installed. Open Android Studio, go to Settings > Languages & Frameworks > Android SDK > SDK Tools tab and check the box for Android SDK Command-line Tools.

ⓧ Flutter not found

Go back to the first substep and follow the Command not found instructions to fix your PATH. Then return here and run flutter doctor -v again.

Now run the starter app on your emulator.

  • Look at the bottom-right of the VS Code status bar for the device selector.
  • Click it and select your running Android emulator (it will show something like sdk gphone64 x86 64 or Pixel 7 API).
  • Press F5 to launch the app in debug mode.

First launch takes a while

The first time you run a Flutter app, it needs to download additional dependencies and compile the Android build. This can take 2-3 minutes. Subsequent launches will be much faster thanks to caching.

You should see the Flutter demo counter app appear on the emulator. It has an app bar that says Flutter Demo Home Page, a counter showing 0, and a floating action button with a + icon.

  • Tap the + button on the emulator to confirm the counter increments.

App not launching?

  • Make sure the emulator is selected in the VS Code status bar device selector, not Chrome or No Device.
  • Check the VS Code terminal for errors. A common issue is Gradle needing to download dependencies on first run.
  • If you see a timeout error, the emulator may still be booting. Wait for the home screen to fully load, then press F5 again.

Why won't my Flutter app launch on the Android emulator?

Your development environment is ready. Next up, you will replace this demo counter with a weather app UI that displays temperature, conditions, and an hourly forecast.

Build the Weather UI with Hardcoded Data

In Step 1 you got Flutter running and saw the demo counter app on your emulator. Now it is time to replace that demo with the actual weather interface.

Before connecting to a real weather API, you will build the full UI with fake data. This lets you focus entirely on learning Flutter widgets without worrying about networking or JSON parsing.

In this step, get ready to:
  • Replace the default code with a weather layout showing temperature, condition, and metrics.
  • Add a horizontal hourly forecast row with 24 scrollable cards.
  • Style the app with a dark theme, large typography, and card containers.
Replace the default code with the app shell

Every Flutter app starts with a MaterialApp widget that sets up theming and navigation. You will create a custom app shell with a dark color scheme so the weather data stands out.

  • In VS Code, open lib/main.dart from the Explorer sidebar.
  • Select all the existing code (Ctrl+A) and delete it.
  • Paste the following app shell code into the empty file:
import 'package:flutter/material.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.dark(
          primary: Colors.blueGrey,
          surface: const Color(0xFF1E1E2E),
        ),
        scaffoldBackgroundColor: const Color(0xFF1E1E2E),
        useMaterial3: true,
      ),
      home: const WeatherScreen(),
    );
  }
}

What does this code do?

runApp() is the entry point that launches your Flutter app. It takes a widget tree starting with WeatherApp.

The ThemeData sets up a dark color scheme with a deep blue-grey background (0xFF1E1E2E). This makes white text readable and gives the app a sleek, modern feel.

Add the weather screen with temperature display

The WeatherScreen widget is where all the weather content lives. You will start with just the temperature to get something on screen quickly.

  • Directly below the WeatherApp class closing brace, paste the following WeatherScreen class:
class WeatherScreen extends StatelessWidget {
  const WeatherScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        title: const Text('London'),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            const SizedBox(height: 20),
            const Text(
              '15°C',
              style: TextStyle(
                fontSize: 64,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

What is a StatelessWidget?

A StatelessWidget is a widget that does not change after it is built. Since you are using hardcoded data right now, nothing needs to update dynamically.

The Scaffold provides the basic page structure: an AppBar at the top and a body below it. The Column stacks child widgets vertically.

  • Save the file (Ctrl+S). Flutter hot reloads the app on your emulator.
  • You should see a dark screen with "London" in the app bar and a large "15°C" in the center.

Seeing a red error screen on the emulator?

Make sure the WeatherScreen class is pasted BELOW the WeatherApp class closing brace, not inside it. Check that every opening parenthesis and brace has a matching close.

If hot reload does not work, try a hot restart by pressing Ctrl+Shift+F5 in VS Code.

Help me debug my Flutter weather screen error.

Add the weather icon, condition, and metrics

A temperature number alone does not tell the full story. You will add a weather icon, a condition label, and wind/humidity metrics.

  • In lib/main.dart, find this line inside the children list:
            const SizedBox(height: 20),
            const Text(
              '15°C',
              style: TextStyle(
                fontSize: 64,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
  • Replace those lines with the following expanded version that adds the icon above the temperature and the condition label below it:
            const SizedBox(height: 20),
            const Text('⛅', style: TextStyle(fontSize: 64)),
            const SizedBox(height: 8),
            const Text(
              '15°C',
              style: TextStyle(
                fontSize: 64,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
            const Text(
              'Partly Cloudy',
              style: TextStyle(fontSize: 16, color: Colors.white70),
            ),
            const SizedBox(height: 24),
  • Now add the wind and humidity metrics row. Paste the following code directly below the const SizedBox(height: 24), line you just added (still inside the children list):
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                  decoration: BoxDecoration(
                    color: const Color(0xFF2A2A3E),
                    borderRadius: BorderRadius.circular(20),
                  ),
                  child: const Row(
                    children: [
                      Icon(Icons.air, color: Colors.white70, size: 18),
                      SizedBox(width: 6),
                      Text('12 km/h', style: TextStyle(color: Colors.white70)),
                    ],
                  ),
                ),
                const SizedBox(width: 16),
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
                  decoration: BoxDecoration(
                    color: const Color(0xFF2A2A3E),
                    borderRadius: BorderRadius.circular(20),
                  ),
                  child: const Row(
                    children: [
                      Icon(Icons.water_drop, color: Colors.white70, size: 18),
                      SizedBox(width: 6),
                      Text('72%', style: TextStyle(color: Colors.white70)),
                    ],
                  ),
                ),
              ],
            ),

What does the metrics row do?

Each metric is a Container styled as a rounded pill (borderRadius: 20) with an icon and text inside. The outer Row places them side by side, centered horizontally.

The color 0xFF2A2A3E is a slightly lighter shade than the background, creating a subtle card effect without harsh borders.

  • Save the file (Ctrl+S).
  • On the emulator you should see the weather icon, temperature, "Partly Cloudy" label, and two metric chips showing wind speed and humidity.

Seeing an overflow error or misaligned layout?

Check that the Row widget with the metrics is inside the children: [ list of the Column, not outside it. Every widget in a Column children list must end with a comma.

Help me fix my Flutter layout overflow error.

Add a horizontal hourly forecast row

Weather apps show an hour-by-hour preview so you can plan your day. You will build a horizontally scrollable list of 24 forecast cards using ListView.builder.

  • In lib/main.dart, find the closing ], of the Column children list (it comes right after the metrics Row closing).
  • Add the following heading section ABOVE that ], line:
            const SizedBox(height: 32),
            const Align(
              alignment: Alignment.centerLeft,
              child: Text(
                'Hourly Forecast',
                style: TextStyle(
                  fontSize: 18,
                  fontWeight: FontWeight.bold,
                  color: Colors.white,
                ),
              ),
            ),
            const SizedBox(height: 12),
  • Directly below that const SizedBox(height: 12), line, paste the horizontal list:
            SizedBox(
              height: 120,
              child: ListView.builder(
                scrollDirection: Axis.horizontal,
                itemCount: 24,
                itemBuilder: (context, index) {
                  return Container(
                    width: 70,
                    margin: const EdgeInsets.only(right: 8),
                    padding: const EdgeInsets.all(8),
                    decoration: BoxDecoration(
                      color: const Color(0xFF2A2A3E),
                      borderRadius: BorderRadius.circular(12),
                    ),
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Text(
                          '${index.toString().padLeft(2, '0')}:00',
                          style: const TextStyle(color: Colors.white70, fontSize: 12),
                        ),
                        const SizedBox(height: 8),
                        const Text('⛅', style: TextStyle(fontSize: 24)),
                        const SizedBox(height: 8),
                        Text(
                          '${15 + index % 5}°',
                          style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
                        ),
                      ],
                    ),
                  );
                },
              ),
            ),

What does ListView.builder do?

ListView.builder creates list items on demand instead of building all 24 at once. Setting scrollDirection: Axis.horizontal makes the list scroll sideways instead of up and down.

The index parameter tells each card what hour it represents. The expression 15 + index % 5 generates varying temperatures between 15 and 19 degrees for visual variety.

  • Save the file (Ctrl+S).

Before you look at the emulator, what do you expect to see below the metrics chips?

You should see an "Hourly Forecast" heading followed by a row of rounded cards you can swipe horizontally. Each card shows a time like "00:00", a cloud emoji, and a temperature.

Cards not scrolling horizontally?

Make sure the SizedBox wrapping the ListView.builder has height: 120. Without a fixed height, the horizontal list cannot determine how much vertical space to use and may throw an error.

Also confirm scrollDirection: Axis.horizontal is set inside the ListView.builder constructor.

Help me fix my horizontal ListView in Flutter.

Review the styling choices

Take a moment to look at how the styling works across your app. The dark theme, large fonts, and card containers all combine to create a polished weather interface.

How does the styling come together?

The ColorScheme.dark() in your ThemeData tells Flutter to use light-colored text on a dark background by default. You customized the surface color to 0xFF1E1E2E for a deep navy-grey tone.

The temperature uses fontSize: 64 and fontWeight: FontWeight.bold to make it the largest element on screen. The metric chips and hourly cards both use 0xFF2A2A3E as their background, giving a subtle layered effect without hard borders.

Before checking the emulator one final time, predict what you expect the full screen to look like from top to bottom.

You should see: "London" in the app bar, a cloud emoji, a large "15°C", "Partly Cloudy" underneath, wind and humidity chips, an "Hourly Forecast" heading, and a scrollable row of 24 cards.

✔️ Awesome, I've got everything!

Great work. Make sure you have saved lib/main.dart and the emulator shows the full weather UI.

ⓧ I'd like to double check the full code

Your lib/main.dart should contain these sections in order. Scroll through your file and confirm each one is present:

  • The import statement, main() function, and WeatherApp class with the dark ThemeData.
  • The WeatherScreen class with Scaffold, AppBar showing "London", and SingleChildScrollView body.
  • Inside the Column children: emoji icon, temperature Text, "Partly Cloudy" label, SizedBox(height: 24).
  • The metrics Row with two Container chips (wind and humidity).
  • The "Hourly Forecast" heading wrapped in Align.
  • The SizedBox(height: 120) containing ListView.builder with scrollDirection: Axis.horizontal and itemCount: 24.

Your weather UI is looking sharp. Next up, you will replace all this hardcoded data with live weather from the Open-Meteo API.

Fetch Live Weather from Open-Meteo

Your app looks polished, but the data on screen is frozen in time. The temperature never changes, the hourly forecast repeats the same pattern, and every value is a guess you typed in yourself.

In this step, you will replace the hardcoded data with real weather pulled from the Open-Meteo API. This is where you learn async programming and HTTP requests in Flutter.

In this step, get ready to:
  • Add the http package and create a weather service file that calls the Open-Meteo API.
  • Convert your UI widget to a stateful widget that fetches live data on startup.
  • Display real hourly temperatures and weather icons from live API data.
Add the http package and create a weather service

Flutter does not include an HTTP client by default. The official http package from the Dart team gives you a simple way to make network requests.

  • Install the http package by running this command in your VS Code terminal:
flutter pub add http

You should see output confirming the package was added to pubspec.yaml and resolved successfully.

Seeing a network error?

Make sure you have an active internet connection. If you are behind a corporate proxy, check that your terminal can reach pub.dev.

Help me fix this network error

Now you will create a new file that handles all communication with the Open-Meteo API. This keeps your networking logic separate from your UI code.

  • In the VS Code Explorer sidebar, right-click the lib folder.
  • Select New File and name it weather_service.dart.
  • Add the imports and the WeatherData model class by pasting this code:
import 'dart:convert';
import 'package:http/http.dart' as http;

// Holds all the weather values we get back from the API
class WeatherData {
  final double currentTemperature;
  final double apparentTemperature;
  final int currentWeatherCode;
  final double windSpeed;
  final int humidity;
  final List<String> hourlyTimes;
  final List<double> hourlyTemperatures;
  final List<int> hourlyWeatherCodes;

  WeatherData({
    required this.currentTemperature,
    required this.apparentTemperature,
    required this.currentWeatherCode,
    required this.windSpeed,
    required this.humidity,
    required this.hourlyTimes,
    required this.hourlyTemperatures,
    required this.hourlyWeatherCodes,
  });
}

What does this code do?

The two imports bring in JSON decoding (built into Dart) and the HTTP package you just installed.

WeatherData is a plain Dart class that holds every weather value the UI needs. By defining the shape up front, you get compile-time safety when you access fields later.

  • Below the WeatherData class, add the LocationResult model. This represents a city returned by a search (you will use it in the next step):
// Represents a city from the geocoding search
class LocationResult {
  final String name;
  final String country;
  final double latitude;
  final double longitude;

  LocationResult({
    required this.name,
    required this.country,
    required this.latitude,
    required this.longitude,
  });
}

What does LocationResult hold?

Each LocationResult stores a city's name, country, and coordinates. You will wire this up for city search in Step 4.

  • Below LocationResult, start the WeatherService class with the first part of the getWeather method. This builds the API URL and makes the HTTP request:
class WeatherService {
  // Fetches current + hourly weather for given coordinates
  Future<WeatherData> getWeather(double latitude, double longitude) async {
    final url = Uri.parse(
      'https://api.open-meteo.com/v1/forecast'
      '?latitude=$latitude'
      '&longitude=$longitude'
      '&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m'
      '&hourly=temperature_2m,weather_code'
      '&forecast_days=1'
      '&timezone=auto',
    );

    final response = await http.get(url);

    if (response.statusCode != 200) {
      throw Exception('Failed to fetch weather data');
    }

What is this URL doing?

The Open-Meteo forecast endpoint accepts latitude, longitude, and parameter lists. You request current conditions (temperature, wind, humidity) plus hourly forecasts for one day.

The &timezone=auto parameter tells the API to return times in the location's local time zone.

  • Directly below the error check (still inside getWeather), add the JSON parsing logic that extracts values and returns a WeatherData object:
    // Parse the JSON response
    final json = jsonDecode(response.body);
    final current = json['current'];
    final hourly = json['hourly'];

    // Extract hourly time strings ("14:00", "15:00", etc.)
    final List<String> times = (hourly['time'] as List)
        .map((t) => (t as String).substring(11, 16))
        .toList();
    final List<double> temperatures = (hourly['temperature_2m'] as List)
        .map((t) => (t as num).toDouble())
        .toList();
    final List<int> weatherCodes = (hourly['weather_code'] as List)
        .map((c) => (c as num).toInt())
        .toList();

    return WeatherData(
      currentTemperature: (current['temperature_2m'] as num).toDouble(),
      apparentTemperature: (current['apparent_temperature'] as num).toDouble(),
      currentWeatherCode: (current['weather_code'] as num).toInt(),
      windSpeed: (current['wind_speed_10m'] as num).toDouble(),
      humidity: (current['relative_humidity_2m'] as num).toInt(),
      hourlyTimes: times,
      hourlyTemperatures: temperatures,
      hourlyWeatherCodes: weatherCodes,
    );
  }

How does the parsing work?

The API returns ISO 8601 timestamps like 2026-08-01T14:00. The .substring(11, 16) call extracts just the time portion (14:00).

Each list from the response is mapped to the correct Dart type using .toDouble() or .toInt() so you get type-safe values to display in the UI.

  • Finally, add the searchLocation method below getWeather and close the class. You will use this in Step 4 for city search:
  // Searches for cities by name using the geocoding API
  Future<List<LocationResult>> searchLocation(String query) async {
    final url = Uri.parse(
      'https://geocoding-api.open-meteo.com/v1/search'
      '?name=$query'
      '&count=5'
      '&language=en',
    );

    final response = await http.get(url);

    if (response.statusCode != 200) {
      throw Exception('Failed to search location');
    }

    final json = jsonDecode(response.body);

    if (json['results'] == null) {
      return [];
    }

    return (json['results'] as List).map((item) {
      return LocationResult(
        name: item['name'] as String,
        country: item['country'] as String? ?? '',
        latitude: (item['latitude'] as num).toDouble(),
        longitude: (item['longitude'] as num).toDouble(),
      );
    }).toList();
  }
}

What does searchLocation do?

It calls the Open-Meteo Geocoding API with a city name and returns up to 5 matching locations. If the API finds nothing, it returns an empty list instead of crashing.

  • Save weather_service.dart.
  • Confirm the file has no errors by running this command in the terminal:
flutter analyze

You should see No issues found! in the output.

Seeing analysis errors?

Check that your import 'package:http/http.dart' as http; line is at the top of the file. If VS Code shows a red underline on http, the package may not have installed correctly. Re-run flutter pub get in the terminal.

Help me fix these analysis errors

Connect the service to the UI

Your weather service is ready to fetch data, but main.dart still uses a StatelessWidget with hardcoded values. You need to convert it to a StatefulWidget so it can hold changing data and trigger network calls.

  • In lib/main.dart, find this import line at the very top:
import 'package:flutter/material.dart';
  • Add a new import directly below it, so both lines look like this:
import 'package:flutter/material.dart';
import 'weather_service.dart';
  • Now find the WeatherScreen class (starts with class WeatherScreen extends StatelessWidget).
  • Delete everything from class WeatherScreen extends StatelessWidget { all the way down to the end of the file.
  • Replace it with this StatefulWidget declaration and state class opening:
// StatefulWidget so we can hold and update weather data
class WeatherScreen extends StatefulWidget {
  const WeatherScreen({super.key});

  @override
  State<WeatherScreen> createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen> {
  final WeatherService _weatherService = WeatherService();
  WeatherData? _weatherData;
  bool _isLoading = true;
  String _cityName = 'London';

  @override
  void initState() {
    super.initState();
    // Fetch London's weather as soon as the widget loads
    _fetchWeather(51.51, -0.13);
  }

What changed from StatelessWidget to StatefulWidget?

A StatelessWidget cannot hold data that changes over time. A StatefulWidget pairs with a State class that can store variables and rebuild the UI when they change.

The _isLoading flag starts as true so the app shows a spinner while the network request completes. Once data arrives, setState flips it to false and the UI rebuilds with real values.

  • Directly below initState, add the _fetchWeather method that calls your service and handles errors:
  // Calls the weather service and updates state with the result
  Future<void> _fetchWeather(double latitude, double longitude) async {
    setState(() {
      _isLoading = true;
    });
    try {
      final data = await _weatherService.getWeather(latitude, longitude);
      setState(() {
        _weatherData = data;
        _isLoading = false;
      });
    } catch (e) {
      setState(() {
        _isLoading = false;
      });
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: const Text('Failed to fetch weather. Tap to retry.'),
            action: SnackBarAction(
              label: 'Retry',
              onPressed: () => _fetchWeather(latitude, longitude),
            ),
          ),
        );
      }
    }
  }

How does error handling work here?

The try/catch block catches any network failure. If the request fails, the app shows a SnackBar at the bottom of the screen with a Retry button so the user can try again without restarting the app.

  • Below _fetchWeather, add the _getWeatherIcon helper and the build method:
  // Maps WMO weather codes to emoji icons
  String _getWeatherIcon(int code) {
    if (code <= 1) return '☀️';
    if (code <= 3) return '⛅';
    if (code <= 48) return '🌫️';
    if (code <= 67) return '🌧️';
    if (code <= 77) return '❄️';
    return '⛈️';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        title: Text(_cityName),
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _buildWeatherContent(),
    );
  }

What are WMO weather codes?

The World Meteorological Organization assigns numeric codes to weather conditions. Code 0-1 means clear sky, 2-3 means partly cloudy, 45-48 is fog, 51-67 is rain, 71-77 is snow, and 80+ is storms.

The build method shows a CircularProgressIndicator while loading, then switches to _buildWeatherContent() once data arrives.

Display live hourly data in the forecast row

The build method references _buildWeatherContent() which does not exist yet. This method builds the entire weather display using real data from the API.

  • Below the build method, add the first part of _buildWeatherContent showing current temperature and metrics:
  Widget _buildWeatherContent() {
    final weather = _weatherData!;
    return SingleChildScrollView(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          const SizedBox(height: 20),
          Text(
            _getWeatherIcon(weather.currentWeatherCode),
            style: const TextStyle(fontSize: 64),
          ),
          const SizedBox(height: 8),
          Text(
            '${weather.currentTemperature.round()}°C',
            style: const TextStyle(
              fontSize: 64,
              fontWeight: FontWeight.bold,
              color: Colors.white,
            ),
          ),
          Text(
            'Feels like ${weather.apparentTemperature.round()}°C',
            style: const TextStyle(fontSize: 16, color: Colors.white70),
          ),
          const SizedBox(height: 24),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              _buildMetricChip(Icons.air, '${weather.windSpeed} km/h'),
              const SizedBox(width: 16),
              _buildMetricChip(Icons.water_drop, '${weather.humidity}%'),
            ],
          ),

What does this section display?

This renders the large weather icon (mapped from the WMO code), the current temperature, a "feels like" value, and two metric chips for wind speed and humidity. All values now come from weather. fields instead of hardcoded strings.

  • Continue _buildWeatherContent by adding the hourly forecast section. Paste this directly below the Row widget (still inside the children list):
          const SizedBox(height: 32),
          const Align(
            alignment: Alignment.centerLeft,
            child: Text(
              'Hourly Forecast',
              style: TextStyle(
                fontSize: 18,
                fontWeight: FontWeight.bold,
                color: Colors.white,
              ),
            ),
          ),
          const SizedBox(height: 12),
          SizedBox(
            height: 120,
            child: ListView.builder(
              scrollDirection: Axis.horizontal,
              itemCount: 24,
              itemBuilder: (context, index) {
                return Container(
                  width: 70,
                  margin: const EdgeInsets.only(right: 8),
                  padding: const EdgeInsets.all(8),
                  decoration: BoxDecoration(
                    color: const Color(0xFF2A2A3E),
                    borderRadius: BorderRadius.circular(12),
                  ),
  • Immediately below the decoration line, add the child Column that displays each hourly card's content, then close all the brackets for _buildWeatherContent:
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Text(
                        weather.hourlyTimes[index],
                        style: const TextStyle(color: Colors.white70, fontSize: 12),
                      ),
                      const SizedBox(height: 8),
                      Text(
                        _getWeatherIcon(weather.hourlyWeatherCodes[index]),
                        style: const TextStyle(fontSize: 24),
                      ),
                      const SizedBox(height: 8),
                      Text(
                        '${weather.hourlyTemperatures[index].round()}°',
                        style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
                      ),
                    ],
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }

How does the hourly row use live data?

Instead of the old hardcoded '${index.toString().padLeft(2, '0')}:00', each card now pulls the real time from weather.hourlyTimes[index].

The weather icon changes per hour based on weather.hourlyWeatherCodes[index], so you see sun, cloud, or rain icons matching the actual forecast.

  • Below _buildWeatherContent, add the helper method that builds the wind and humidity chips, then close the class:
  Widget _buildMetricChip(IconData icon, String label) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      decoration: BoxDecoration(
        color: const Color(0xFF2A2A3E),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Row(
        children: [
          Icon(icon, color: Colors.white70, size: 18),
          const SizedBox(width: 6),
          Text(label, style: const TextStyle(color: Colors.white70)),
        ],
      ),
    );
  }
}
  • Save lib/main.dart.

Before you run the app, what do you think you will see on the emulator? Will it still show 15°C, or something different?

  • Press Ctrl+S to trigger a hot reload. If the app does not update, press Shift+F5 to stop and then F5 to do a full restart.

You should see a loading spinner for a moment, then real weather data for London appears. The temperature matches the current conditions, the hourly forecast scrolls with actual predicted values, and the weather icon reflects the real sky.

Still seeing a loading spinner that never stops?

Your emulator needs internet access to reach the Open-Meteo API. Open the Chrome browser inside the emulator and try visiting any website. If pages do not load, restart the emulator from Android Studio.

If the browser works but the app still spins, check the VS Code Debug Console for error messages. A common issue is a typo in the API URL parameters.

Help me debug this loading issue

Verify against the real API

Open this URL in your computer's browser to see the raw JSON your app receives:

https://api.open-meteo.com/v1/forecast?latitude=51.51&longitude=-0.13&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,relative_humidity_2m&hourly=temperature_2m,weather_code&forecast_days=1&timezone=auto

The temperature_2m value in the JSON should match what your app displays.

✔️ Awesome, I've got everything!

Great work. Confirm you have saved both lib/weather_service.dart and lib/main.dart.

ⓧ I'd like to double check the full code

Scroll up through your work and confirm each section is present in order:

In lib/weather_service.dart:

  • Imports (dart:convert and package:http/http.dart).
  • WeatherData class with 8 fields and a required constructor.
  • LocationResult class with 4 fields.
  • WeatherService class containing getWeather and searchLocation methods.

In lib/main.dart:

  • Both imports (material.dart and weather_service.dart).
  • WeatherApp class (unchanged from previous step).
  • WeatherScreen as a StatefulWidget with _WeatherScreenState.
  • State variables: _weatherService, _weatherData, _isLoading, _cityName.
  • initState calling _fetchWeather(51.51, -0.13).
  • _fetchWeather with try/catch and SnackBar error handling.
  • _getWeatherIcon mapping WMO codes to emojis.
  • build method with CircularProgressIndicator and _buildWeatherContent().
  • _buildWeatherContent displaying current weather and hourly ListView.builder.
  • _buildMetricChip helper at the bottom.

Your app now pulls live weather data from the internet every time it launches. Next up, you will add a search bar so users can look up weather for any city in the world.

Add City Search

Your app now displays real weather data for London. That is useful for Londoners, but everyone else is stuck looking at someone else's forecast.

In this step, you will add a city search feature. The learner types a city name, picks from a list of results, and the weather updates instantly. You already built the searchLocation method in weather_service.dart during the last step. Now you will wire it up to a search bar in the UI.

In this step, get ready to:
  • Add a search icon and text field to the app bar.
  • Display geocoding results in a dropdown overlay.
  • Update the weather when the user taps a city.
Add search state and logic

Before adding any UI, you need state variables to track whether the user is searching, what results came back, and a controller to manage the text field.

  • In lib/main.dart, find the existing state variables inside _WeatherScreenState. Add the following three new variables and the _searchCity method below _fetchWeather:
  // Track search results, search mode, and text input
  List<LocationResult> _searchResults = [];
  bool _isSearching = false;
  final TextEditingController _searchController = TextEditingController();

  // Search for cities when the user types
  Future<void> _searchCity(String query) async {
    if (query.length < 2) {
      setState(() {
        _searchResults = [];
      });
      return;
    }
    try {
      final results = await _weatherService.searchLocation(query);
      setState(() {
        _searchResults = results;
      });
    } catch (e) {
      setState(() {
        _searchResults = [];
      });
    }
  }

What does this code do?

The three state variables track search state: _searchResults holds the list of matching cities, _isSearching toggles the UI between showing the city name and a text field, and _searchController lets you programmatically clear the text field.

The _searchCity method waits until the user has typed at least 2 characters before calling the geocoding API. This prevents unnecessary network requests on every single keystroke.

  • Save main.dart.
  • The app should still run normally on the emulator showing London's weather. The new variables and method are ready but not connected to any UI yet.
Build a search input in the app bar

The app bar currently shows a static city name. You will replace it with a conditional layout: a TextField when the user is searching, and the city name when they are not.

  • In the build method, find the existing appBar property. Replace the entire AppBar(...) with this updated version:
      appBar: AppBar(
        backgroundColor: Colors.transparent,
        elevation: 0,
        // Show TextField when searching, city name otherwise
        title: _isSearching
            ? TextField(
                controller: _searchController,
                autofocus: true,
                style: const TextStyle(color: Colors.white),
                decoration: const InputDecoration(
                  hintText: 'Search city...',
                  hintStyle: TextStyle(color: Colors.white54),
                  border: InputBorder.none,
                ),
                onChanged: _searchCity,
              )
            : Text(_cityName),
        // Toggle search mode with the icon button
        actions: [
          IconButton(
            icon: Icon(_isSearching ? Icons.close : Icons.search),
            onPressed: () {
              setState(() {
                _isSearching = !_isSearching;
                if (!_isSearching) {
                  _searchResults = [];
                  _searchController.clear();
                }
              });
            },
          ),
        ],
      ),

What does this code do?

The title property uses a ternary operator to switch between a TextField (when _isSearching is true) and the city name text. The onChanged callback fires _searchCity on every keystroke.

The IconButton in actions toggles search mode on and off. When the user closes search, it clears the results and resets the text field.

  • Save main.dart and check the emulator.
  • You should see a search icon (magnifying glass) in the top-right corner of the app bar. Tap it. The city name disappears and a text field with the hint "Search city..." appears.

Don't see the search icon?

Make sure you replaced the entire AppBar(...) widget, not just the title property. The new version includes an actions list that the original did not have.

If hot reload shows a red error screen, check that your closing parentheses and brackets match. The actions list ends with ], and the AppBar ends with ),.

Help me fix my AppBar search icon not showing up in Flutter.

Display search results in a dropdown

The searchLocation method you built in weather_service.dart already calls the Open-Meteo Geocoding API and returns a list of LocationResult objects. Now you need a widget to display those results as a tappable list.

  • First, add the _selectCity method below _searchCity in your _WeatherScreenState class. This handles what happens when the user picks a city:
  // Handle city selection from search results
  void _selectCity(LocationResult location) {
    setState(() {
      _cityName = location.name;
      _searchResults = [];
      _isSearching = false;
      _searchController.clear();
    });
    _fetchWeather(location.latitude, location.longitude);
  }

What does this code do?

When the user taps a search result, _selectCity stores the selected city name, clears the search UI, and calls _fetchWeather with the new coordinates. The weather display updates automatically because setState triggers a rebuild.

  • Next, update the body property in the build method. Find this line:
          : _buildWeatherContent(),
  • Replace it with a Stack that layers the weather content and the search results overlay:
          : Stack(
              children: [
                _buildWeatherContent(),
                if (_searchResults.isNotEmpty) _buildSearchResults(),
              ],
            ),

Why use a Stack?

A Stack widget layers its children on top of each other. The weather content sits at the bottom, and the search results overlay floats on top when results exist. This keeps the weather visible behind the dropdown.

  • Finally, add the _buildSearchResults method above _buildWeatherContent in your class:
  Widget _buildSearchResults() {
    return Positioned(
      top: 0,
      left: 0,
      right: 0,
      child: Material(
        color: const Color(0xFF2A2A3E),
        elevation: 4,
        child: ListView.builder(
          shrinkWrap: true,
          itemCount: _searchResults.length,
          itemBuilder: (context, index) {
            final location = _searchResults[index];
            return ListTile(
              title: Text(
                '${location.name}, ${location.country}',
                style: const TextStyle(color: Colors.white),
              ),
              onTap: () => _selectCity(location),
            );
          },
        ),
      ),
    );
  }

What does this code do?

The Positioned widget pins the dropdown to the top of the Stack, stretching it across the full width. Each result is a ListTile showing the city name and country. Tapping a tile calls _selectCity to load that city's weather.

  • Save main.dart.
  • Tap the search icon in the emulator, then type Tokyo. You should see a dark dropdown appear below the app bar with results like "Tokyo, Japan".

No results appearing when you type?

Make sure the onChanged: _searchCity callback is present in your TextField inside the AppBar. Without it, typing does not trigger a search.

Remember the geocoding API requires at least 2 characters. If you only typed one letter, try typing a full city name.

Help me debug why my Flutter search results aren't showing.

Test the full city search flow

Everything is wired up. The search field calls _searchCity on each keystroke, results appear in the dropdown, and tapping a result calls _selectCity which fetches new weather data. Time to test it end-to-end.

Before you tap a result, think about what should happen. Will the app bar change? Will the temperature change? Will the search dropdown stay visible?

  • In the emulator, tap the search icon and type Tokyo.
  • Tap "Tokyo, Japan" in the results dropdown.

The dropdown disappears, the app bar now shows "Tokyo", and the weather data updates to Tokyo's current temperature and hourly forecast. The numbers should be different from London's.

How does the app handle edge cases?

If the user types a nonsensical query like "xyzabc", the geocoding API returns an empty results array. Because _buildSearchResults only renders when _searchResults.isNotEmpty, the dropdown simply stays hidden.

If a network error occurs during the weather fetch, the _fetchWeather method catches the exception and shows a SnackBar with a "Retry" button at the bottom of the screen. The user can tap it to try again.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.dart and the emulator is showing weather for a searched city.

ⓧ I'd like to double check the full code

Your lib/main.dart file is now quite long. Scroll through and confirm you have these sections in order:

  • State variables: _weatherService, _weatherData, _isLoading, _cityName, _searchResults, _isSearching, _searchController.
  • The initState method calling _fetchWeather(51.51, -0.13).
  • The _fetchWeather method with try/catch and SnackBar error handling.
  • The _searchCity method with the 2-character minimum check.
  • The _selectCity method that updates _cityName and calls _fetchWeather.
  • The _getWeatherIcon method mapping weather codes to emoji.
  • The build method with the conditional AppBar title, search IconButton, and Stack body.
  • The _buildSearchResults method returning a Positioned ListView.
  • The _buildWeatherContent method (unchanged from the previous step).
  • The _buildMetricChip helper method.

Your lib/weather_service.dart file is unchanged from the previous step.

Your weather app now lets users search any city worldwide and see live weather data for it. Next up, you will tackle the Secret Mission and add a 7-day daily forecast below the hourly view.

Secret mission

Add a 7-Day Daily Forecast

Your hourly forecast only covers the next 24 hours. Can you extend the app to show a full 7-day outlook with high and low temperatures for each day? The Open-Meteo API already supports daily parameters. Your job is to request them, parse them, and build a new vertical list below the hourly row.

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 no cloud resources or paid services, so there are no ongoing costs.

Resources you used:

  • weather_app project folder on your local machine.
  • Android emulator virtual device in Android Studio.
  • Flutter SDK folder at C:\flutter.

Keep everything running

No action needed. Choose this if you plan to keep building with Flutter or want to revisit this app later.

  • Your weather_app folder stays on your machine and is ready to run any time.
  • The Open-Meteo API is free with no API key, so it keeps working indefinitely.
  • The Flutter SDK and Android emulator remain installed for future projects.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your project files and SDK remain intact for when you return.

  • Close the Android emulator by clicking the X button on the emulator window.
  • Stop the Flutter debug session in VS Code by pressing Shift+F5 or clicking the stop button in the debug toolbar.
  • Your weather_app folder and Flutter SDK stay on disk, ready to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees up disk space used by the project, SDK, and emulator image.

  • Delete the weather_app project folder. Open a terminal and run:
rmdir /s /q weather_app
  • Delete the Android virtual device. Open Android Studio, go to More Actions > Virtual Device Manager, then click the dropdown menu on your device and select Delete.
  • Optionally, delete the Flutter SDK folder by running:
rmdir /s /q C:\flutter
  • After deleting the SDK, remove the Flutter path entry from your system environment variables. Press the Windows key, search for Environment Variables, open it, find Path in the user variables, and remove the C:\flutter\bin entry.
  • Optionally, uninstall the Flutter and Dart extensions from VS Code by going to the Extensions panel and clicking Uninstall on each.

That's a Wrap!

That's a Wrap!

Nice work! You just built a complete weather app with Flutter from scratch on Windows. It fetches live data, displays a polished dark UI, and lets users search any city worldwide.

You've learned how to:

  • Build a mobile app with Flutter's widget system using Scaffold, Column, ListView, TextField, and StatefulWidget to manage changing data.
  • Make HTTP requests to the Open-Meteo API and parse JSON responses into typed Dart model classes using async/await.
  • Implement a city search feature powered by the Open-Meteo Geocoding API that converts names into coordinates and updates weather in real time.
  • Secret Mission: Added a 7-day daily forecast with high/low temperatures and day-by-day weather icons below the hourly view.

Ready to quiz yourself?