Build a Real Weather App in Flutter

Wednesday 05 May 2021 ~12 min read


Let's build a real weather application using Flutter, in less than 300 lines of code!

First, let me give you an overview of what we are building.

What are we building?

I want my users to effortelessly know about the weather in their current location, and to teach you, my dear reader, how to build real applications using a really simple real use-case, it's probably an idea that have been used many times, but it's also a good example of a complete app with less than 300 lines of code.

The app consists of 1 page only, and 2 layers communicating between each others:

  1. UI layer
  2. API layer

The API layer will be talking to OpenWeather API to get the weather using my current location, then it will handle these information to the UI layer, where widgets will be drawn on the screen, and my users informed of the weather status.

But things don't always go smooth, what if there is no connection? what if OpenWeather is unable to deliver any data? that's where we will learn about error handling in Flutter applications.

If you want to jump to the final result without going through this tutorial, here is the full source code on GitHub.

The following screenshots shows the final result in all the different cases.

Getting Started

First thing you need to do is have Flutter correctly installed on your machine with your favorite IDE, if not, please follow the installation guide for your OS.

Next, create a new Flutter project, then create 2 new files under lib folder, main.dart will already be created for you:

// Structure of the app

|_ lib
|____ key.dart
|____ api.dart
|____ main.dart

In the file key.dart, we will need to put the API key from OpenWeather in order to communicate with it. Head to OpenWeather and create an account, then go to this page to get your API key.

Inside key.dart, paste the following code including your key:

const String API_KEY = "PASTE_KEY_HERE";

All ready, time to build.

Let's build a weather app

Dependencies

We will use 2 packages in this project, to add them, go to pubspec.yaml, and paste the following, be careful with spaces:

dependencies:
  flutter:
    sdk: flutter
    
  http: ^0.12.2
  geolocator: ^5.3.2+2
  1. http: using this package, we will make http requests to the API, and get a response in a form of JSON.
  2. geolocator: to get the current location of the user.

UI Layer

Clean all contents inside main.dart, and let's start from scratch. We will create our application root widget, and name it WeatherApp, it's going to be a StatelessWidget, that's because your app's state isn't going to change through out its lifetime, what changes is the states of the widgets down the tree.

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.teal,
      ),
      home: Home(),
    );
  }
}

The app will launch the first page to be Home() as declared in the home propperty, so let's create the home page and only page in the app.

class Home extends StatefulWidget {
  Home({Key key}) : super(key: key);

  
  _HomeState createState() => _HomeState();
}

class _HomeState extends State<Home> {

  
  void initState() {
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0.0,
        title: Text("WEATHER TODAY"),
      ),
    );
  }
}

Why is it a StatefulWidget? Things will change, I would need to add a way to refresh the weather, so the widget should help me by rebuilding itself when new data comes.

At this point, all we have is a boring app with a fabulous AppBar, but now things will get a bit exciting.

I will keep main.dart as a UI layer, meaning that I won't write any code inside any of the widgets to communicate with OpenWeather, so we will be starting with dummy and hardcoded data, as placeholders to build the UI.

Before we move to widgets, we need to think about the different states of our app, and translate that into clear constants each representing a different state. To achive this idea, we can use dart enum, which is a really convenient way to visualize different states or shapes of the same general type, instead of using normal constants.

This will look like this:

enum Status {PENDING, ACTIVE, ERROR}

You can see clearly, that we are expecting the UI to be in 3 states.

If you would like to know more about enums, click here to find the official docs.

💡 I encourage you to also read about extenssion methods as they usually are used to add more useful functionality to enums.

The first part of the UI is the green background, which is a Container widget. Then we have a box at the top with an explanatory message about where the weather data is coming from. To place it at the top, I will use a Stack, and have the Box above the Container.

