Introduction
OpenWeather
is a WPF-MVVM weather forecast application that displays three day (current day + two days following) forecast for a particular location. The app makes use of the OpenWeatherMap API.
To get the project code, clone or download the project from GitHub.
Background
I wrote the first version of this app back in 2013, when the OpenWeatherMap
API was still in beta. After taking a look at the old code, I decided it was time for a rewrite. As part of the update, the UI has been redesigned and hopefully looks much better than before.
ye olde app UI
Requirements
To use this project effectively, you require the following:
OpenWeatherMap
App ID - Internet connection
- Visual Studio 2015/17
Once you get your App ID, insert it as the value of the APP_ID
constant in the OpenWeatherMapService
class.
private const string APP_ID = "PLACE-YOUR-APP-ID-HERE";
...
Private Const APP_ID As String = "PLACE-YOUR-APP-ID-HERE"
...
OpenWeatherMap
The OpenWeatherMap
API provides weather data in XML, JSON, and HTML formats. The service is free for limited API calls – less than 60 calls per minute, and maximum 5 day forecast.
This project makes use of the XML response:
<weatherdata>
<location>
<name>Nairobi</name>
<type/>
<country>KE</country>
<timezone/>
<location altitude="0" latitude="-1.2834" longitude="36.8167"
geobase="geonames" geobaseid="184745"/>
</location>
<credit/>
<meta>
<lastupdate/>
<calctime>0.0083</calctime>
<nextupdate/>
</meta>
<sun rise="2017-08-17T03:34:28" set="2017-08-17T15:38:48"/>
<forecast>
<time day="2017-08-17">
<symbol number="501" name="moderate rain" var="10d"/>
<precipitation value="5.03" type="rain"/>
<windDirection deg="54" code="NE" name="NorthEast"/>
<windSpeed mps="1.76" name="Light breeze"/>
<temperature day="24.55" min="16.92" max="24.55"
night="16.92" eve="24.55" morn="24.55"/>
<pressure unit="hPa" value="832.78"/>
<humidity value="95" unit="%"/>
<clouds value="scattered clouds" all="48" unit="%"/>
</time>
<time day="2017-08-18">
<symbol number="500" name="light rain" var="10d"/>
<precipitation value="0.65" type="rain"/>
<windDirection deg="109" code="ESE" name="East-southeast"/>
<windSpeed mps="1.62" name="Light breeze"/>
<temperature day="21.11" min="14.33" max="23.5"
night="17.81" eve="21.48" morn="14.33"/>
<pressure unit="hPa" value="836.23"/>
<humidity value="62" unit="%"/>
<clouds value="clear sky" all="8" unit="%"/>
</time>
<time day="2017-08-19">
<symbol number="500" name="light rain" var="10d"/>
<precipitation value="2.47" type="rain"/>
<windDirection deg="118" code="ESE" name="East-southeast"/>
<windSpeed mps="1.56" name=""/>
<temperature day="22.55" min="12.34" max="22.55"
night="15.46" eve="20.92" morn="12.34"/>
<pressure unit="hPa" value="836.28"/>
<humidity value="56" unit="%"/>
<clouds value="clear sky" all="0" unit="%"/>
</time>
</forecast>
</weatherdata>
Fetching Forecast Data
Getting the forecast data is done by a function in the implementation of an interface named IWeatherService
.
public class OpenWeatherMapService : IWeatherService
{
private const string APP_ID = "PLACE-YOUR-APP-ID-HERE";
private const int MAX_FORECAST_DAYS = 5;
private HttpClient client;
public OpenWeatherMapService()
{
client = new HttpClient();
client.BaseAddress = new Uri("http://api.openweathermap.org/data/2.5/");
}
public async Task<IEnumerable<WeatherForecast>>
GetForecastAsync(string location, int days)
{
if (location == null) throw new ArgumentNullException("Location can't be null.");
if (location == string.Empty) throw new ArgumentException
("Location can't be an empty string.");
if (days <= 0) throw new ArgumentOutOfRangeException
("Days should be greater than zero.");
if (days > MAX_FORECAST_DAYS) throw new ArgumentOutOfRangeException
($"Days can't be greater than {MAX_FORECAST_DAYS}");
var query = $"forecast/daily?q={location}&type=accurate&mode=xml&units=metric&
cnt={days}&appid={APP_ID}";
var response = await client.GetAsync(query);
switch (response.StatusCode)
{
case HttpStatusCode.Unauthorized:
throw new UnauthorizedApiAccessException("Invalid API key.");
case HttpStatusCode.NotFound:
throw new LocationNotFoundException("Location not found.");
case HttpStatusCode.OK:
var s = await response.Content.ReadAsStringAsync();
var x = XElement.Load(new StringReader(s));
var data = x.Descendants("time").Select(w => new WeatherForecast
{
Description = w.Element("symbol").Attribute("name").Value,
ID = int.Parse(w.Element("symbol").Attribute("number").Value),
IconID = w.Element("symbol").Attribute("var").Value,
Date = DateTime.Parse(w.Attribute("day").Value),
WindType = w.Element("windSpeed").Attribute("name").Value,
WindSpeed = double.Parse(w.Element("windSpeed").Attribute("mps").Value),
WindDirection = w.Element("windDirection").Attribute("code").Value,
DayTemperature = double.Parse
(w.Element("temperature").Attribute("day").Value),
NightTemperature = double.Parse
(w.Element("temperature").Attribute("night").Value),
MaxTemperature = double.Parse
(w.Element("temperature").Attribute("max").Value),
MinTemperature = double.Parse
(w.Element("temperature").Attribute("min").Value),
Pressure = double.Parse(w.Element("pressure").Attribute("value").Value),
Humidity = double.Parse(w.Element("humidity").Attribute("value").Value)
});
return data;
default:
throw new NotImplementedException(response.StatusCode.ToString());
}
}
}
Public Class OpenWeatherMapService
Implements IWeatherService
Private Const APP_ID As String = "PLACE-YOUR-APP-ID-HERE"
Private Const MAX_FORECAST_DAYS As Integer = 5
Private client As HttpClient
Public Sub New()
client = New HttpClient
client.BaseAddress = New Uri("http://api.openweathermap.org/data/2.5/")
End Sub
Public Async Function GetForecastAsync(location As String,
days As Integer) _
As Task(Of IEnumerable(Of WeatherForecast)) _
Implements IWeatherService.GetForecastAsync
If location Is Nothing Then Throw New ArgumentNullException("Location can't be null.")
If location = String.Empty Then Throw New ArgumentException_
("Location can't be an empty string.")
If days <= 0 Then Throw New ArgumentOutOfRangeException_
("Days should be greater than zero.")
If days > MAX_FORECAST_DAYS Then Throw New ArgumentOutOfRangeException_
($"Days can't be greater than {MAX_FORECAST_DAYS}.")
Dim query = $"forecast/daily?q={location}&type=accurate&_
mode=xml&units=metric&cnt={days}&appid={APP_ID}"
Dim response = Await client.GetAsync(query)
Select Case response.StatusCode
Case HttpStatusCode.Unauthorized
Throw New UnauthorizedApiAccessException("Invalid API key.")
Case HttpStatusCode.NotFound
Throw New LocationNotFoundException("Location not found.")
Case HttpStatusCode.OK
Dim s = Await response.Content.ReadAsStringAsync()
Dim x = XElement.Load(New StringReader(s))
Dim data = x...<time>.Select(Function(w) New WeatherForecast With {
.Description = w.<symbol>.@name,
.ID = w.<symbol>.@number,
.IconID = w.<symbol>.@var,
.[Date] = w.@day,
.WindType = w.<windSpeed>.@name,
.WindSpeed = w.<windSpeed>.@mps,
.WindDirection = w.<windDirection>.@code,
.DayTemperature = w.<temperature>.@day,
.NightTemperature = w.<temperature>.@night,
.MaxTemperature = w.<temperature>.@max,
.MinTemperature = w.<temperature>.@min,
.Pressure = w.<pressure>.@value,
.Humidity = w.<humidity>.@value})
Return data
Case Else
Throw New NotImplementedException(response.StatusCode.ToString())
End Select
End Function
End Class
In GetForecastAsync()
, I'm fetching the data asynchronously and using LINQ to XML to create a collection of WeatherForecast
objects. (In the VB code, I'm using XML axis properties to access XElement
objects and to extract values from the necessary attributes.) A command in WeatherViewModel
will be used to call this function.
private ICommand _getWeatherCommand;
public ICommand GetWeatherCommand
{
get
{
if (_getWeatherCommand == null) _getWeatherCommand =
new RelayCommandAsync(() => GetWeather(), (o) => CanGetWeather());
return _getWeatherCommand;
}
}
public async Task GetWeather()
{
try
{
var weather = await weatherService.GetForecastAsync(Location, 3);
CurrentWeather = weather.First();
Forecast = weather.Skip(1).Take(2).ToList();
}
catch (HttpRequestException ex)
{
dialogService.ShowNotification
("Ensure you're connected to the internet!", "Open Weather");
}
catch (LocationNotFoundException ex)
{
dialogService.ShowNotification("Location not found!", "Open Weather");
}
}
Private Property _getWeatherCommand As ICommand
Public ReadOnly Property GetWeatherCommand As ICommand
Get
If _getWeatherCommand Is Nothing Then _getWeatherCommand =
New RelayCommandAsync(Function() GetWeather(), Function() CanGetWeather())
Return _getWeatherCommand
End Get
End Property
Public Async Function GetWeather() As Task
Try
Dim weather = Await weatherService.GetForecastAsync(Location, 3)
CurrentWeather = weather.First
Forecast = weather.Skip(1).Take(2).ToList
Catch ex As HttpRequestException
DialogService.ShowNotification
("Ensure you're connected to the internet!", "Open Weather")
Catch ex As LocationNotFoundException
DialogService.ShowNotification("Location not found!", "Open Weather")
End Try
End Function
The GetWeatherCommand
is fired when the user types in a location and presses the Enter key.
<TextBox x:Name="LocationTextBox"
SelectionBrush="{StaticResource PrimaryLightBrush}" Margin="7,0"
VerticalAlignment="Top"
controls:TextBoxHelper.Watermark="Type location & press Enter"
VerticalContentAlignment="Center"
Text="{Binding Location, UpdateSourceTrigger=PropertyChanged}">
<i:Interaction.Behaviors>
<utils:SelectAllTextBehavior/>
</i:Interaction.Behaviors>
<TextBox.InputBindings>
<KeyBinding Command="{Binding GetWeatherCommand}" Key="Enter"/>
</TextBox.InputBindings>
</TextBox>
Displaying the Data
The current weather data is displayed in a user control named CurrentWeatherControl
. The control displays a weather icon, temperature, weather description, maximum and minimum temperatures, and wind speed.
<UserControl x:Class="OpenWeatherCS.Controls.CurrentWeatherControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:OpenWeatherCS.Controls"
xmlns:data="clr-namespace:OpenWeatherCS.SampleData"
mc:Ignorable="d"
Height="180" MinHeight="180" MinWidth="300"
d:DesignHeight="180" d:DesignWidth="300"
d:DataContext="{d:DesignInstance Type=data:SampleWeatherViewModel,
IsDesignTimeCreatable=True}">
<Grid Background="{StaticResource PrimaryMidBrush}">
<Grid.RowDefinitions>
<RowDefinition Height="106"/>
<RowDefinition/>
<RowDefinition/>
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Image Margin="5">
<Image.Source>
<MultiBinding Converter="{StaticResource WeatherIconConverter}"
Mode="OneWay">
<Binding Path="CurrentWeather.ID"/>
<Binding Path="CurrentWeather.IconID"/>
</MultiBinding>
</Image.Source>
</Image>
<TextBlock Grid.Column="1"
Style="{StaticResource WeatherTextStyle}" FontSize="60">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource TemperatureConverter}"
StringFormat="{}{0:F0}°">
<Binding Path="CurrentWeather.IconID"/>
<Binding Path="CurrentWeather.DayTemperature"/>
<Binding Path="CurrentWeather.NightTemperature"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</Grid>
<Grid Grid.Row="1" Background="{StaticResource PrimaryDarkBrush}">
<TextBlock Grid.Row="1" Style="{StaticResource WeatherTextStyle}"
Foreground="#FFE9F949" TextTrimming="CharacterEllipsis" Margin="5,0"
Text="{Binding CurrentWeather.Description}"/>
</Grid>
<Grid Grid.Row="2" Background="#FF14384F">
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<Border Background="{StaticResource PrimaryLightBrush}"
SnapsToDevicePixels="True">
<TextBlock Grid.Row="1" Style="{StaticResource WeatherTextStyle}">
<Run Text="{Binding CurrentWeather.MaxTemperature,
StringFormat={}{0:F0}°}"/>
<Run Text="/" Foreground="Gray"/>
<Run Text="{Binding CurrentWeather.MinTemperature,
StringFormat={}{0:F0}°}"/>
</TextBlock>
</Border>
<StackPanel Grid.Column="1" Orientation="Horizontal"
HorizontalAlignment="Center">
<Viewbox Margin="5">
<Canvas Width="24" Height="24">
<Path Data="M4,10A1,1 0 0,1 3,9A1,1 0 0,1 4,8H12A2,
2 0 0,0 14,6A2,2 0 0,0 12,
4C11.45,4 10.95,4.22 10.59,4.59C10.2,5 9.56,
5 9.17,4.59C8.78,4.2 8.78,
3.56 9.17,3.17C9.9,2.45 10.9,2 12,2A4,4 0 0,
1 16,6A4,4 0 0,1 12,10H4M19,
12A1,1 0 0,0 20,11A1,1 0 0,0 19,10C18.72,
10 18.47,10.11 18.29,10.29C17.9,
10.68 17.27,10.68 16.88,10.29C16.5,9.9 16.5,
9.27 16.88,8.88C17.42,8.34 18.17,
8 19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14H5A1,
1 0 0,1 4,13A1,1 0 0,1 5,12H19M18,
18H4A1,1 0 0,1 3,17A1,1 0 0,1 4,16H18A3,
3 0 0,1 21,19A3,3 0 0,1 18,22C17.17,
22 16.42,21.66 15.88,21.12C15.5,20.73 15.5,
20.1 15.88,19.71C16.27,19.32 16.9,
19.32 17.29,19.71C17.47,19.89 17.72,20 18,
20A1,1 0 0,0 19,19A1,1 0 0,0 18,18Z"
Fill="#FF9B8C5E" />
</Canvas>
</Viewbox>
<TextBlock Grid.Column="1" Style="{StaticResource WeatherTextStyle}"
Text="{Binding CurrentWeather.WindSpeed,
StringFormat={}{0:F0} mps}"/>
</StackPanel>
</Grid>
</Grid>
</UserControl>
The rest of the forecast data is displayed in an ItemsControl
using a data template which shows the day of the forecast, weather icon, maximum and minimum temperatures, and wind speed.
<DataTemplate x:Key="ForecastDataTemplate">
<DataTemplate.Resources>
<Storyboard x:Key="OnTemplateLoaded">
<DoubleAnimationUsingKeyFrames
Storyboard.TargetProperty=
"(UIElement.RenderTransform).(TransformGroup.Children)[0].
(ScaleTransform.ScaleY)"
Storyboard.TargetName="RootBorder">
<EasingDoubleKeyFrame KeyTime="0" Value="0"/>
<EasingDoubleKeyFrame KeyTime="0:0:1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<QuinticEase EasingMode="EaseOut"/>
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</DataTemplate.Resources>
<Border x:Name="RootBorder" Height="200" Width="140" Margin="0,0,-1,0"
SnapsToDevicePixels="True"
Background="{StaticResource PrimaryDarkBrush}"
RenderTransformOrigin="0.5,0.5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Border BorderThickness="1,1,1,0"
BorderBrush="{StaticResource PrimaryDarkBrush}"
Background="{StaticResource PrimaryMidBrush}">
<TextBlock Style="{StaticResource WeatherTextStyle}" FontWeight="Normal"
Margin="5" Foreground="#FFADB982"
Text="{Binding Date, StringFormat={}{0:dddd}}"/>
</Border>
<Border Grid.Row="1" BorderThickness="0,1,1,0"
BorderBrush="{StaticResource PrimaryDarkBrush}"
Background="{StaticResource PrimaryDarkBrush}">
<Image MaxWidth="100" Margin="5,0,5,5">
<Image.Source>
<MultiBinding Converter="{StaticResource WeatherIconConverter}"
Mode="OneWay">
<Binding Path="ID"/>
<Binding Path="IconID"/>
</MultiBinding>
</Image.Source>
</Image>
</Border>
<Border Grid.Row="2" BorderThickness="1"
BorderBrush="{StaticResource PrimaryDarkBrush}"
Background="{StaticResource PrimaryLightBrush}">
<TextBlock Grid.Row="0" Style="{StaticResource WeatherTextStyle}"
Margin="5" Foreground="#FFAEBFAE">
<Run Text="{Binding MaxTemperature, StringFormat={}{0:F0}°}"/>
<Run Text="/" Foreground="Gray"/>
<Run Text="{Binding MinTemperature, StringFormat={}{0:F0}°}"/>
</TextBlock>
</Border>
<Border Grid.Row="3" BorderThickness="1,0,1,1"
BorderBrush="{StaticResource PrimaryDarkBrush}"
Background="{StaticResource PrimaryMidBrush}">
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Center">
<Viewbox Margin="0,5,5,5">
<Canvas Width="24" Height="24">
<Path Data="M4,10A1,1 0 0,1 3,9A1,1 0 0,1 4,8H12A2,2 0 0,0 14,6A2,
2 0 0,0 12,4C11.45,4 10.95,4.22 10.59,
4.59C10.2,5 9.56,5 9.17,
4.59C8.78,4.2 8.78,3.56 9.17,3.17C9.9,
2.45 10.9,2 12,2A4,4 0 0,
1 16,6A4,4 0 0,1 12,10H4M19,12A1,
1 0 0,0 20,11A1,1 0 0,0 19,
10C18.72,10 18.47,10.11 18.29,
10.29C17.9,10.68 17.27,10.68 16.88,
10.29C16.5,9.9 16.5,9.27 16.88,
8.88C17.42,8.34 18.17,8 19,8A3,
3 0 0,1 22,11A3,3 0 0,1 19,14H5A1,
1 0 0,1 4,13A1,1 0 0,1 5,12H19M18,
18H4A1,1 0 0,1 3,17A1,1 0 0,1 4,
16H18A3,3 0 0,1 21,19A3,3 0 0,1 18,
22C17.17,22 16.42,21.66 15.88,
21.12C15.5,20.73 15.5,20.1 15.88,
19.71C16.27,19.32 16.9,19.32 17.29,
19.71C17.47,19.89 17.72,20 18,
20A1,1 0 0,0 19,19A1,1 0 0,0 18,18Z"
Fill="#FF9B8C5E" />
</Canvas>
</Viewbox>
<TextBlock Grid.Column="1" Style="{StaticResource WeatherTextStyle}"
Foreground="#FFAEBFAE"
Text="{Binding WindSpeed, StringFormat={}{0:F0} mps}"/>
</StackPanel>
</Border>
</Grid>
</Border>
</DataTemplate>
The weather icons that are displayed are from VClouds and I'm using a converter to ensure the appropriate icon is displayed.
public class WeatherIconConverter : IMultiValueConverter
{
public object Convert(object[] values,
Type targetType, object parameter, CultureInfo culture)
{
var id = (int)values[0];
var iconID = (string)values[1];
if (iconID == null) return Binding.DoNothing;
var timePeriod = iconID.ToCharArray()[2];
var pack = "pack://application:,,,/OpenWeather;component/WeatherIcons/";
var img = string.Empty;
if (id >= 200 && id < 300) img = "thunderstorm.png";
else if (id >= 300 && id < 500) img = "drizzle.png";
else if (id >= 500 && id < 600) img = "rain.png";
else if (id >= 600 && id < 700) img = "snow.png";
else if (id >= 700 && id < 800) img = "atmosphere.png";
else if (id == 800) img = (timePeriod == 'd') ? "clear_day.png" : "clear_night.png";
else if (id == 801) img = (timePeriod == 'd') ?
"few_clouds_day.png" : "few_clouds_night.png";
else if (id == 802 || id == 803) img = (timePeriod == 'd') ?
"broken_clouds_day.png" : "broken_clouds_night.png";
else if (id == 804) img = "overcast_clouds.png";
else if (id >= 900 && id < 903) img = "extreme.png";
else if (id == 903) img = "cold.png";
else if (id == 904) img = "hot.png";
else if (id == 905 || id >= 951) img = "windy.png";
else if (id == 906) img = "hail.png";
Uri source = new Uri(pack + img);
BitmapImage bmp = new BitmapImage();
bmp.BeginInit();
bmp.UriSource = source;
bmp.EndInit();
return bmp;
}
public object[] ConvertBack(object value, Type[] targetTypes,
object parameter, CultureInfo culture)
{
return (object[])Binding.DoNothing;
}
}
Public Class WeatherIconConverter
Implements IMultiValueConverter
Public Function Convert(values() As Object, targetType As Type, parameter As Object,
culture As CultureInfo) As Object _
Implements IMultiValueConverter.Convert
Dim id = CInt(values(0))
Dim iconID = CStr(values(1))
If iconID Is Nothing Then Return Binding.DoNothing
Dim timePeriod = iconID.ElementAt(2)
Dim pack = "pack://application:,,,/OpenWeather;component/WeatherIcons/"
Dim img = String.Empty
If id >= 200 AndAlso id < 300 Then
img = "thunderstorm.png"
ElseIf id >= 300 AndAlso id < 500 Then
img = "drizzle.png"
ElseIf id >= 500 AndAlso id < 600 Then
img = "rain.png"
ElseIf id >= 600 AndAlso id < 700 Then
img = "snow.png"
ElseIf id >= 700 AndAlso id < 800 Then
img = "atmosphere.png"
ElseIf id = 800 Then
If timePeriod = "d" Then img = "clear_day.png" Else img = "clear_night.png"
ElseIf id = 801 Then
If timePeriod = "d" Then img = "few_clouds_day.png" _
Else img = "few_clouds_night.png"
ElseIf id = 802 Or id = 803 Then
If timePeriod = "d" Then img = "broken_clouds_day.png" _
Else img = "broken_clouds_night.png"
ElseIf id = 804 Then
img = "overcast_clouds.png"
ElseIf id >= 900 AndAlso id < 903 Then
img = "extreme.png"
ElseIf id = 903 Then
img = "cold.png"
ElseIf id = 904 Then
img = "hot.png"
ElseIf id = 905 Or id >= 951 Then
img = "windy.png"
ElseIf id = 906 Then
img = "hail.png"
End If
Dim source As New Uri(pack & img)
Dim bmp As New BitmapImage
bmp.BeginInit()
bmp.UriSource = source
bmp.EndInit()
Return bmp
End Function
Public Function ConvertBack(value As Object, targetTypes() As Type,
parameter As Object, culture As CultureInfo) _
As Object() Implements IMultiValueConverter.ConvertBack
Return Binding.DoNothing
End Function
End Class
The choice of which icon to display is based on the list of weather condition codes specified here.
Conclusion
While the OpenWeatherMap
API has some limitations for free usage, it is still quite a suitable and well documented API. Hopefully, you have learnt something useful from this article and can now more easily go about using the API with your XAML applications.
History
- 1st August, 2013: Initial post
- 20th August, 2017: Updated code and article