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:
- requesting the device's current location,
- using Google's Places API to search for places other than the device's current location,
- downloading and parsing JSON files,
- storing the weather forecast data in a database, and
- 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:
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:
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:
if (mPermissionGranted)
{
try
{
LocationRequest locationRequest = new LocationRequest();
locationRequest.setInterval(1000);
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:
public void onLocationResult( LocationResult locationResult )
{
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:
- they are part of the basic information that is displayed about the requested location, and
- 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:
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);
String strSearchText = prefs.getString("SearchText", "");
if (! addresses.get(0).getPostalCode().equals(strSearchText))
{
strSearchText = addresses.get(0).getPostalCode();
editor.putString("SearchText", strSearchText);
}
}
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:
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:
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);
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:
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:
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
:
prefs = PreferenceManager.getDefaultSharedPreferences(activity);
prefs.registerOnSharedPreferenceChangeListener(this);
@Override
public void onSharedPreferenceChanged( SharedPreferences sharedPreferences, String key )
{
if (key.equals("SearchText"))
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:
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)
{
StringBuilder builder = downloadLocationJSON(strSearchText);
if (builder.length() > 0)
bReturn = parseLocationInfo(builder, strSearchText);
}
else
{
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
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:
[
{
"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.
[
{
"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:
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);
String sCountry = obj.getJSONObject("Country").getString("ID");
if (sCountry.equals("US"))
{
nOffset = obj.getJSONObject("TimeZone").getInt("GmtOffset");
sTZName = obj.getJSONObject("TimeZone").getString("Code");
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:
SharedPreferences.Editor editor = mPrefs.edit();
editor.putString("LocationKey", sLocationKey);
editor.putInt("TimeZoneOffset", nOffset);
editor.putString("TimeZoneName", sTZName);
editor.commit();
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.
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
hour_forecast
locations
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:
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 URI
s 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.
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:
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:
public Uri insert( @NonNull Uri uri, @Nullable ContentValues values )
{
SQLiteDatabase database = dbHelper.getWritableDatabase();
Uri _uri = null;
switch(uriMatcher.match(uri))
{
case DAY_FORECAST:
{
long lRowId = database.insert
(ForecastDatabaseHelper.DAY_FORECAST_TABLE, null, values);
if (lRowId > -1)
{
_uri = ContentUris.withAppendedId(DAY_FORECAST_CONTENT_URI, lRowId);
}
break;
}
case HOUR_FORECAST:
{
long lRowId = database.insert(ForecastDatabaseHelper.HOUR_FORECAST_TABLE,
null, values);
if (lRowId > -1)
{
_uri = ContentUris.withAppendedId(HOUR_FORECAST_CONTENT_URI, lRowId);
}
break;
}
case LOCATIONS:
{
long lRowId = database.insert(ForecastDatabaseHelper.LOCATIONS_TABLE, null, values);
if (lRowId > -1)
{
_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:
public void onStart()
{
super.onStart();
getActivity().getSupportLoaderManager().initLoader(0, null, this);
...
}
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.
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
{
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.
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:
<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:
{
"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:
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.
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:
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:
When one of the forecast cards is clicked, another activity is started to display those details. This detail activity looks like:
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