Stack(
  children: [
      Container(
        color: Theme.of(context).primaryColor.withOpacity(0.15),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (status == Status.PENDING)
              Center(
                child: CircularProgressIndicator.adaptive(),
              ),
            if (status == Status.ACTIVE)
           Column(
              children: [
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Icon(
                      Icons.location_pin,
                      color: Theme.of(context).primaryColor,
                    ),
                    Text(
                      "City, country",
                      style: TextStyle(
                        fontSize: 20,
                        fontWeight: FontWeight.bold,
                        color: Theme.of(context).primaryColor,
                      ),
                    ),
                  ],
                ),
                SizedBox(height: 20),
                Text(
                  "38 degree",
                  style: TextStyle(
                    fontSize: 80,
                    fontWeight: FontWeight.bold,
                  ),
                ),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    Text(
                      "Clear: clear sky",
                      style: TextStyle(fontSize: 20),
                    ),
                  ],
                )
              ],
            )
          ],
        ),
      ),
    Column(
      children: [
        HighlightedMsg(
          msg: "The information is using OpenWeather Public API, "
              "and is displaying the weather for your current location. "
              "Tempreture is in celcius.",
        ),
        if (status == Status.ERROR)
          HighlightedMsg(
            msg: "Error",
            color: Colors.red[100],
          ),
      ],
    ),
  ],
),

You can see that we are using if condition inside the Column, and Stack. We can use collection if and for inside any list, even if it's a List of widgets. I ecnourage you to read more about collection if, for here. Flutter doesn't have a widget called HighlightedMsg, it's a widget that we are going to compose now, in order to make it reusable.

