Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Mobile / Android

Weather or Not

5.00/5 (4 votes)
12 Jun 2020CPOL19 min read 10.3K   180  
The World's Best Weather Forecasting App (That's the Not Part!)
In this article, we look at requesting the device's current location, using Google's Places API, downloading and parsing JSON files, using a database for forecast data storage, using a Loader to monitor and retrieve forecast data, tabs with rounded ends, and getting a location's time zone.

Introduction

What follows is my quest for a small, easy-to-read, no-frills, weather forecasting app. It also contains no ads! One item that was not on my list was to create YAWA (those of you with a Unix background will understand this). There are dozens of better ones, that do tons more, with radar images, already in the Play store.

Background

I started working on this project many years ago back before Weather Underground was acquired by IBM. I got the project nearly complete, but had to push the pause button to divert my attention elsewhere. I still needed to polish the UI just a bit, and do some more testing on various (emulated) devices. When I went to pick it up again last year, right off the bat I noticed it wasn't working. In short order, I found that the free Weather Underground API had gone away. Bummer.

Searching for a new API led me to AccuWeather. Aside from a slightly different URL format for requesting weather data, the usage of the API was very similar. The biggest difference (at least for my needs) was how many requests it took to get the weather data. With Weather Underground, the URL contained both the location (e.g., ZIP code) and the type of forecast desired. With AccuWeather, however, those two items are their own URL. The first request "converts" the location (e.g., ZIP code, city) to a location key. That key is then part of the second request to get the actual forecast. Not a major change, but a change nonetheless.

The first UI made for this project was a form of master/detail that displayed one way for smartphones and a different way for tablets. That, coupled with wanting an easy way to switch between a 5-day and a 12-hour forecast, made for some hard-to-understand, let alone maintainable, code. YMMV. I then switched to a 2-tab design, with one tab containing the 5-day forecast and the other tab containing the 12-hour forecast. They would operate independently of each other, with each item in the tab opening a detail activity when clicked. I liked this approach much better.

The big-ticket items that I'm going to talk about in this article are:

  1. requesting the device's current location,
  2. using Google's Places API to search for places other than the device's current location,
  3. downloading and parsing JSON files,
  4. storing the weather forecast data in a database, and
  5. using a loader to monitor changes to that data.

In the end, what I ended up with was an easy-to-use app with very little bulk (I'm still learning how to remove unnecessary items from the APK files that Android Studio produces), no ad dependency, and minimal permission requirements.

Using the Code

Requesting the Device's Current Location

To retrieve the weather forecast for your current location (see the My Location button in the toolbar), the first thing that must happen is to get runtime permission. We first check to see if permission has already been granted. If it has, we proceed to the location retrieval. If it hasn't, we request it, and then wait on the result. If the request was granted in that result, we can proceed to the location retrieval. This code looks like:

Java
mPermissionGranted = (ActivityCompat.checkSelfPermission
           (this, Manifest.permission.ACCESS_FINE_LOCATION) == PERMISSION_GRANTED);
if (mPermissionGranted)
    requestCurrentLocation();
else
    ActivityCompat.requestPermissions(this, new String[]
             { Manifest.permission.ACCESS_FINE_LOCATION }, 1);

If we did have to request permission, the result of such will be returned in onRequestPermissionsResult(), like:

Java
if (requestCode == 1)
{
    if (grantResults.length == 1)
    {
        mPermissionGranted = (grantResults[0] == PERMISSION_GRANTED);
        requestCurrentLocation();
    }
}
else
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);

Now that permission has been granted, we can submit a request to get the device's location. This is done by creating a LocationRequest object (setting its parameters to real fast, right now), creating a new instance of FusedLocationProviderClient, and calling its requestLocationUpdates() method, like:

Java
if (mPermissionGranted)
{
    try
    {
        LocationRequest locationRequest = new LocationRequest();
        locationRequest.setInterval(1000); // 1 second
        locationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY);

        mFusedLocationClient = LocationServices.getFusedLocationProviderClient(this);
        mLocationCallback = new MyLocationCallback();
        mFusedLocationClient.requestLocationUpdates
               (locationRequest, mLocationCallback, Looper.myLooper());
    }
    catch (Exception e)
    {
        Log.e(TAG, "  Error getting my location: " + e.getMessage());
    }
}

Note the callback (class) that is used to receive the location request. Passed to its onLocationResult() method is a LocationResult object that we can use to get the last known location. From that, we get a Location object that contains, among other things, the device's lat/long coordinates. The code for this looks like:

