Edited on January 16, 2017: I received reports that the app was not working with the most recent versions of Ionic 2. I've built an updated version that seems to work well, but I did not test it heavily. You can find that version here: https://github.com/cfjedimaster/Cordova-Examples/tree/master/ionicweatherv3
Before I begin, a quick reminder that I am still way new to Angular 2 and Ionic 2. I'm trying to learn, but you should treat what follows as the ramblings of a semi-intelligent monkey doing his best to learn something new. So with that disclaimer out of the way, I'd love to share an application I built this week - Ionic Weather.
Ionic Weather is heavily influenced by the excellent Yahoo Weather mobile app (available on both iOS and Android, the previous link is just for the iOS version). I was motivated to build this app by the NativeScript Weather App challenge, and while obviously this isn't NativeScript, I figured I'd try it first in Ionic 2 and then see if I could build something similar in NativeScript.
Ionic Weather has a simple interface. When you start up, you are prompted to select your first city.
Clicking Add Location (or the + in the UI) opens up a prompt:
Enter a location and then it will be added to your list of cities. I then render a picture based on the weather and report on it in text.
The text here was arbitrary. I initially went with a tabular interface, but I thought a descriptive text version would be kind of cool. The storm thing is kind of personal. I love storms, but I've got an incredible phobia of them as well. Not sure if that makes sense, but when I found that the API I was using supported the feature, I had to include it.
When more cities are added, you use a swipe interface to switch between them. I don't yet support a way of deleting cities, but I'll add that sometime in the future. (Maybe. I tend to say stuff like that and never actually get around to doing it.)
Now let's take a look at the code.
First, the main (and only) view, home.html:
<ion-toolbar primary>
<ion-buttons start>
<button royal (click)="doAdd()">
<ion-icon name="add-circle"></ion-icon>
</button>
</ion-buttons>
<ion-title>Weather</ion-title>
</ion-toolbar>
<ion-content *ngIf="!locations.length" padding>
<h2>No Locations</h2>
<p>
You currently don't have any locations. Please add one.
</p>
<button full (click)="doAdd()">Add Location</button>
</ion-content>
<ion-content #weatherContent *ngIf="locations.length" padding [ngClass]="curClass">
<ion-slides #mySlides (ionDidChange)="onSlideChanged()">
<ion-slide *ngFor="let location of locations;let i = index">
<h2>{{location.name}}</h2>
<div *ngIf="weatherData[i]">
<p>
{{ weatherData[i].summary }}
</p>
<p style="text-align:justify">
It is currently {{ weatherData[i].temperature | number:'2.0-0' }} °F
and there is {{weatherData[i].precipProbability | percent}} chance of rain.
Tomorrow will be {{weatherData[i].tomorrow.summary}} with the
low being {{ weatherData[i].tomorrow.temperatureMin | number:'2.0-0' }} °F
and a high of {{ weatherData[i].tomorrow.temperatureMax | number:'2.0-0' }} °F.
</p>
<p style="text-align:justify">
The nearest storm to you is {{weatherData[i].nearestStormDistance}} miles away.
</p>
</div>
</ion-slide>
</ion-slides>
</ion-content>
The top portion of the template is the simple header. You can see the button used to add locations as well.
Next, I've got two <ion-content> blocks. This is how I handle that initial view when you have no locations. When there are locations, I loop over them and display weather data for each. You'll notice I'm using a second array, weatherData
, for probably what will end up being a dumb reason, but let's worry about that later.
That's the view, now let's discuss the services I use. When you enter an address, I use Google's Geocoding API to reverse geocode the address to a longitude and latitude value. Here's that code.
import {Injectable} from '@angular/core';
import {Http} from '@angular/http';
import 'rxjs/add/operator/map';
/*
Generated class for the GeocodeService provider.
See https://angular.io/docs/ts/latest/guide/dependency-injection.html
for more info on providers and Angular 2 DI.
*/
@Injectable()
export class GeocodeService {
key:String = 'change';
constructor(public http: Http) {}
locate(address) {
// don't have the data yet
return new Promise(resolve => {
// We're using Angular Http provider to request the data,
// then on the response it'll map the JSON data to a parsed JS object.
// Next we process the data and resolve the promise with the new data.
this.http.get('https://maps.googleapis.com/maps/api/geocode/json?address='+encodeURIComponent(address)+'&key='+this.key)
.map(res => res.json())
.subscribe(data => {
// we've got back the raw data, now generate the core schedule data
// and save the data for later reference
if(data.status === "OK") {
resolve({name: data.results[0].formatted_address, location:{
latitude: data.results[0].geometry.location.lat,
longitude: data.results[0].geometry.location.lng
}});
} else {
console.log(data);
console.log('need to reject');
}
});
});
}
}
All I do is call their API with the address and massage the result a bit to make it simpler for the caller.
The Weather service is provided by Forecast.io. They have a great API, and I thought the storm info was neat. My only real concern is their support. I'm not a paying customer, but I reached out to them two days ago with a question and never heard back from them. (For folks curious about my question, the storm info they provide includes a distance and a bearing, but not a location. So you know how far away a storm is and the direction it is heading, but not the exact location of the storm. So basically you can't tell if the storm is approaching you.) Here is the code for that service.
import {Injectable} from '@angular/core';
import {Http} from '@angular/http';
import 'rxjs/add/operator/map';
/*
Generated class for the WeatherService provider.
See https://angular.io/docs/ts/latest/guide/dependency-injection.html
for more info on providers and Angular 2 DI.
*/
@Injectable()
export class WeatherService {
key: string = 'change';
constructor(public http: Http) {
this.http = http;
}
load(latitude:String,longitude:String) {
return this.http.get('https://api.forecast.io/forecast/'+this.key+'/'+latitude+','+longitude+'?exclude=alerts,minutely,hourly')
.map(res => res.json());
}
}
In this service, I don't change the result at all so it is much simpler.
So - all that leaves then is the logic for the home page itself. It is... a bit messy. I want to talk about what I did before I just dump a bit page of code down so that it will - hopefully - make a bit more sense.
In general, my logic begins with a check to see if you have locations. I use LocalStorage to store an array of location objects. I kept this code within the home logic and didn't abstract it out to a service itself, but obviously that's something I could do too. When I store a location, it is only after the Google Geocode call, so I have a place name and longitude/latitude pair.
Here comes the fun part. When I render out the location, I do an async call to fetch the weather for the location. I ran into issues when I added new locations and they didn't have their weather yet. This led me to working with 2 different arrays, locations
and weatherData
. I keep them in sync so that index N of locations matches index N of weatherData.
I want to stress that I don't necessarily think this makes sense... but it worked. The last bit I added was the background image. That ended up being a royal pain in the rear because... well... CSS. The Yahoo Weather app does some cool Flickr integration where they'll show a picture relevant to your location. I decided to go much simpler and pick hard coded images based on the weather type. Here is what my CSS is now:
.weatherContent-partly-cloudy-day {
background:linear-gradient(to bottom, rgba(255,255,255,0.7) 0%,rgba(255,255,255,0.8) 100%), url(https://c2.staticflickr.com/8/7437/12735811404_22571641b1_z.jpg) no-repeat 0 0;
background-size:cover;
}
.weatherContent-clear-day {
background:linear-gradient(to bottom, rgba(255,255,255,0.7) 0%,rgba(255,255,255,0.8) 100%), url(https://c2.staticflickr.com/6/5607/15473807871_0b00c10bb3_z.jpg) no-repeat 0 0;
background-size:cover;
}
So my code checks the weather and then applies the appropriate class. I've only done two so far (and Forecast.io doesn't actually give you all the possible condition values) but obviously it wouldn't be hard to add more in.
Alright - with that out of the way - here's the code - messy and all:
import {Component,ViewChild} from '@angular/core';
import {WeatherService} from '../../providers/weather-service/weather-service';
import {GeocodeService} from '../../providers/geocode-service/geocode-service';
import {Alert, NavController,Slides,Content} from 'ionic-angular';
@Component({
providers: [WeatherService,GeocodeService],
templateUrl: 'build/pages/home/home.html'
})
export class HomePage {
public weather:Object = {temperture:''};
public locations:Array<Object>;
@ViewChild('mySlides') slider: Slides;
@ViewChild('weatherContent') weatherContent:Content
public curClass:String;
/*
I store a related array of weather reports so that when we add a city, we don't lose the existing
data. Probably a better way of doing this.
*/
public weatherData:Array<Object>;
constructor(public weatherService:WeatherService, public geocodeService:GeocodeService, public nav:NavController) {
this.locations = this.getLocations();
this.weatherData = [];
if(this.locations.length) {
this.locations.forEach((loc,idx) => {
this.weatherService.load(loc.location.latitude, loc.location.longitude).subscribe(weatherRes => {
this.weatherData[idx] = this.formatWeather(weatherRes);
//update the css for slide 0 only
if(idx === 0) this.curClass = 'weatherContent-'+this.weatherData[idx].icon;
});
});
}
}
/*
A utility func that takes the raw data from the weather service and prepares it for
use in the view.
*/
formatWeather(data) {
let tempData:any = data.currently;
tempData.tomorrow = data.daily.data[1];
//do a bit of manip on tomorrow.summary
tempData.tomorrow.summary = tempData.tomorrow.summary.toLowerCase().substr(0,tempData.tomorrow.summary.length-1);
return tempData;
}
addLocation(location:Object) {
let currentLocations = this.getLocations();
currentLocations.push(location);
let index = currentLocations.length-1;
this.weatherService.load(location.location.latitude, location.location.longitude).subscribe(weatherRes => {
this.weatherData[index] = this.formatWeather(weatherRes);
if(index === 0) this.curClass = 'weatherContent-'+this.weatherData[index].icon;
});
this.locations = currentLocations;
localStorage.locations = JSON.stringify(currentLocations);
}
getLocations() {
if(localStorage.locations) return JSON.parse(localStorage.locations);
return [];
}
onSlideChanged() {
let currentIndex = this.slider.getActiveIndex();
var weatherClass = 'weatherContent-'+this.weatherData[currentIndex].icon;
console.log('class is '+weatherClass);
this.curClass = weatherClass;
}
doAdd() {
let prompt = Alert.create({
title:'Add Location',
message:'Enter the new location (name or zip).',
inputs:[
{
name:'location',
placeholder:'Location'
}
],
buttons:[
{
text:'Cancel',
handler: data => {
}
},
{
text:'Add',
handler:data => {
if(data.location == '') return true;
this.geocodeService.locate(data.location).then(result => {
this.addLocation(result);
this.nav.pop();
});
return false;
}
}
]
});
this.nav.present(prompt);
}
}
Yep - not my best code, but it works. :) You can find the complete source for the application here:
https://github.com/cfjedimaster/Cordova-Examples/tree/master/ionicWeatherV2
Let me know what you think and I hope this helps!