class HighlightedMsg extends StatelessWidget {
  const HighlightedMsg({
    Key key,
     this.msg,
    this.color,
  }) : super(key: key);
  final String msg;
  final Color color;
  
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.topCenter,
      child: Container(
        width: MediaQuery.of(context).size.width,
        decoration: BoxDecoration(color: color ?? Theme.of(context).highlightColor),
        padding: EdgeInsets.all(20),
        child: Text(
          msg,
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}

API Layer

Let's move to api.dart, it's where the API layer code will live, and data to be manipulated into models that are ready to be consumed by the UI layer.

Let's start with API class, which will hold all properties and methods to communicate with the outside world 🪐.

class API {
  const API._();
  static const API instance = API._();

  final String host = "api.openweathermap.org";

  ///The final URL would look like this: 
  ///[https://<host>/data/2.5/weather?lat=<lat>&lon=<lon>&units=metric&appid=<APPI_KEY>]
  Uri uri({String lat, String lon}) => Uri();

  Future<Weather> getCurrentWeather() async {}
}

Let's explain what's the different parts of this class by order.

The constructor

I like to call this kind of classes Service Classes, since they serve the UI by many ways including the communication with other third-party APIs and SDKs, and factoring the data into useful shapes (Objects) that are easier to read and maintain.

The app usually needs only one instance of any service class, so it should be a Singelton, meaning that only a single API object will live through the lifecycle of this app.

To make a Singleton in Dart, we declar the constructor of a class to be a private factory using the _ underscore annotation. Then to get this instance anywhere, we need a static getter, since we can't instantiate an instance with the constructor being private, right? instance is a getter that have access to our Singelton, thus we use it to access all other public methods and properties of this class.

Host and URI

Now we start with things specific to the API we want to connect to, first step is to declare the host "api.openweathermap.org", and use it to construct a URI object.

What is URI?

It's a type in Dart that helps us in constructing the different parts of any URI (Uniform Resource Identifier) including the host, the arguments, the path, and all other parts that form a URI.

But how exactly is the shape of URI that OpenWeather have for its different endpoints?

The one we are interested in is this:

https://<host>/data/2.5/weather?lat=<lat>&lon=<lon>&units=metric&appid=<APPI_KEY>

Taken from OpenWeather docs, we want to connect to the current weather endpoint, and get the weather by user's location.

Uri uri({String lat, String lon}) => Uri(
    scheme: "https",
    host: host,
    pathSegments: {"data", "2.5", "weather"},
    queryParameters: {
      "lat": lat,
      "lon": lon,
      "units": "metric",
      "appid": API_KEY
    },
);

The uri method will simply take the longitue and latitue and give us back a URI with the correct parameteres.

getCurrentWeather Method

Inside this method, we will finally use the http package to make a request! First, import http and geolocator.

import 'package:geolocator/geolocator.dart';
import "package:http/http.dart" as http;

Next, we need to get the current location of the user using geolocator.

Position position = await Geolocator().getCurrentPosition(desiredAccuracy: LocationAccuracy.high);

Then, we will use uri(), pass it the location from the pervious code, and get a URI.

final requestUri = uri(
  lon: position.longitude.toString(),
  lat: position.latitude.toString(),
);

Next, write the request, await for the result, and then store it in a variable of type Response. The Response type has a getter body, which will give us the body of the response containing the data we want, any data that comes from an http request should be decoded using json.decode().

///After we have a uri, we can now send our request using
///http package: https://pub.dev/packages/http, and the result
///will be stored in a variable [response].
final response = await http.get(requestUri);

///The response, if recieaved correctly. will be in the form of [json]
///so in order to read the data inside of it, we first need to decode it,
///by using the [json.decode()] method from the built-in [dart:convert] package,
final decodedJson = json.decode(response.body);

Finally, we will return what we got to whoever is calling the method, but we are not done yet. There are 2 important things we still have to do in order to give back a clean response to the UI.

📒 Weather Data Model

Data models are a crucial part of any modern app. The idea is to map the raw data from the previous step, into a meaningful object.

Let's first create the Model of this object.

class Weather {
  final int temp;
  final String city;
  final String country;
  final String main;
  final String desc;
  final String icon;

  String getIcon() => Uri(
        scheme: "https",
        host: "openweathermap.org",
        pathSegments: {"img", "wn", "$icon@2x.png"},
      ).toString();

  Weather.fromMap(Map<String, dynamic> json)
      : temp = json['main']['temp'].toInt(),
        city = json['name'],
        country = json['sys']['country'],
        main = json['weather'][0]['main'],
        desc = json['weather'][0]['description'],
        icon = json['weather'][0]['icon'];
}

Each response we will get will be turned into a Weather object, ussing fromMap() method, that will read the keys from the decoded json response, and assign the values to each peroperty in the model.

Back to getCurrentWeather() method, we will append this line.

return Weather.fromMap(decodedJson);

Putting it all together, now we know where did the return type of this method came from, it's a User Define Type.

⚠️ Catching Errors

Not all responses are going to be successful, we might get a response that is not what we expect, for that we need to guard well against that by wrapping all of prvious method inside a try/catch block.

try {
  Position position = await Geolocator()
      .getCurrentPosition(desiredAccuracy: LocationAccuracy.high);

  final requestUri = uri(
    lon: position.longitude.toString(),
    lat: position.latitude.toString(),
  );

  final Response response = await http.get(requestUri);

  final decodedJson = json.decode(response.body);

  return Weather.fromMap(decodedJson);
} on SocketException catch (e) {
  throw "You don't have connection, try again later.";
} on PlatformException catch (e) {
  throw "${e.message}, please allow the app to access your current location from the settings.";
} catch (e) {
  throw "Unknown error, try again.";
}

This way, we ensure that we will catch any errors thrown by http or geolocator, and show the proper feedback for the user. Some of the usual exception types we might get are SocketException when there is no connection, and PlatformException when Geolocator fails to specify the location of the user, we can throw meaningful messages describing what's happening.

Putting it all together

It's the time to use API and put the real data inside widgets. In the initState method of Home widget, we will call getCurrentWeather and use setState((){}) to ask the page to rebuild with the data, or error.

First, prepare variables.

  Weather currentWeather;
  Status status;
  String error = "";

Second, create an async method to update our variabless with the response Weather or error.

Future<void> getWeather() async {
  String _error = "";

  try {
    final _currentWeather = await API.instance.getCurrentWeather();

    setState(() {
      currentWeather = _currentWeather;
      status = Status.ACTIVE;
    });
  } catch (e) {
    _error = e;
    setState(() {
      error = _error;
      status = Status.ERROR;
    });
    return;
  }
}

Lastly, initiate the widget with PENDING state, and call getWeather.

  
  void initState() {
    status = Status.PENDING;
    getWeather();
    super.initState();
  }

It's worth noting here that initState will only be called once, this means if the weather changes there is no way of letting the user know. A solution is to provide a way to refresh and call getWeather again!

Refresh Indicator

Back inside build, we will wrap the body with RefreshIndicator widget, which takes onRefresh method, that's where we will allow for calling getWeather, and refresh the weather data.

body: RefreshIndicator(
  onRefresh: getWeather,
  child: Stack(
    // Rest of widgets...
  ),
),

Conclusion

Once again, you can find the full source code here. Don't hesitate to reach me out if any step of this tutorial doesn't seem so clear, I'll be happy to help!