Java
public void onLocationResult( LocationResult locationResult )
{
    // kill future requests
    if (mFusedLocationClient != null)
    {
        mFusedLocationClient.removeLocationUpdates(mLocationCallback);
        mFusedLocationClient = null;
    }

    Location loc = locationResult.getLastLocation();
    if (loc != null)
    {
        SharedPreferences.Editor editor = prefs.edit();

        double lat = loc.getLatitude();
        editor.putFloat("Latitude", (float) lat);
        double lng = loc.getLongitude();
        editor.putFloat("Longitude", (float) lng);
        editor.commit();
    }
    else
        Log.d(TAG, "  Unable to get last location");
}

Once the location has been obtained, location updates can be turned off so as to not drain the device's battery any more than absolutely necessary. Clicking the My Location button will repeat this request/remove process.

At this point, the lat/long coordinates serve two purposes:

  1. they are part of the basic information that is displayed about the requested location, and
  2. they serve as input to the Geocoder API that we use to get the requested location's address information (i.e., reverse geocoding).

Detailed discussion of the Geocoder API is plentiful so I won't rehash that here. I'll only show how I used it to get these pieces of interest: city, state, and ZIP code. The ZIP code is also used as the search text for AccuWeather's API. The code for this looks like:

Java
Geocoder geoCoder = new Geocoder(MainActivity.this, Locale.getDefault());
List<Address> addresses = geoCoder.getFromLocation(lat, lng, 1);
if (addresses != null && ! addresses.isEmpty())
{
    String s = addresses.get(0).getLocality();
    editor.putString("City", s);
    s = addresses.get(0).getAdminArea();
    editor.putString("State", s);
    s = addresses.get(0).getPostalCode();
    editor.putString("ZIP", s);

    // only bother updating the preference (and retrieving forecasts) 
    // if the SearchText changed
    String strSearchText = prefs.getString("SearchText", "");
    if (! addresses.get(0).getPostalCode().equals(strSearchText))
    {
        strSearchText = addresses.get(0).getPostalCode();
        editor.putString("SearchText", strSearchText); // this is being monitored by 
                                                       // DayFragment and HourFragment
    }
}
else
    Log.d(TAG, "  getFromLocation() returned null/empty");

Using Google's Places API

Part way through this project, I wanted to add a search feature, and I was mulling over how I wanted to tackle that. I was hoping that a large database existed somewhere that contained all known locations, places, landmarks, etc. What would it look like? How would I interact with it? These were just some of the questions that popped up that I had no answer for. I then stumbled upon Google's Places API. Details on how to use the autocomplete service can be found here. I went with option 2, which uses an Intent to launch the autocomplete activity. The first thing to do is specify the fields we are interested in once the activity returns. Second is to create the intent using the IntentBuilder() method. Since we are interested in the return value of that intent (i.e., the selected place), the last thing to do is call startActivityForResult(). This looks like:

Java
List<Place.Field> fields = Arrays.asList(Place.Field.ADDRESS, 
                           Place.Field.ADDRESS_COMPONENTS, Place.Field.LAT_LNG);
Intent intentSearch = new Autocomplete.IntentBuilder
                      (AutocompleteActivityMode.FULLSCREEN, fields).build(this);
startActivityForResult(intentSearch, 1);

When the autocomplete activity returns, we get notified in the onActivityResult() method. If resultCode equals RESULT_OK and requestCode matches the code we passed to startActivityForResult(), we can extract the necessary information from the returned intent. The code to do this looks like:

Java
SharedPreferences.Editor editor = prefs.edit();

Place place = Autocomplete.getPlaceFromIntent(data);
LatLng latlng = place.getLatLng();
editor.putFloat("Latitude", (float) latlng.latitude);
editor.putFloat("Longitude", (float) latlng.longitude);

String sSearchText = place.getAddress();
editor.putString("SearchText", sSearchText); // this is being monitored by 
                                             // DayFragment and HourFragment
editor.commit();

While the above three pieces of information have so far been present for every place I've searched, other place-related information like city, state, and ZIP have not. For example, a search of Miami, Florida would produce an AddressComponents array like:

JavaScript
AddressComponents{asList=[AddressComponent{name=Miami, shortName=Miami, 
                          types=[locality, political]}, 
                          AddressComponent{name=Miami-Dade County, 
                          shortName=Miami-Dade County, types=[administrative_area_level_2, 
                          political]}, 
                          AddressComponent{name=Florida, shortName=FL, 
                          types=[administrative_area_level_1, political]}, 
                          AddressComponent{name=United States, shortName=US, 
                          types=[country, political]}]}

To extract the individual pieces, we employ something like:

