A Windows Phone 7 Trip Recorder
Previously I developed a trip recorder using HTML, CSS, and JavaScript leveraging JavaScript’s geolocation API. I recently switched from an iPhone to Windows Phone 7 (WP7) and noticed that mobile Internet Explorer doesn’t support the geolocation API. After a bit of a rant about IE not supporting something so fundamental to a mobile device, I quickly moved forward to see an opportunity to develop a trip recorder application for WP7. This is a long post, but covers all of the steps to create this application. Here a few screenshot (from the emulator) of the application:
Getting Started
There are a few pre-requisites that will help get you started for this project. These have been covered in numerous places and I will only provide links.
- Getting Started with Windows Phone 7 Development – A nice summary of Windows Phone 7 and how to get started by Dan Wahlin.
- 31 Days of Windows Phone 7 – In this ‘how to’ series of posts, Jeff Blankenburg breaks down a number of Windows Phone 7 topics.
- Microsoft’s App Hub – This site helps you get registered as a WP7 developer, download the development tools and manage your account (e.g. publish apps). Once you are registered there is an approval process before you can deploy any apps to your WP7 hardware. In the meantime, Microsoft supplies an emulator for you to use.
You should know that this emulator (from what I have read) is a virtual machine and may have issues running inside another virtual machine. I am developing using the latest version (6) of Parallels for the Mac and have not had any issues. A friend had version 5 and it would not work.
- Bing Maps Portal – This site helps you get registered as a Bing Maps developer. Once you are registered you will receive a ‘key’ to remove the ‘unregistered user’ message from the Bing Maps Silverlight control. You also gain access to the Bing Maps web services.
- MVVM Explained – A nice explanation of the Model-View-ViewModel pattern by Jeremy Likness.
Bing Maps Reverse Geocoding Service
Many times you want to be able to translate GPS latitude / longitude coordinates into a street address. This process is known as reverse geocoding. Bing provides a “Spatial Data Services” service to do this. The service publishes a WSDL and it seemed like it would be straight-forward to a “Service Reference” in Visual Studio. Usually this reads the WSDL and generates a proxy for you to use in your application. Unfortunately, this failed every time I tried. No problem. There is a command line tool for Silverlight that generates the proxy classes for you. Here are the command line command and response from generating the proxy classes:
Microsoft Windows [Version 6.1.7600] Copyright (c) 2009 Microsoft Corporation. All rights reserved. C:Windowssystem32>cd "c:Program Files (x86)Microsoft SDKsWindows Phonev7.0Tools" c:Program Files (x86)Microsoft SDKsWindows Phonev7.0Tools>SlSvcUtil.exe http://dev.virtualearth.net/webservices/v1/geocodeservice/geocodeservice.svc?wsdl Attempting to download metadata from 'http://dev.virtualearth.net/webservices/v1/geocodeservice/geocodeservice.svc?wsdl' using WS-Metadata Exchange or DISCO. Generating files... c:Program Files (x86)Microsoft SDKsWindows Phonev7.0ToolsGeocodeService.cs c:Program Files (x86)Microsoft SDKsWindows Phonev7.0ToolsServiceReferences.ClientConfig
In the above snippet, you can see the path of the tool (I am on a 64 bit system so it may just be ‘c:Program Files’). To run the proxy generator service utility, just feed the URL to the WSDL service as the command line parameter. The service utility generated two files: ‘GeocodeService.cs’ and ‘ServiceReferences.ClientConfig’. Add these two you application (the latter file needs to be in the application root). Then you will be able to use the service with the following code:
private void ReverseGeocode(double lat, double lng) { // Reverse geocode using the Bing service. // ReverseGeocodeRequest reverseGeocodeRequest = new ReverseGeocodeRequest { Credentials = new Credentials { ApplicationId = Keys.BingMaps } }; Location point = new Location { Latitude = lat, Longitude = lng }; reverseGeocodeRequest.Location = point; try { GeocodeServiceClient geocodeServiceClient = new GeocodeServiceClient("BasicHttpBinding_IGeocodeService"); geocodeServiceClient.ReverseGeocodeCompleted += GeocodeServiceClientReverseGeocodeCompleted; geocodeServiceClient.ReverseGeocodeAsync(reverseGeocodeRequest); } catch (Exception ex) { // Could be the revsese geocode service is off-line ReverseGeocodeCompletedEventArgs fake = new ReverseGeocodeCompletedEventArgs(null, ex, false, null); GeocodeServiceClientReverseGeocodeCompleted(this, fake); } } private void GeocodeServiceClientReverseGeocodeCompleted(object sender, ReverseGeocodeCompletedEventArgs e) { // Asnyc callback for completed Bing reverse geocode. // GeocodeResponse geocodeResponse = e.Result; Address = geocodeResponse.Results.Length > 0 ? geocodeResponse.Results[0].DisplayName : "not found"; }
The call to the service is asynchronous. The first method accepts the lat/long GPS coordinates and make the reverse geocoding call to the service using the proxy classes we just built. Note that the ‘ReverseGeocodeRequest’ object has a ‘Credentials’ property that is initialized with the ‘ApplicationId’ I received when I registered as a Bing Maps developer. The second method is the callback that provides the address data. There are times where the initial call to the service may throw exceptions (e.g. end point not available). In those instances, the exception is caught and the callback method invoked with an empty result leading to the ‘not found’ case.
WP7 Infrastructure
The WP7 hardware allows your application to store settings and files. This is done using the ‘System.IO.IsolatedStorage’ namespace. Directly using this namespace can lead to code that cannot be tested without the hardware (or emulator) available. To facilitate automated testing, this infrastructure is encapsulated by the following interfaces and implementations.
public interface IAppSettings { void Save(string name, object value); object Load(string name); void Remove(string name); } public class AppSettings : IAppSettings { public void Save(string name, object value) { IsolatedStorageSettings.ApplicationSettings[name] = value; } public object Load(string name) { if(IsolatedStorageSettings.ApplicationSettings.Contains(name)) { return IsolatedStorageSettings.ApplicationSettings[name]; } return null; } public void Remove(string name) { if(IsolatedStorageSettings.ApplicationSettings.Contains(name)) { IsolatedStorageSettings.ApplicationSettings.Remove(name); } } }
The above code encapsulates the storing of application session data (name/value pairs). The following encapsulate the file system:
public interface IFileSystem { void CreateDirectory(string path); bool DirectoryExists(string path); string GetDirectoryName(string path); bool FileExists(string path); void WriteAllText(string path, string contents); void WriteAllLines(string path, string[] contents); string ReadAllText(string path); string[] ReadAllLines(string path); } public class Wp7FileSystem : IFileSystem { private readonly IsolatedStorageFile _isolatedStorageFile = IsolatedStorageFile.GetUserStoreForApplication(); public void CreateDirectory(string path) { _isolatedStorageFile.CreateDirectory(path); } public bool DirectoryExists(string path) { return _isolatedStorageFile.DirectoryExists(path); } public string GetDirectoryName(string path) { return Path.GetDirectoryName(path); } public bool FileExists(string path) { return _isolatedStorageFile.FileExists(path); } public void WriteAllText(string path, string contents) { StreamWriter writer = new StreamWriter(new IsolatedStorageFileStream(path, FileMode.OpenOrCreate, _isolatedStorageFile)); writer.Write(contents); writer.Close(); } public void WriteAllLines(string path, string[] contents) { string data = string.Join("n", contents); WriteAllText(path, data); } public string ReadAllText(string path) { StreamReader reader = null; string data = null; if (FileExists(path)) { try { reader = new StreamReader(new IsolatedStorageFileStream(path, FileMode.Open, _isolatedStorageFile)); data = reader.ReadToEnd(); } catch (Exception) { } finally { if (reader != null) { reader.Close(); } } } return data; } public string[] ReadAllLines(string path) { string data = ReadAllText(path); if(!string.IsNullOrEmpty(data)) { return data.Split('n'); } return null; } }
The ‘IFileSystem’ interface contains the features that are needed for this application. It could easily expand to contain others. These two interfaces can be faked or mocked to allow code that depends on the WP7 file system to be tested without the need for the WP7 hardware or emulator.
Repository
The persistence mechanism in this application will consist of saving collected GPS data to a file using a simple format. The entities are defined by the following class:
public class TimeStampedLocation { public DateTime Timestamp { get; set; } public GeoCoordinate Location { get; set; } public override string ToString() { return Timestamp.ToString("o") + "," + Location.Latitude + "," + Location.Longitude + "," + Location.Altitude + "," + Location.HorizontalAccuracy + "," + Location.VerticalAccuracy + "," + Location.Course + "," + Location.Speed; } public static TimeStampedLocation Parse(string value) { string[] parts = value.Split(','); if (parts.Length != 8) { return null; } DateTime timeStamp; if(!DateTime.TryParse(parts[0], out timeStamp)) { return null; } double latitude; if(!double.TryParse(parts[1], out latitude)) { return null; } double longitude; if(!double.TryParse(parts[2], out longitude)) { return null; } double altitude; if(!double.TryParse(parts[3], out altitude)) { return null; } double horzAccuracy; if (!double.TryParse(parts[4], out horzAccuracy)) { return null; } double vertAccuracy; if (!double.TryParse(parts[5], out vertAccuracy)) { return null; } double course; if (!double.TryParse(parts[6], out course)) { return null; } double speed; if (!double.TryParse(parts[7], out speed)) { return null; } return new TimeStampedLocation { Timestamp = timeStamp, Location = new GeoCoordinate(latitude, longitude, altitude, horzAccuracy, vertAccuracy, speed, course) }; } }
This entity has two properties: ‘Timestamp’ and ‘Location’. The ‘Location’ property is a ‘System.Device.Location.GeoCoordinate’ object which encapsulates all the GPS data (lat, long, altitude, accuracy…). The two methods provide features for serialization / de-serialization.
The following interface defines the repository. This interface allows points to be added, the list to be cleared and the data persistence.
public interface ILocationRepository { List<TimeStampedLocation> Locations { get; } int Count { get; } double DistanceInMiles { get; } double DistanceInKilometers { get; } void Add(TimeStampedLocation location); void ClearAll(); void Save(); void Load(); }
The two distance properties provide the total distance represented by all the points in the collection. Distance calculation is done using the Haversine Formula which is implemented as follows:
public static class GeoDistanceCalculator { private const double _earthRadiusInMiles = 3956.0; private const double _earthRadiusInKilometers = 6367.0; public static double DistanceInMiles(double lat1, double lng1, double lat2, double lng2) { return Distance(lat1, lng1, lat2, lng2, _earthRadiusInMiles); } public static double DistanceInKilometers(double lat1, double lng1, double lat2, double lng2) { return Distance(lat1, lng1, lat2, lng2, _earthRadiusInKilometers); } private static double Distance(double lat1, double lng1, double lat2, double lng2, double radius) { // Implements the Haversine formulat http://en.wikipedia.org/wiki/Haversine_formula // var lat = ToRadians(lat2 - lat1); var lng = ToRadians(lng2 - lng1); var sinLat = Math.Sin(0.5 * lat); var sinLng = Math.Sin(0.5 * lng); var cosLat1 = Math.Cos(ToRadians(lat1)); var cosLat2 = Math.Cos(ToRadians(lat2)); var h1 = sinLat * sinLat + cosLat1 * cosLat2 * sinLng * sinLng; var h2 = Math.Sqrt(h1); var h3 = 2 * Math.Asin(Math.Min(1, h2)); return radius * h3; } private static double ToRadians(double degrees) { return 2.0*Math.PI*degrees/360.0; } }
The implementation of the repository interface is shown below.
public class LocationRepository : ILocationRepository { private readonly List<TimeStampedLocation> _locations = new List<TimeStampedLocation>(); private const string _relativePath = "data\trip.txt"; private readonly IFileSystem _fileSystem; public LocationRepository(IFileSystem fileSystem) { _fileSystem = fileSystem; } public int Count { get { return _locations.Count; } } public List<TimeStampedLocation> Locations { get { return _locations; } } public double DistanceInMiles { get { return CalculateDistance(false); } } public double DistanceInKilometers { get { return CalculateDistance(true); } } public void Add(TimeStampedLocation location) { lock(_locations) { _locations.Add(location); } } public void ClearAll() { lock (_locations) { _locations.Clear(); } } public void Load() { lock (_locations) { // Clear the current locations // ClearAll(); string data = _fileSystem.ReadAllText(_relativePath); if (data != null) { // Parse the data // string[] parts = data.Split('t'); foreach (string part in parts) { TimeStampedLocation location = TimeStampedLocation.Parse(part); if(location!=null) { Add(location); } } } } } public void Save() { lock (_locations) { StringBuilder data = new StringBuilder(); foreach (TimeStampedLocation location in _locations) { data.Append(location + "t"); } if (!_fileSystem.FileExists(_relativePath)) { // Create the directory string relativeDir = _fileSystem.GetDirectoryName(_relativePath); _fileSystem.CreateDirectory(relativeDir); } _fileSystem.WriteAllText(_relativePath, data.ToString()); } } private double CalculateDistance(bool isMetric) { double totalDistance = 0.0; for (int i = 1; i < _locations.Count; i++) { GeoCoordinate pos = _locations[i].Location; GeoCoordinate last = _locations[i - 1].Location; double distanceFromLastPoint = isMetric ? GeoDistanceCalculator.DistanceInKilometers(pos.Latitude, pos.Longitude, last.Latitude, last.Longitude) : GeoDistanceCalculator.DistanceInMiles(pos.Latitude, pos.Longitude, last.Latitude, last.Longitude); totalDistance += distanceFromLastPoint; } return totalDistance; } }
The constructor takes an ‘IFileSystem’ implementation as a parameter. This is used by the ‘Save’ and ‘Load’ methods to serialize and de-serialize the data to the file system. The only other bit of complex code is the ‘CalculateDistance’ method which loops through all the points accumulating the distances calculated using the Haversine implementation.
GPS Service
The ‘System.Device.Location’ namespace provides access to the WP7 GPS hardware. The main access is through the ‘GeoCoordinateWatcher’ class. Once again, to isolate our code from the hardware implementation and enable features to be faked/mocked for testing purposes the GPS service used by this application is designed to an interface. In this case, the ‘GeoCoordinateWatcher’ class implements the ‘IGeoPositionWatcher<GeoCoordinate>’ interface. The following code defines the GPS watcher by extending this interface.
public interface IGpsService : IGeoPositionWatcher<GeoCoordinate> { event EventHandler<TimeStampedLocationEventArgs> PointAdded; bool IsReady { get; } double TotalDistanceInMeters { get; } int Count { get; } List<TimeStampedLocation> Locations { get; } void Clear(); void Save(); void Load(); } public class TimeStampedLocationEventArgs : EventArgs { public TimeStampedLocation Point { get; set; } }
The ‘IGpsService’ interface inherits ‘IGeoPositionWatcher<GeoCoordinate>’ and as such exposes the features to control and receive data from the GPS. Beyond the ability to start/stop, the ‘IGeoPositionWatcher<GeoCoordinate>’ implementation exposes two events. One provides status (e.g. initializing, ready) information about the GPS hardware. The other provides access to new GPS points as they are calculated by the GPS engine.
The GPS data coming out of the WP7 will have varying accuracy. Inside a building the hardware may use cell phone tower triangulation and provide a low accuracy point. Outdoors the accuracy will improve based upon the number of satellites the hardware ‘sees’. For this application, we want to observe all the GPS data points, but only add accurate data to the repository. The ‘PointAdded’ event is raised when an accurate point is added to the repository.
The ‘TotalDistanceInMeters’, ‘Locations’, ‘Clear’, ‘Save’, and ‘Load’ features allow the users of this object to interact with the repository. These are a light-weight wrapper around the equivalent ‘IRepository’ features without exposing the full ‘IRepository’ interface. For example, the users of the ‘IGpsService’ can save/load data (from the repository) but they cannot add new points.
The implementation of the ‘IGpsService’ is a bit long and probably deserves some refactoring. Here is the current version:
public class GpsService : IGpsService { public event EventHandler<GeoPositionChangedEventArgs<GeoCoordinate>> PositionChanged; public event EventHandler<GeoPositionStatusChangedEventArgs> StatusChanged; public event EventHandler<TimeStampedLocationEventArgs> PointAdded; private readonly IGeoPositionWatcher<GeoCoordinate> _watcher; private readonly ILocationRepository _locationRepo; public bool IsReady { get; set; } public double TotalDistanceInMeters { get; set; } public int Count { get { return _locationRepo.Count; } } public List<TimeStampedLocation> Locations { get { return _locationRepo.Locations; } } public GpsService(IGeoPositionWatcher<GeoCoordinate> watcher, ILocationRepository locationRepository) { _watcher = watcher; _locationRepo = locationRepository; TotalDistanceInMeters = _locationRepo.DistanceInKilometers*1000.0; _watcher.PositionChanged += WatcherPositionChanged; _watcher.StatusChanged += WatcherStatusChanged; } // Expose the gps watcher controls // public GeoPosition<GeoCoordinate> Position { get { return _watcher.Position; } } public GeoPositionStatus Status { get { return _watcher.Status; } } public void Start() { _watcher.Start(); } public void Start(bool suppressPermissionPrompt) { _watcher.Start(suppressPermissionPrompt); } public bool TryStart(bool suppressPermissionPrompt, TimeSpan timeout) { return _watcher.TryStart(suppressPermissionPrompt, timeout); } public void Stop() { _watcher.Stop(); } // Expose the location repo controls // public void Clear() { _locationRepo.ClearAll(); } public void Save() { _locationRepo.Save(); } public void Load() { _locationRepo.Load(); } private void WatcherStatusChanged(object sender, GeoPositionStatusChangedEventArgs e) { IsReady = e.Status == GeoPositionStatus.Ready; if (StatusChanged != null) { StatusChanged(sender, e); } } private void WatcherPositionChanged(object sender, GeoPositionChangedEventArgs<GeoCoordinate> e) { if (e.Position.Location.HorizontalAccuracy <= 20) { TimeStampedLocation point = new TimeStampedLocation { Timestamp = e.Position.Timestamp.Date, Location = e.Position.Location }; if (_locationRepo.Count > 0) { GeoCoordinate last = _locationRepo.Locations[_locationRepo.Count - 1].Location; double distanceFromLastPointInMiles = GeoDistanceCalculator.DistanceInMiles(e.Position.Location.Latitude, e.Position.Location.Longitude, last.Latitude, last.Longitude); // Ignore the points that are closer than the minimum distance. // if (distanceFromLastPointInMiles > 0.005) { TotalDistanceInMeters += distanceFromLastPointInMiles * 1609.344; _locationRepo.Add(point); if (PointAdded != null) { TimeStampedLocationEventArgs args = new TimeStampedLocationEventArgs { Point = point }; PointAdded(this, args); } } } else { // Always add the first accurate point. // _locationRepo.Add(point); if (PointAdded != null) { TimeStampedLocationEventArgs args = new TimeStampedLocationEventArgs { Point = point }; PointAdded(this, args); } } } if (PositionChanged != null) { PositionChanged(sender, e); } } }
The constructor takes an instance of the ‘IGeoPositionWatcher<GeoCoordinate>’ and ‘ILocationRepository’.
The emulator does not provide simulated data for the GPS hardware. By injecting a fake ‘IGeoPositionWatcher<GeoCoordinate>’ entity into our ‘IGpsService’ this code can still work on the emulator.
The incoming repository may already have points so the constructor initializes the ‘TotalDistanceInMeters’ property to reflect this initial state. Finally the ‘state change’ and ‘position change’ events are wired up.
The bulk of the next bit of code simply exposes properties/methods of the two constructor parameters to the ‘IGpsService’ consumer. The final two methods implement the ‘status change’ and ‘position change’ event handlers. In both cases, the events are raised again and propagate out of the ‘IGpsService’ implementation.
The ‘position change’ event handler first examines the accuracy of the observed GPS point. If the point is accurate enough (20 meters) then the next bit of logic ensures the point is far enough away (0.005 miles) from the last point in the repository. If this final condition is met, the point is added and the ‘PointAdded’ event is raised. This bit of logic prevents the ‘total distance’ from accumulating a bunch of points that consist of GPS error randomly distributed around a static location when ever you stop.
Inversion of Control and the Application
So far, we have built quite a few decoupled components. Normally, I would be leaning on my favorite Inversion of Control (IOC) container, Ninject, to glue all these together. Ninject does have a WP7 compatible release, and I will probably explore this in a later post. However, for now I have a really simple container implementation.
public partial class App { // Dependency Management // private static IAppSettings _appSettings; private static IFileSystem _fileSystem; private static ILocationRepository _locationRepo; private static MainPageViewModel _mainPageViewModel; private static readonly IGeoPositionWatcher<GeoCoordinate> _watcher = new GeoCoordinateWatcher(GeoPositionAccuracy.High); private static IGpsService _gpsService; public static IAppSettings AppSettings { get { return _appSettings ?? (_appSettings = new AppSettings()); } } public static IFileSystem FileSystem { get { return _fileSystem ?? (_fileSystem = new Wp7FileSystem()); } } public static ILocationRepository LocationRepo { get { return _locationRepo ?? (_locationRepo = new LocationRepository(FileSystem)); } } public static MainPageViewModel ViewModel { get { return _mainPageViewModel ?? (_mainPageViewModel = new MainPageViewModel(GpsService)); } } public static IGpsService GpsService { get { return _gpsService ?? (_gpsService = new GpsService(_watcher, LocationRepo)); } } // // Other code removed for brevity ... // // }
In this application all of the components (dependencies) are ‘singleton’ instances. In other words, each has a single instance with the lifetime of the application. The above code simply, exposes a number of public static properties on the Application that allows access to the dependencies. Notice that all the dependencies are hand-wired. The ‘IGpsService’ instance is injected with the ‘GeoCoordinateWatcher’ entity that communicated with the WP7 GPS hardware.
The remaining methods in the application class are stubbed out by the default Visual Studio WP7 solution. These event handlers allow you to take actions when the application is starting / closing or ‘tomb stoning’. The application uses these events for persisting data. The following code shows methods in the Application class that have been modified:
private void Application_Launching(object sender, LaunchingEventArgs e) { // rehydrate the trip data LocationRepo.Load(); object value = AppSettings.Load("Exception"); ViewModel.Error = value != null ? value.ToString() : "none"; } private void Application_Activated(object sender, ActivatedEventArgs e) { // rehydrate the trip data LocationRepo.Load(); object value = AppSettings.Load("Exception"); ViewModel.Error = value != null ? value.ToString() : "none"; } private void Application_Deactivated(object sender, DeactivatedEventArgs e) { // Serialize out the trip data LocationRepo.Save(); } private void Application_Closing(object sender, ClosingEventArgs e) { // Serialize out the trip data LocationRepo.Save(); AppSettings.Remove("Exception"); } private static void RootFrame_NavigationFailed(object sender, NavigationFailedEventArgs e) { LocationRepo.Save(); if (System.Diagnostics.Debugger.IsAttached) { // A navigation has failed; break into the debugger System.Diagnostics.Debugger.Break(); } } private static void Application_UnhandledException(object sender, ApplicationUnhandledExceptionEventArgs e) { LocationRepo.Save(); AppSettings.Save("Exception", e.ExceptionObject.ToString()); if (System.Diagnostics.Debugger.IsAttached) { // An unhandled exception has occurred; break into the debugger System.Diagnostics.Debugger.Break(); } }
This code does two things. First it saves/loads the location data from the repository using the static ‘LocationRepo’ instance when the app is starting/closing. Second is captures unhandled exception data in the isolated storage using the ‘AppSettings’ instance. Capturing unhandled exceptions in this way makes debugging a bit easier on the real hardware once you have installed and are out and about. Otherwise the app will just crash with no trace of what happened.
Model-View-ViewModel
I have attempted to follow the Model-View-ViewModel pattern in this application There is plenty of room for improvement here. If you have suggestions, please contact me or leave them in the comments. The ‘view’ or user interface of the application consists of one page of XAML. The page uses the WP7 Silverlight pivot control to break the content up into four areas. If you have never seen the pivot control, you can visualize it as a tab control where you use a swipe gesture to transition between the tabs. Here is the core main page XAML:
<controls:Pivot x:Name="pivot" Title="Trip Recorder"> <controls:PivotItem Header="Data"> <Grid Background="Transparent" Height="752" Width="510" ShowGridLines="False"> <Grid.RowDefinitions> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="50"/> <RowDefinition Height="auto"/> <RowDefinition Height="auto"/> <RowDefinition Height="*" /> </Grid.RowDefinitions> <Grid.ColumnDefinitions> <ColumnDefinition Width="175"/> <ColumnDefinition Width="308*" /> </Grid.ColumnDefinitions> <!--ContentPanel - place additional content here--> <Grid Grid.Row="0" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Status: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="0" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding Status}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="1" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Timestamp:" Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="1" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding TimeStamp}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="2" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Latitude: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="2" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding LatitudeString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="3" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Longitude: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="3" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding LongitudeString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="4" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Altitude: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="4" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding AltitudeString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="5" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Horz Accuracy: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="5" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding HorzAccuracyString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="6" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Vert Accuracy: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="6" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding VertAccuracyString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="7" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Course: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="7" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding CourseString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="8" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Speed: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="8" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding SpeedString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="10" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="Trip Distance: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="10" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding DistanceString}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="11" Grid.Column="0" Margin="12,0,0,0"> <TextBlock Text="# Points: " Style="{StaticResource PhoneTextNormalStyle}" TextAlignment="Right" /> </Grid> <Grid Grid.Row="11" Grid.Column="1" Margin="0,0,0,0"> <TextBlock Text="{Binding NumPoints}" Style="{StaticResource PhoneTextNormalStyle}" /> </Grid> <Grid Grid.Row="12" Grid.Column="0" Grid.ColumnSpan="2"> <StackPanel> <Button x:Name="Start" Height="120" Click="Toggle_Click" Background="#B0088312" Content="Start" Width="400" /> <Button x:Name="Stop" Height="120" Click="Toggle_Click" Background="#94FF0000" Content="Stop" Width="400" Visibility="Collapsed" /> <Button x:Name="Clear" Background="#AC0000FF" Height="120" Click="Clear_Click" Content="Clear" Width="400" /> </StackPanel> </Grid> </Grid> </controls:PivotItem> <controls:PivotItem Header="Map"> <StackPanel> <my:Map Height="544" Name="map1" Width="482" Center="{Binding MapCenter}" ZoomLevel="{Binding MapZoom}" CredentialsProvider="enter-your-own-bing-maps-developer-id" MouseLeftButtonDown="Map1MouseLeftButtonDown"> <my:Map.Mode> <my:AerialMode ShouldDisplayLabels="True" /> </my:Map.Mode> <my:MapPolyline Locations="{Binding Locations}" StrokeThickness="5" Opacity="0.65" Stroke="Blue"></my:MapPolyline> <my:Pushpin Location="{Binding Position}" Background="Orange"></my:Pushpin> </my:Map> <TextBlock Text="{Binding Address}" Style="{StaticResource PhoneTextNormalStyle}" /> </StackPanel> </controls:PivotItem> <controls:PivotItem Header="Settings"> <StackPanel> <TextBlock Text="Units" FontSize="28" FontWeight="Bold"></TextBlock> <RadioButton x:Name="Imperial" GroupName="Unit" Content="Imperial (miles, feet, mph)" IsChecked="True" Checked="Imperial_Checked"></RadioButton> <RadioButton x:Name="Metric" GroupName="Unit" Content="Metric (kilometers, meters, kmph)" Checked="Metric_Checked"></RadioButton> </StackPanel> </controls:PivotItem> <controls:PivotItem Header="Error"> <StackPanel> <TextBlock Text="{Binding Error}" Style="{StaticResource PhoneTextNormalStyle}" /> </StackPanel> </controls:PivotItem> </controls:Pivot>
The pivot control has the following main items:
- Data – Presents raw GPS / trip summary data and exposes the start/stop/clear controls.
- Map – Displays the Bing Maps Silverlight control and the line/pushpin overlays.
- Settings – Presents the user the choice of either ‘metric’ or ‘imperial’ units.
- Error – A temporary page that displays exception data.
A few comments on the XAML. First, in the spirit of MVVM, most of the dynamic content is set by ‘binding’ a control’s property to a property on the view-model (to be discussed shortly). Second, the ‘Map’ control has a ‘CredentialProviders’ property that you will need to fill out with your Bing ID assigned to your application.
The following is the code-behind for the above XAML:
public partial class MainPage { private readonly IGpsService _gpsService = App.GpsService; private Point _p1, _p2; private bool _isZooming; private bool _isLoaded; private bool _isStarted; // Constructor public MainPage() { InitializeComponent(); Touch.FrameReported += Touch_FrameReported; DataContext = App.ViewModel; } private void Touch_FrameReported(object sender, TouchFrameEventArgs e) { if (!_isLoaded) { return; } // If there are more than one finger on screen if (!pivot.IsHitTestVisible && e.GetTouchPoints(map1).Count == 2) { TouchPointCollection tpc = e.GetTouchPoints(map1); // if true that means this is the first time the user puts its fingers on the screen in order to perform a zoom in/out if (!_isZooming) { // store the starting segment _p1 = tpc[0].Position; _p2 = tpc[1].Position; _isZooming = true; } else { // store the secondary segment Point tp1 = tpc[0].Position; Point tp2 = tpc[1].Position; //compute the segments distances then subtract them from each other and add the result to the zoom double d1 = Math.Sqrt(Math.Pow(tp1.X - tp2.X, 2) + Math.Pow(tp1.Y - tp2.Y, 2)); double d2 = Math.Sqrt(Math.Pow(_p1.X - _p2.X, 2) + Math.Pow(_p1.Y - _p2.Y, 2)); double distance = d1 - d2; _isZooming = true; // we divide the zoom ratio by 10 to keep it slow, but you can choose any value that suite your needs map1.ZoomLevel += (distance / 20); _p1 = tp1; _p2 = tp2; } } else { _isZooming = false; } } private void Map1MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { pivot.IsHitTestVisible = false; } private void LayoutRoot_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) { pivot.IsHitTestVisible = true; } private void PhoneApplicationPage_Loaded(object sender, RoutedEventArgs e) { LoadSettings(); _isLoaded = true; } private void Imperial_Checked(object sender, RoutedEventArgs e) { SaveSettings(); App.ViewModel.IsMetric = false; } private void Metric_Checked(object sender, RoutedEventArgs e) { SaveSettings(); App.ViewModel.IsMetric = true; } private void SaveSettings() { if(Imperial==null) { return; } const string name = "Unit"; string value; if (Imperial.IsChecked.HasValue && Imperial.IsChecked.Value) { value = "Imperial"; } else { value = "Metric"; } App.AppSettings.Save(name, value); } private void LoadSettings() { const string name = "Unit"; object value = App.AppSettings.Load(name); if (value != null) { string unit = (string) value; if (unit == "Imperial") { Imperial.IsChecked = true; Metric.IsChecked = false; } else { Imperial.IsChecked = false; Metric.IsChecked = true; } } } private void Toggle_Click(object sender, RoutedEventArgs e) { if(!_isStarted) { _isStarted = true; Start.Visibility = Visibility.Collapsed; Stop.Visibility = Visibility.Visible; _gpsService.Start(); } else { _isStarted = false; Stop.Visibility = Visibility.Collapsed; Start.Visibility = Visibility.Visible; _gpsService.Stop(); } } private void Clear_Click(object sender, RoutedEventArgs e) { _gpsService.Clear(); _gpsService.Save(); } }
There is still some improvements that can be done using ‘ICommand’ and binding the radio buttons. I will save those improvements for possible a later post.
First notice we use the ‘IOC container’ we previously build to get the ‘IGpsService’ instance. Second notice we set the ‘DataContext’ in the constructor to the instance of the ‘ViewModel’ (again using the IOC container we built).
Embedding the map control into the pivot control presents a bit of a dilemma. Both controls intercept on use the ‘flick touch motion’. By default it appears that the pivot control wins. The solution presented in the previous link works. I have tweaked it to zoom as expected (in when the fingers separate) and adjusted the zoom sensitivity a bit. The ‘Touch_FrameReport’, ‘Map1MouseLeftButtonDown’, and ‘LayouRoot_MouseLeftButtonUp’ event handlers work together to disable (by setting the ‘IsHitTestVisible’ to false) pivot control when the map has focus.
The ‘PhoneApplicationPage_Loaded’, ‘Imperial_Checked’, ‘Metric_Checked’, ‘SaveSettings’, and ‘LoadSettings’ methods handle the two radio button events on the ‘Units’ pivot item. Finally the ‘Toggle_Click’ and ‘Clear_Click’ event handlers manage the start/stop and clear button behaviors.
The only thing left to discuss is the view-model. The view-model must implement the ‘INotifyPropertyChange’ interface to allow properties that are bound to the view to supply a ‘change notification’ to the view (and this can be two-way) to trigger an update. The following base class provides this implementation:
public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected void OnNotifyPropertyChanged(string p) { if(PropertyChanged != null) { PropertyChanged(this, new PropertyChangedEventArgs(p)); } } public bool IsDesignTime { get { return (Application.Current == null) || (Application.Current.GetType() == typeof (Application)); } } }
The view-model implementation in this version is a bit heavy and deserves a round (or maybe two) of refactoring. The private ‘backing’ variables for the properties and implementation of the properties for binding account for most of the line count. Here is the code:
public class MainPageViewModel : ViewModelBase { private readonly IGpsService _gpsService; // These all have public properties and are exposed to the // view for binding. // private readonly LocationCollection _locations = new LocationCollection(); private string _status; private string _timestamp; private double _latitude; private double _longitude; private double _altitude; private double _horzAccuracy; private double _vertAccuracy; private double _course; private double _speed; private double _distance; private string _address; private string _error; private bool _isMetric; private GeoCoordinate _mapCenter; private double _mapZoom; public MainPageViewModel(IGpsService gpsService) { // Wire up event handlers to the service // events so the model can reflect the current state. // _gpsService = gpsService; _gpsService.StatusChanged += GpsServiceStatusChanged; _gpsService.PositionChanged += GpsServicePositionChanged; _gpsService.PointAdded += GpsServicePointAdded; // If there are existing points, then we need to set // the state accordingly. // if(_gpsService.Count > 0) { foreach (TimeStampedLocation location in _gpsService.Locations) { _locations.Add(location.Location); } OnNotifyPropertyChanged("Locations"); OnNotifyPropertyChanged("Position"); OnNotifyPropertyChanged("NumPoints"); MapCenter = _locations[_locations.Count - 1]; MapZoom = 15.0; Distance = _gpsService.TotalDistanceInMeters; // Fetch the address from the reverse geocoding service. // ReverseGeocode(Position.Latitude, Position.Longitude); } } #region Binding Points public string Status { get { return _status; } set { if(_status != value) { _status = value; OnNotifyPropertyChanged("Status"); } } } public GeoCoordinate Position { get { return Locations.Count == 0 ? null : Locations[Locations.Count - 1]; } } public string TimeStamp { get { return _timestamp; } set { if(_timestamp!=value) { _timestamp = value; OnNotifyPropertyChanged("TimeStamp"); } } } public double Latitude { get { return _latitude; } set { if(_latitude != value) { _latitude = value; OnNotifyPropertyChanged("Latitude"); OnNotifyPropertyChanged("LatitudeString"); } } } public string LatitudeString { get { return string.Format("{0:0.00} degrees", _latitude); } } public double Longitude { get { return _longitude; } set { if(_longitude !=value) { _longitude = value; OnNotifyPropertyChanged("Longitude"); OnNotifyPropertyChanged("LongitudeString"); } } } public string LongitudeString { get { return string.Format("{0:0.00} degrees", _longitude); } } public double Altitude { get { return _altitude; } set { if(_altitude != value) { _altitude = value; OnNotifyPropertyChanged("Altitude"); OnNotifyPropertyChanged("AltitudeString"); } } } public string AltitudeString { get { double val = ConvertUnitsSmall(_altitude); if (_isMetric) { return string.Format("{0:0.00} meters", val); } return string.Format("{0:0.00} feet", val); } } public double HorzAccuracy { get { return _horzAccuracy; } set { if(_horzAccuracy != value) { _horzAccuracy = value; OnNotifyPropertyChanged("HorzAccuracy"); OnNotifyPropertyChanged("HorzAccuracyString"); } } } public string HorzAccuracyString { get { double val = ConvertUnitsSmall(_horzAccuracy); if (_isMetric) { return string.Format("{0:0.00} meters", val); } return string.Format("{0:0.00} feet", val); } } public double VertAccuracy { get { return _vertAccuracy; } set { if(_vertAccuracy != value) { _vertAccuracy = value; OnNotifyPropertyChanged("VertAccuracy"); OnNotifyPropertyChanged("VertAccuracyString"); } } } public string VertAccuracyString { get { double val = ConvertUnitsSmall(_vertAccuracy); if (_isMetric) { return string.Format("{0:0.00} meters", val); } return string.Format("{0:0.00} feet", val); } } public double Course { get { return _course; } set { if(_course!=value) { _course = value; OnNotifyPropertyChanged("Course"); OnNotifyPropertyChanged("CourseString"); } } } public string CourseString { get { return string.Format("{0:0.00} degrees", _course); } } public double Speed { get { return _speed; } set { if(_speed!=value) { _speed = value; OnNotifyPropertyChanged("Speed"); OnNotifyPropertyChanged("SpeedString"); } } } public string SpeedString { get { double val = ConvertUnitsLarge(_speed)*60*60; if(_isMetric) { return string.Format("{0:0.00} kmph", val); } return string.Format("{0:0.00} mph", val); } } public int NumPoints { get { return Locations.Count; } } public double Distance { get { return _distance; } set { if(_distance != value) { _distance = value; OnNotifyPropertyChanged("Distance"); OnNotifyPropertyChanged("DistanceString"); } } } public string DistanceString { get { double val = ConvertUnitsLarge(_distance); if(_isMetric) { return string.Format("{0:0.00} kilometers", val); } return string.Format("{0:0.00} miles", val); } } public LocationCollection Locations { get { return _locations; } } public string Address { get { return _address; } set { if(_address != value) { _address = value; OnNotifyPropertyChanged("Address"); } } } public bool IsMetric { get { return _isMetric; } set { if(_isMetric != value) { _isMetric = value; OnNotifyPropertyChanged("IsMetric"); OnNotifyPropertyChanged("AltitudeString"); OnNotifyPropertyChanged("HorzAccuracyString"); OnNotifyPropertyChanged("VertAccuracyString"); OnNotifyPropertyChanged("SpeedString"); OnNotifyPropertyChanged("DistanceString"); } } } public GeoCoordinate MapCenter { get { return _mapCenter; } set { if(_mapCenter != value) { _mapCenter = value; OnNotifyPropertyChanged("MapCenter"); } } } public double MapZoom { get { return _mapZoom; } set { if(_mapZoom != value) { _mapZoom = value; OnNotifyPropertyChanged("MapZoom"); } } } public string Error { get { return _error; } set { if(_error != value) { _error = value; OnNotifyPropertyChanged("Error"); } } } #endregion private void GpsServicePointAdded(object sender, TimeStampedLocationEventArgs e) { // A point was added to the collection of trip points. // // Add the location and notify the view // Locations.Add(e.Point.Location); OnNotifyPropertyChanged("Locations"); OnNotifyPropertyChanged("Position"); OnNotifyPropertyChanged("NumPoints"); // If this is the first point, provide a default zoom // and set the center. // if (Locations.Count == 1) { MapCenter = e.Point.Location; MapZoom = 15.0; } // Update the total trip distance. // Distance = _gpsService.TotalDistanceInMeters; // Fetch the address from the reverse geocoding service. // ReverseGeocode(e.Point.Location.Latitude, e.Point.Location.Longitude); } private void GpsServicePositionChanged(object sender, GeoPositionChangedEventArgs<GeoCoordinate> e) { // A new observation point was collected by the GPS. // This was not necessarily with enough accuracy or // far enough away from the last trip point. // // Update the properties and notify the UI // TimeStamp = e.Position.Timestamp.ToString(); Latitude = e.Position.Location.Latitude; // degrees Longitude = e.Position.Location.Longitude; // degrees Altitude = e.Position.Location.Altitude; // meters HorzAccuracy = e.Position.Location.HorizontalAccuracy; // meters VertAccuracy = e.Position.Location.VerticalAccuracy; // meters Course = e.Position.Location.Course; // degrees relative to north Speed = e.Position.Location.Speed; // meters per second } private void GpsServiceStatusChanged(object sender, GeoPositionStatusChangedEventArgs e) { // A status change in the GPS receiver. // Update and notify the UI. // Status = e.Status.ToString(); } private void ReverseGeocode(double lat, double lng) { // Reverse geocode using the Bing service. // ReverseGeocodeRequest reverseGeocodeRequest = new ReverseGeocodeRequest { Credentials = new Credentials { ApplicationId = Keys.BingMaps } }; Location point = new Location { Latitude = lat, Longitude = lng }; reverseGeocodeRequest.Location = point; try { GeocodeServiceClient geocodeServiceClient = new GeocodeServiceClient("BasicHttpBinding_IGeocodeService"); geocodeServiceClient.ReverseGeocodeCompleted += GeocodeServiceClientReverseGeocodeCompleted; geocodeServiceClient.ReverseGeocodeAsync(reverseGeocodeRequest); } catch (Exception ex) { // Could be the revsese geocode service is off-line ReverseGeocodeCompletedEventArgs fake = new ReverseGeocodeCompletedEventArgs(null, ex, false, null); GeocodeServiceClientReverseGeocodeCompleted(this, fake); } } private void GeocodeServiceClientReverseGeocodeCompleted(object sender, ReverseGeocodeCompletedEventArgs e) { // Asnyc callback for completed Bing reverse geocode. // GeocodeResponse geocodeResponse = e.Result; Address = geocodeResponse.Results.Length > 0 ? geocodeResponse.Results[0].DisplayName : "not found"; } private double ConvertUnitsSmall(double value) { if (!IsMetric) { const double conversionFactorFeetPerMeter = 3.2808399; return value * conversionFactorFeetPerMeter; } return value; } private double ConvertUnitsLarge(double value) { if (!IsMetric) { const double conversionFactorMilesPerMeter = 0.000621371192; return value * conversionFactorMilesPerMeter; } return value * 0.001; } }
The constructor takes an ‘IGpsService’ instance. This allows the view-model to bind to the three exposed events and update various properties when these events are raised. There is a bit of logic in the constructor to account for any existing locations by setting the view-model properties appropriately.
Next is a long list of property implementations that are bound to the various XAML elements. The XAML elements are bound to a ‘String’ representation of the underlying element. For example, the binding uses the ‘SpeedString’ property instead of the ‘Speed’ property directly. This allows the value in the ‘Speed’ property to be displayed in the currently selected unit (metric or imperial).
Below this region are the three ‘IGpsService’ event handlers. As new data arrives (via the event) properties are set and ‘OnNotifyPropertyChanged’ methods are called to alert the view. New points are also sent off to the Bing reverse geocoding service to determine the address.
Summary
Sorry for the long post. We covered a lot of ground. I thought about splitting this into a couple of posts, but couldn’t settle in good way to divide it up. Developing WP7 applications is very enjoyable to a large extent because of the tools that have been provided by Microsoft. I am certain these will only get better. As always, if you have constructive criticism, questions or want to leave a comment please do so.
i use the reverse geocoder service, but get some connection instead of real address. can you tell me how to fix the problem
Is this application able to work?
when i tried step by step.. i had an error on the (keys.bingmap).. it is replace the API key in it? or?
Great article but is the full sources available ? if so, can you send them thru my email add.
thanks a lot in advance,
sincerly
Domi.