Bryn Flow

🐦 Build a Weather App with http and FutureBuilder

Fetch live data from a REST API with the http package, decode JSON, and let FutureBuilder drive loading/error/success UI states automatically.

Intermediate ⏱️ ~35 minutes 🧰 Dart, Flutter, http, FutureBuilder
← Flutter Track

What You'll Build

A screen with a city text field and a search button. Submitting triggers a new Future that FutureBuilder turns into a spinner, an error message, or the weather details — with no manual state juggling.

Prerequisites

Step by Step

1 Model the API response

// lib/weather.dart
class Weather {
  final String city;
  final double temp;
  final double feelsLike;
  final int humidity;
  final String description;

  Weather({
    required this.city,
    required this.temp,
    required this.feelsLike,
    required this.humidity,
    required this.description,
  });

  factory Weather.fromJson(Map<String, dynamic> json) {
    return Weather(
      city: json['name'],
      temp: (json['main']['temp'] as num).toDouble(),
      feelsLike: (json['main']['feels_like'] as num).toDouble(),
      humidity: json['main']['humidity'],
      description: json['weather'][0]['description'],
    );
  }
}

2 Write the networking function

// lib/weather_service.dart
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'weather.dart';

const _apiKey = 'YOUR_API_KEY';

Future<Weather> fetchWeather(String city) async {
  final uri = Uri.parse(
    'https://api.openweathermap.org/data/2.5/weather'
    '?q=$city&appid=$_apiKey&units=metric',
  );

  final response = await http.get(uri);

  if (response.statusCode != 200) {
    throw Exception('Could not load weather for "$city"');
  }

  return Weather.fromJson(jsonDecode(response.body));
}

Throwing an Exception on a non-200 response lets FutureBuilder pick it up automatically through snapshot.hasError — no separate error-state variable needed.

3 Build the screen

The key idea: the Future lives in a field, created only when the user searches — never called directly inside build(), which would restart the request on every rebuild.

// lib/main.dart
import 'package:flutter/material.dart';
import 'weather_service.dart';
import 'weather.dart';

void main() => runApp(const WeatherApp());

class WeatherApp extends StatelessWidget {
  const WeatherApp({super.key});
  @override
  Widget build(BuildContext context) =>
      MaterialApp(home: const WeatherScreen(), debugShowCheckedModeBanner: false);
}

class WeatherScreen extends StatefulWidget {
  const WeatherScreen({super.key});
  @override
  State<WeatherScreen> createState() => _WeatherScreenState();
}

class _WeatherScreenState extends State<WeatherScreen> {
  final _controller = TextEditingController();
  Future<Weather>? _weatherFuture;

  void _search() {
    final city = _controller.text.trim();
    if (city.isEmpty) return;
    setState(() {
      _weatherFuture = fetchWeather(city);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Weather')),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          children: [
            Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(hintText: 'City name'),
                    onSubmitted: (_) => _search(),
                  ),
                ),
                ElevatedButton(onPressed: _search, child: const Text('Search')),
              ],
            ),
            const SizedBox(height: 24),
            Expanded(
              child: _weatherFuture == null
                  ? const Text('Enter a city to see the weather.')
                  : FutureBuilder<Weather>(
                      future: _weatherFuture,
                      builder: (context, snapshot) {
                        if (snapshot.connectionState == ConnectionState.waiting) {
                          return const Center(child: CircularProgressIndicator());
                        }
                        if (snapshot.hasError) {
                          return Center(child: Text('⚠️ ${snapshot.error}'));
                        }
                        final w = snapshot.data!;
                        return Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(w.city, style: Theme.of(context).textTheme.headlineMedium),
                            Text('${w.temp.toStringAsFixed(1)}°C — feels like ${w.feelsLike.toStringAsFixed(1)}°C'),
                            Text(w.description),
                            Text('Humidity: ${w.humidity}%'),
                          ],
                        );
                      },
                    ),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}

4 Run it

Run the app, type a city, tap Search. You should see a spinner briefly, then either the weather details or an error — try an invalid city to confirm the error branch renders correctly.

Final Working Code

Three files — lib/weather.dart, lib/weather_service.dart, lib/main.dart — exactly as shown above form the complete, working app.

What to Try Next

Want more Flutter walkthroughs?

Head back to the Flutter track for the full topic list, roadmap, and practice quiz.

Back to Flutter Track