Java
List<AddressComponent> list = place.getAddressComponents().asList();
for (int x = 0; x < list.size(); x++)
{
    AddressComponent ac = list.get(x);
    List<String> types = ac.getTypes();
    for (int y = 0; y < types.size(); y++)
    {
        String type = types.get(y);
        if (type.equals("postal_code"))
        {
            String sZIP = ac.getName();
            editor.putString("ZIP", sZIP);
            editor.commit();
        }
        else if (type.equals("locality"))
        {
            String sCity = ac.getName();
            editor.putString("City", sCity);
            editor.commit();
        }
        else if (type.equals("administrative_area_level_1"))
        {
            String sState = ac.getName();
            editor.putString("State", sState);
            editor.commit();
        }
    }
}

At this point, a location has either been searched for or the current location was requested. In either case, the SearchText preference was added or changed. This change is being monitored in two places: one in DayFragment, and the other in HourFragment. Both of these fragments register a callback that gets invoked when a change happens to a preference, like this in DayFragment:

Java
prefs = PreferenceManager.getDefaultSharedPreferences(activity);
prefs.registerOnSharedPreferenceChangeListener(this);

@Override
public void onSharedPreferenceChanged( SharedPreferences sharedPreferences, String key )
{
    if (key.equals("SearchText")) // if a different location was requested, retrieve it
        mDayForecast.retrieveForecasts();
}

Now when the SearchText preference is added or changed, each fragment can begin the forecast retrieval of the new location.

Downloading and parsing JSON files

The forecast retrieval process all starts in the retrieveForecasts() method of either DayForecastIO or HourForecastIO. Since they only differ by the URL used to retrieve the forecast and the fields extracted from the JSON file, for brevity, I'll show code snippets for the former.

So as to not tie up the UI thread unnecessarily, the retrieveForecasts() method wraps everything up in a secondary thread. This thread downloads the JSON file, and then processes it.

I mentioned earlier that we must have a location key for the location we want to retrieve the forecast for. This "conversion" also comes from the AccuWeather API, and is returned in a JSON file. Part way through this project, I noticed that each of my forecast requests was consuming four calls to the AccuWeather API (for both 5-day and 12-hour, one request to get the location key, and a second request to get that location's forecast). At that rate, my app could only handle about 12 forecast requests per day. Probably not a big deal for my daily routine, but what if I was searching other places, or hit the refresh "button" too many times?

The LocationKey class is a singleton class whose only instance is shared between both DayForecastIO and HourForecastIO. Since retrieveLocationKey() is synchronized, whichever of DayForecastIO or HourForecastIO call it first, the location key will get retrieved from either the database or from the internet, and the other call will block and then retrieve it from the database. This saves one trip to the internet.

Before downloading and parsing the location JSON file, however, we first look in the database to see if the location has already been searched for (more on the database in the next section). If it has, we just retrieve the location key from it. Otherwise, we make the request and process it. By storing searched locations and their key in the database, forecast requests now consume only three calls to the AccuWeather API, thus my app could now handle about 16 requests per day. More than enough! The code for this looks like:

Java
public synchronized boolean retrieveLocationKey( String strSearchText )
{
    boolean bReturn = false;
    String where = "'" + strSearchText + "' = " + ForecastProvider.KEY_LOCATION;

    ContentResolver cr = mActivity.getContentResolver();
    Cursor query = cr.query(ForecastProvider.LOCATIONS_CONTENT_URI, null, where, null, null);
    if (query != null)
    {
        if (query.getCount() == 0)
        {
            // the location was not found, so retrieve the location key from the internet
            StringBuilder builder = downloadLocationJSON(strSearchText);
            if (builder.length() > 0)
                bReturn = parseLocationInfo(builder, strSearchText);
        }
        else
        {
            // the location was found, so retrieve its associated location key
            query.moveToFirst();
            String sLocationKey = query.getString(query.getColumnIndex
                                  (ForecastProvider.KEY_LOCATION_KEY));

            SharedPreferences.Editor editor = mPrefs.edit();
            editor.putString("LocationKey", sLocationKey);
            editor.commit();

            bReturn = true;
        }

        query.close();
    }

    return bReturn;
}

If it is found that a location has not previously been searched for, we go to the next step of downloading the JSON file. With the search text, the URL is going to look something like:

http://dataservice.accuweather.com/locations/v1/search?apikey=[API_KEY]&q=Redmond%2CWA

Java
private StringBuilder downloadLocationJSON( String strSearchText )
{
    StringBuilder builder = new StringBuilder();

    try
    {
        URL url = new URL(Utility.getLocationUrl(mPrefs, strSearchText));
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        if (connection.getResponseCode() == HttpURLConnection.HTTP_OK)
        {
            String strLine;

            BufferedReader reader = new BufferedReader(new InputStreamReader
                                    (connection.getInputStream()));
            while ((strLine = reader.readLine()) != null)
                builder.append(strLine);

            reader.close();
            connection.disconnect();
        }
        else
        {
            if (connection.getResponseCode() == HttpURLConnection.HTTP_UNAVAILABLE)
                Utility.showSnackbarMessage(mActivity, 
                        mActivity.getResources().getString(R.string.too_many_requests));
            else if (connection.getResponseCode() == HttpURLConnection.HTTP_UNAUTHORIZED)
                Utility.showSnackbarMessage(mActivity, 
                        mActivity.getResources().getString(R.string.invalid_api_key));
        }
    }
    catch(UnknownHostException uhe)
    {
        Utility.showSnackbarMessage(mActivity, mActivity.getResources().getString
                                   (R.string.no_network_connection));
    }
    catch (Exception e)
    {
        Log.e(TAG, "  Error downloading Location JSON file: " + e.getMessage());
    }

    return builder;
}

For performance reasons inside the while() loop, the lines read from the JSON file are appended to a StringBuilder object rather than String concatenation. Since JSON files are meant for data transport and not UI presentations, the data stream will likely not contain \r or \n characters, thus the while() loop will probably execute only once.

The JSON file returned from the download code above will come in one of two very similar formats, all depending on WHAT was searched for. If the search text was a city, the JSON file looked like the following with the location key in the Key key:

JavaScript
[
  {
    "Version": 1,
    "Key": "341347",
    "Type": "City",
    "Rank": 55,
    "LocalizedName": "Redmond",
    "EnglishName": "Redmond",
    "PrimaryPostalCode": "98052",
    "Region": {
      "ID": "NAM",
      "LocalizedName": "North America",
      "EnglishName": "North America"
    },
    "Country": {
      "ID": "US",
      "LocalizedName": "United States",
      "EnglishName": "United States"
    },
    "AdministrativeArea": {
      "ID": "WA",
      "LocalizedName": "Washington",
      "EnglishName": "Washington",
      "Level": 1,
      "LocalizedType": "State",
      "EnglishType": "State",
      "CountryID": "US"
    },
  }
  ...
]

If the search text was a ZIP code, the JSON file looked like the following with the location key in the ParentCity/Key key. When I was testing this section of the app's code, I never saw them both present for the same search.

JavaScript
[
  {
    "Version": 1,
    "Key": "37935_PC",
    "Type": "PostalCode",
    "Rank": 55,
    "LocalizedName": "Beverly Hills",
    "EnglishName": "Beverly Hills",
    "PrimaryPostalCode": "90210",
    "Region": {
      "ID": "NAM",
      "LocalizedName": "North America",
      "EnglishName": "North America"
    },
    "Country": {
      "ID": "US",
      "LocalizedName": "United States",
      "EnglishName": "United States"
    },
    "AdministrativeArea": {
      "ID": "CA",
      "LocalizedName": "California",
      "EnglishName": "California",
      "Level": 1,
      "LocalizedType": "State",
      "EnglishType": "State",
      "CountryID": "US"
    },
    "ParentCity": {
      "Key": "332045",
      "LocalizedName": "Beverly Hills",
      "EnglishName": "Beverly Hills"
    },
  }
  ...
]

If your weather interests are in some country other than the United States, you'll obviously need to make allowances for this in the if() test. Testing whether a key exists is done using the isNull() method. The parsing code for the two scenarios above thus looks like:

Java
String sLocationKey = "";
int nOffset = 0;
String sTZName = "";

JSONArray array = new JSONArray(builder.toString());
for (int x = 0; x < array.length(); x++)
{
    JSONObject obj = array.getJSONObject(x);

    // in case our search can be found in multiple countries, limit it to the US
    String sCountry = obj.getJSONObject("Country").getString("ID");
    if (sCountry.equals("US"))
    {
        // get TZ from here instead of going through the TimeZoneDB API
        nOffset = obj.getJSONObject("TimeZone").getInt("GmtOffset");
        sTZName = obj.getJSONObject("TimeZone").getString("Code");

        // the location key can be found in one of two spots, 
        // depending on what was searched for
        if (! obj.isNull("ParentCity"))
        {
            sLocationKey = obj.getJSONObject("ParentCity").getString("Key");
            break;
        }
        else if (! obj.isNull("Key"))
        {
            sLocationKey = obj.getString("Key");
            break;
        }
    }
}

Once either location key is found, there's no need to muddle through the rest of the JSON file, so the for() loop exits. Now that we have a location key, it can be saved to the preferences and the database, like:

Java
SharedPreferences.Editor editor = mPrefs.edit();
editor.putString("LocationKey", sLocationKey);
editor.putInt("TimeZoneOffset", nOffset);
editor.putString("TimeZoneName", sTZName);
editor.commit();

// store this location/location key pair for retrieval later on, saving an internet trip
ContentValues values = new ContentValues();
values.put(ForecastProvider.KEY_LOCATION, sSearchText);
values.put(ForecastProvider.KEY_LOCATION_KEY, sLocationKey);

String where = "'" + sSearchText + "' = " + ForecastProvider.KEY_LOCATION;
ContentResolver cr = mActivity.getContentResolver();

Cursor query = cr.query(ForecastProvider.LOCATIONS_CONTENT_URI, null, where, null, null);
if (query != null)
{
    if (query.getCount() == 0)
        cr.insert(ForecastProvider.LOCATIONS_CONTENT_URI, values);
    else
        cr.update(ForecastProvider.LOCATIONS_CONTENT_URI, values, where, null);

    query.close();
}

The call to the insert() method is discussed below in the ForecastProvider class.

There are two other JSON files to download and parse: one for the 5-day forecast and the other for the 12-hour forecast. The code for those looks almost identical to what was shown above. The differences would be the URL used and the particular fields extracted.

From those two downloads, there are two fields in the HTTP response headers that are of interest: RateLimit-Limit and RateLimit-Remaining. With our free key, the former should be 50. The latter will start at that and decrease by 1 with each request, whether it's to get the location key or the forecast. Once 0 has been reached, instead of returning HTTP_OK, the HTTP response header will return HTTP_UNAVAILABLE which means too many requests have been made. I reached this point several times during the early stages of development, but rarely dropped below 35 once development settled down.

In the downloadForecastJSON() methods of DayForecastIO and HourForecastIO, between opening a connection to the URL and checking the response code, the following code snippet is used to get the two fields from the HTTP response headers. A Toast message is then popped up showing how many requests remain.

Java
String sLimit = connection.getHeaderField("RateLimit-Limit");
String sRemaining = connection.getHeaderField("RateLimit-Remaining");
SharedPreferences.Editor editor = mPrefs.edit();
editor.putInt("RequestLimit", Integer.parseInt(sLimit));
editor.putInt("RequestRemaining", Integer.parseInt(sRemaining));
editor.commit();

Using a Database for Forecast Data Storage

I've mentioned several times about a database being used for storing the forecast and location data. This is done using Android's implementation of SQLite. I've used this in several past projects so I'm more comfortable with it rather than using the suggested Room library.

There are two ways of going about using a database. One is to go through a ContentProvider to access your data, and the other is to use a database directly via SQLiteDatabase. The former is required if the data is going to be shared between applications. Since the forecast data is not going to be shared with any other application, a content provider is not needed, but I am going to use one anyway.

A chapter in the book Advanced Android Application Development (Developer's Library) provided a good explanation on how to use content providers to get access to your app's data (as well as other app's data). This basically involves extending a class from ContentProvider, defining the necessary URIs that the content provider uses to identity and interact with the data, defining column names, and overriding several methods. We'll also need a private class that extends SQLiteOpenHelper. This class will take care of creating, opening, and upgrading the underlying database as needed. If we were not using a content provider, this last part is all we'd need.

The forecast database has three tables. They are:

day_forecast

Image 1

hour_forecast

Image 2

locations

Image 3

The column names are self explanatory. There's one for each piece of forecast information we are interested in displaying. While the day_forecast and hour_forecast tables are very similar, I kept them separate for simplicity. There's probably a normalization rule being violated, but I was after simple rather than total correctness (especially for a personal app).

The ForecastDatabaseHelper class contains String objects of these table names, the SQL statements used to create them, and two overridden methods that get called by the content provider:

Java
private static final String DATABASE_NAME       = "forecasts.db";
private static final int DATABASE_VERSION       = 1;
private static final String DAY_FORECAST_TABLE  = "day_forecast";
private static final String HOUR_FORECAST_TABLE = "hour_forecast";
private static final String LOCATIONS_TABLE     = "locations";

private static final String DAY_FORECAST_TABLE_CREATE =
    "CREATE TABLE " + DAY_FORECAST_TABLE + " ("
    + KEY_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
    + KEY_FORECAST_ID + " INTEGER, "
    + KEY_FORECAST_DATE + " INTEGER, "
    + KEY_TEMPERATURE + " TEXT, "
    + KEY_FEELS_LIKE + " TEXT, "
    + KEY_FORECAST + " TEXT, "
    + KEY_ICON + " INTEGER, "
    + KEY_RAIN + " TEXT, "
    + KEY_RAIN_PROB + " INTEGER, "
    + KEY_SNOW + " TEXT, "
    + KEY_SNOW_PROB + " INTEGER, "
    + KEY_WIND_SPEED + " TEXT, "
    + KEY_WIND_DIR + " INTEGER, "
    + KEY_RISE + " INTEGER, "
    + KEY_SET + " INTEGER);";
...

//========================================================

public void onCreate( SQLiteDatabase db )
{
    try
    {
        db.execSQL(DAY_FORECAST_TABLE_CREATE);
        db.execSQL(HOUR_FORECAST_TABLE_CREATE);
        db.execSQL(LOCATIONS_TABLE_CREATE);
    }
    catch(SQLiteException e)
    {
        Log.e(TAG, "Error creating tables: " + e.getMessage());
    }
}

//========================================================

public void onUpgrade( SQLiteDatabase db, int oldVersion, int newVersion )
{
    Log.d(TAG, "Upgrading database from version " + oldVersion + " to version " + newVersion);

    try
    {
        db.execSQL("DROP TABLE IF EXISTS " + DAY_FORECAST_TABLE);
        db.execSQL("DROP TABLE IF EXISTS " + HOUR_FORECAST_TABLE);
        db.execSQL("DROP TABLE IF EXISTS " + LOCATIONS_TABLE);
    }
    catch(SQLiteException e)
    {
        Log.e(TAG, "Error dropping tables: " + e.getMessage());
    }

    onCreate(db);
}

The ForecastProvider class is a bit more busy than the above database helper class. It starts by defining the three URIs that are used for table recognition. They have public scope because they are used internally as well as elsewhere in the app. You'll need/want to change the package reference in the URIs.

Java
public static final Uri DAY_FORECAST_CONTENT_URI  = 
       Uri.parse("content://com.dcrow.Forecast.provider/day_forecast");
public static final Uri HOUR_FORECAST_CONTENT_URI = 
       Uri.parse("content://com.dcrow.Forecast.provider/hour_forecast");
public static final Uri LOCATIONS_CONTENT_URI     = 
       Uri.parse("content://com.dcrow.Forecast.provider/locations");

Next is a ForecastDatabaseHelper object that the content provider uses to communicate with the underlying database. It gets created in the onCreate() method, like:

Java
private ForecastDatabaseHelper dbHelper;
...
public boolean onCreate()
{
    try
    {
        dbHelper = new ForecastDatabaseHelper(getContext(),
                                              ForecastDatabaseHelper.DATABASE_NAME,
                                              null,
                                              ForecastDatabaseHelper.DATABASE_VERSION);
    }
    catch(Exception e)
    {
        Log.e(TAG, "Error creating provider: " + e.getMessage());
    }

    return true;
}

The dbHelper object is used in the query(), insert(), delete(), and update() methods to get either a readable or writeable database object. For example, when a new row needs to be inserted like above in the parseLocationInfo() method, the insert() method is called, passing to it the URI of the table and the key/value pairs:

Java
public Uri insert( @NonNull Uri uri, @Nullable ContentValues values )
{
    SQLiteDatabase database = dbHelper.getWritableDatabase();
    Uri _uri = null;

    switch(uriMatcher.match(uri))
    {
        case DAY_FORECAST:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert
                          (ForecastDatabaseHelper.DAY_FORECAST_TABLE, null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(DAY_FORECAST_CONTENT_URI, lRowId);
            }

            break;
        }

        case HOUR_FORECAST:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert(ForecastDatabaseHelper.HOUR_FORECAST_TABLE, 
                                          null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(HOUR_FORECAST_CONTENT_URI, lRowId);
            }

            break;
        }

        case LOCATIONS:
        {
            // database.insert() will return the row number if successful
            long lRowId = database.insert(ForecastDatabaseHelper.LOCATIONS_TABLE, null, values);
            if (lRowId > -1)
            {
                // on success, return a URI to the newly inserted row
                _uri = ContentUris.withAppendedId(LOCATIONS_CONTENT_URI, lRowId);
            }

            break;
        }

        default:
            throw new SQLException("Failed to insert forecast_row into " + uri);
    }

    getContext().getContentResolver().notifyChange(_uri, null);
    return _uri;
}

The switch() statement resolves the URI parameter to an actual table name so that the correct insert() method is called. Lastly, the content resolver's notifyChange() method is called to let any listener/observer know that a change has been made. The other three methods are much the same, in that they call their respective method in the database helper class.

Using a Loader to Monitor and Retrieve Forecast Data

I mentioned earlier that I started this project using a master/detail model. When you do that, Android Studio will create ViewModel and LiveData classes to deal with loading your data. I found using a Loader was a bit more intuitive so that's what I stuck with. I know it has since been deprecated, so I may use the newer approach for future projects that involve data storage.

In the two UI fragments, DayFragment and HourFragment, a Loader gets created with getActivity().getSupportLoaderManager().initLoader(...). This in turn calls that fragment's onCreateLoader() method which creates a CursorLoader object (this feels very much like a SQL SELECT statement). The creation of this object will internally call the ForecastProvider.query() method. Like insert() above, this method will resolve the URI parameter to an actual table name, and then build and execute the query. It ends by calling setNotificationUri() to register a listener/observer for changes to the URI that was passed as a parameter. So when any changes are made to one of our three URIs (e.g., insert, delete, update), any registered listener gets notified by the call to notifyChange(). This is when either of the onLoadFinished() methods get called to populate its adapter with the new data, and let the adapter know its data set has changed. Phew!

This is how DayFragment creates and uses a loader:

Java
public void onStart()
{
    super.onStart();

    getActivity().getSupportLoaderManager().initLoader(0, null, this); // 5-day
    ...
}

In the creation of the "query" below, only the fields that are needed are selected. The KEY_ID field is required. The KEY_FORECAST_ID field is passed on to DetailActivity when a forecast card is clicked. This ID is then used to query the rest of the fields. The rest of the fields (e.g., rain, snow, wind, humidity) are used for display purposes in the detail activity.

Java
public Loader<cursor> onCreateLoader( int id, @Nullable Bundle args )
{
    CursorLoader loader = null;
    Uri uri = ForecastProvider.DAY_FORECAST_CONTENT_URI;
    String[] strProjection = new String[]{ForecastProvider.KEY_ID,
                                          ForecastProvider.KEY_FORECAST_ID,
                                          ForecastProvider.KEY_FORECAST_DATE,
                                          ForecastProvider.KEY_TEMPERATURE,
                                          ForecastProvider.KEY_FORECAST,
                                          ForecastProvider.KEY_ICON};

    try
    {
        // query the ForecastProvider for all of its elements
        loader = new CursorLoader(getActivity(), uri, strProjection, "", null, null);
    }
    catch (Exception e)
    {
        Log.e(TAG, "  Error creating loader: " + e.getMessage());
    }

    return loader;
}

After the cursor has been created and queried, we can now load the table data into the adapter that is tied to the RecyclerView. For each field that is referenced below, that same field must have been part of the projection above. The array used by the adapter is cleared and reloaded with the new data. Once all of the rows are added to the array, we let the adapter know its data set has changed so the RecyclerView can be refreshed.

Java
public void onLoadFinished( @NonNull Loader<cursor> loader, Cursor data )
{
    try
    {
        arrForecasts.clear();

        if (data.moveToFirst())
        {
            do
            {
                DayForecastDetails dayDetails = new DayForecastDetails();
                dayDetails.m_nForecastId      = 
                   data.getInt(data.getColumnIndex(ForecastProvider.KEY_FORECAST_ID));
                dayDetails.m_lForecastDate    = 
                   data.getLong(data.getColumnIndex(ForecastProvider.KEY_FORECAST_DATE));
                dayDetails.m_strTemp          = 
                   data.getString(data.getColumnIndex(ForecastProvider.KEY_TEMPERATURE));
                dayDetails.m_strForecast      = 
                   data.getString(data.getColumnIndex(ForecastProvider.KEY_FORECAST));
                dayDetails.m_nIcon            = 
                   data.getInt(data.getColumnIndex(ForecastProvider.KEY_ICON));

                arrForecasts.add(dayDetails);
            } while (data.moveToNext());
        }

        m_callback.OnUpdateTitle();

        adapter.notifyDataSetChanged();
    }
    catch(Exception e)
    {
        Log.e(TAG, "  Error reading from 'forecast' cursor: " + e.getMessage());
    }
}

The onCreateLoader() and onLoadFinished() methods of HourFragment are identical in functionality, but different fields are selected.

Points of Interest

Tabs With Rounded Ends

Toward the end of this project, I wanted to do something that would make the two tabs "less boring." My initial thought was for the tabs to have rounded ends. In trying to locate some ideas to that end, I found several sites that showed different ways of achieving this, but they all seemed to require more code than I wanted to mess with. I finally found one site that simply talked about wrapping the TabLayout in a material widget called a MaterialCardView. This just produced rounded ends on the outside of each tab, rather than rounded ends on both sides of the tab. It worked for what I was after, though. In the main_activity.xml file, that ended up looking like:

XML
<com.google.android.material.card.MaterialCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="15dp" >

    <com.google.android.material.tabs.TabLayout
        android:id="@+id/tabs"
        android:layout_width="match_parent"
        android:layout_height="40dp"
        app:tabMaxWidth="0dp"
        app:tabGravity="fill"
        app:tabMode="fixed"
        app:tabTextColor="@android:color/white"
        app:tabBackground="@drawable/tab_background_color"
        android:background="?attr/colorPrimary" />

</com.google.android.material.card.MaterialCardView>

Setting tabMaxWidth="0" was to handle the case where the two tabs looked fine in portrait mode but were not filling the width of the screen in landscape mode.

Getting a Location's Time Zone

While having nothing to do with the weather forecast, I wanted to display some basic information about the requested location. Along with city, state, ZIP, and lat/long coordinates, I also wanted the location's time zone. This free information is retrieved from TimeZoneDB. Since the information is in JSON format, the downloading and parsing of the JSON file is the same as it was for the location and forecast files retrieved from AccuWeather.

The entire retrieval process is done in a secondary thread so as not to interrupt either the UI thread or either of the other two secondary threads that may be busy retrieving forecast data.

The time zone URL contains the API key and the lat/long coordinates of the requested location. Since this retrieval comes after a location has either been searched for or the device's location has been retrieved, we know the lat/long coordinates. So the URL becomes something like:

https://api.timezonedb.com/v2.1/get-time-zone?key=[API_KEY]&format=json&by=position&lat=35.6207816&lng=-108.8059262

What gets returned is the most basic of JSON files:

JavaScript
{
    "status":"OK",
    "message":"",
    "countryCode":"US",
    "countryName":"United States",
    "zoneName":"America\/Denver",
    "abbreviation":"MDT",
    "gmtOffset":-21600,
    "dst":"1",
    "zoneStart":1583658000,
    "zoneEnd":1604217600,
    "nextAbbreviation":"MST",
    "timestamp":1584613637,
    "formatted":"2020-03-19 10:27:17"
}

Of these values, the two of interest are abbreviation and gmtOffset. The code to retrieve these values and store them in the preferences file looks like:

Java
try
{
    JSONObject object = new JSONObject(builder.toString());
    int mOffset = object.getInt("gmtOffset") / 3600;
    String mTZName = object.getString("abbreviation");

    SharedPreferences.Editor editor = mPrefs.edit();
    editor.putInt("TimeZoneOffset", mOffset);
    editor.putString("TimeZoneName", mTZName);
    editor.commit();
}
catch(Exception e)
{
    Log.e(TAG, "  Error parsing TimeZone JSON: " + e.getMessage());
}

The time zone offset and name are used later when displaying information about the requested location.

One of the neat things about writing articles like this is it causes you to look at your code differently than when you were actually writing the code itself. I serendipitously found that the time zone information was also offered by the AccuWeather API, specifically the Location service. While I made the small change to the code in the "Downloading and parsing JSON files" section above, I went ahead and included the time zone parsing code here just for comparative purposes. I also left the TimeZone class in the project.

The Finished Product

With a project of this size, there are undoubtedly a lot more code snippets that could be shown and discussed. I'm just going to leave that exercise to the interested reader.

When the app first starts, no location has been selected yet, so an empty view is presented. RecyclerView does not have a setEmptyView() method, so the ability to show an empty view when either of the adapters are empty is handled by EmptyRecyclerView. In this RecyclerView extension is an AdapterDataObserver object which watches for changes to the view's underlying adapter.

Image 4

Selecting a location to retrieve the forecast for is done via either the Search or My Location button in the toolbar. For the latter, you will be prompted to permit the app to access the device's location. Once permission has been granted, the retrieval process starts (with everything discussed above). Now the two tabs of the main activity look like:

Image 5 Image 6

The 5 Day tab contains forecast data for the next 5 days (today plus 4 more days). Each day contains two parts: day and night. The 12 Hour tab contains forecast data for the next 12 hours. The data in either tab can be refreshed via a vertical swipe gesture.

I mentioned a few times in this article about displaying basic information about the location. When the title is clicked, a dialog containing such information is displayed, like:

Image 7

When one of the forecast cards is clicked, another activity is started to display those details. This detail activity looks like:

Image 8

For the Humidity, Rain, and Snow sections, if any of those elements are not in the forecast, those sections are hidden. The details of the compass rose, which is used to indicate the wind direction, are contained in the Compass class.

Epilogue

Normally, I would have included the APK file in the download link at the top of this article. However, since there are API keys in use here, you will need to register to use the AccuWeather, and Google Places APIs. This is a one time thing so once you have the API keys, simply put them in the appropriate place in the Utility class, recompile, and you'll be good to go. The Google Places API does not have restrictions on how often it can be used, but the free AccuWeather API does: 50 requests per day. That's why I did not just leave my keys intact!

Enjoy!

History

  • 12th June, 2020: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)