Monday, April 02, 2007

Walkthrough

This tutorial will walk you through creating a simple Apollo application using the extension for Flex Builder. The application will pull data about a user's geotagged photos from Flickr and save the data in a .kml file that can be read by Google Earth. (If you're new to ActionScript 3, Flex, or Apollo and this sounds complicated, don't worry. You'll be surprized at how simple it actually is.) If you want to download the sample application and project archive, they are available here:

Sample application: right-click and select Save As
(requires the Apollo runtime, available here)

Project archive (.zip)

TOC

Installation
Creating the Project
Laying out the user interface
Setting up the HTTPService
Searching by user name
Retrieving photo data
Converting to kml
Saving the File
Testing the application
Deployment

Installation

If you don't have Apollo or Flex Builder, you can download them here:

Adobe Flex Builder 2

Apollo extensions for Flex Builder


Creating the Project

After installing Flex Builder and the Apollo extensions, go to File > New > Apollo Project. The new project dialog will open. Leave Basic selected and click Next. Give the project a name. I decided to call mine FlickEarth. At this point you can click Finish, or click Next to specify main project source folder, etc. I went with the defaults.

When the project creation is complete, you should see a new Apollo project folder icon in the Navigator pane that contains a bin folder and two files: WhateverYouNamedYourApp.mxml, and WhateverYouNamedYourApp-app.xml. The mxml file is pretty standard - just a stub:

<?xml version="1.0" encoding="utf-8"?>
<mx:ApolloApplication xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute">

</mx:ApolloApplication>



We'll come back to this in a minute when we start coding the application. The first thing we need to do is set up the -app.xml file. Open it and find the properties tag. You can set the name, description, publisher, and copyright info here, if you want to. This information will be displayed during the install dialog when your finished application is being deployed. Lynda.com has some video tutorials on further customization of this file, including how to appropriately set up an application icon. When you've finished, save and switch back to the mxml file.

Laying out the user interface

The mxml code is pretty simple. We'll start the layout by setting the dimensions of the ApolloApplication component:
<mx:ApolloApplication xmlns:mx="http://www.adobe.com/2006/mxml" layout="absolute" width="400" height="144" showEffect="fadeIn" horizontalScrollPolicy="off" verticalScrollPolicy="off">

Next, we'll layout the controls:
<mx:Label text="FlickEarth" verticalCenter="0" horizontalCenter="0" id="splash_lbl"/>
<mx:Form defaultButton="{search_btn}" width="398" y="0" height="88">
<mx:FormItem label="Flickr User:">
<mx:TextInput id="userName_txt" width="230"/>
</mx:FormItem>
<mx:FormItem>
<mx:Button label="Search" id="search_btn" click="findUser()" visible="false"/>
</mx:FormItem>
</mx:Form>
<mx:Button label="Legal" cornerRadius="0" click="Alert.show('This product uses the Flickr API but is not endorsed or certified by Flickr.');" fontSize="9" width="39" height="20" right="10" bottom="10"/>
<mx:Text x="10" y="76" text="" id="status_txt" width="331" height="50"/>
</mx:Canvas>


I've put in findUser() for the click event handler of search_btn. We'll write that function in a minute. You might have noticed that search_btn's visible property is set to false. This is because I originally had no Form component enclosing the input textbox and button. I wanted the user to be able to just hit enter when the textbox had focus and have the Search button's click event be dispatched. Using a form, though, allows you to specify a default_button in the Form component which will essentially dispatch the specified control's click event when the enter/return key is pressed while any of the FormItem controls contained in the form has focus. The default_button property is simply set to the desired component's id.

Setting up the HTTPService

Now that we have our visual components layed out, we can start pulling data from Flickr. The first thing we need is a way to call the web services exposed by the Flickr API. To do this, we add an HTTPService component. Note that this is a non-visual component and must be added in the Code view in Flex Builder.
<mx:HTTPService id="srv" useProxy="false" result="resultHandler(event)" fault="faultHandler(event)" resultFormat="e4x"/>

Now we need to add a Script component where we will put all our ActionScript code:
<mx:Script>
<![CDATA[
import mx.controls.Alert;

private var resultXML:XMLList;

private function resultHandler(event:Event):void {
//store data in xml list
resultXML = new XMLList(srv.lastResult);
}

private function faultHandler(event:Event):void {
//hide the album and display error message
Alert.show("Error contacting flickr.com");
}

]]>
</mx:Script>

In our AS code block, we've added functions to handle the HTTPService's result and fault events. When the service's send method is called, one of these two events will be dispatched, depending on the result of send operation. The resultHandler function stores the data to a local XML object for further use. The faultHandler function simply displays an Alert. This is sort of a catch-all error message, since the fault event could be caused by several things (for example flickr not responding, or no active internet connection).

So our HTTPService is set up and ready to use. Let's now add the code to envoke the service's send method. First, let's set up a few preliminaries. Add the following to your Script block:
import mx.rpc.events.ResultEvent;

private var originalUserName:String;
private var parsedUserName:String;
private var api_key:String = "Here you should paste in your own Flickr API key"; //You can get it by going to http://www.flickr.com/services/api/
private var queryString:String;
private var user_id:String;

Next, add a function to call the HTTPService. This is a bit abstracted for modularity. You need to compose the querystring and pass it in. Documentation for the Flickr API can be found here.
private function callService(queryString:String):void {
//set the url and call the send method
srv.url = "http://api.flickr.com/services/rest/?" + queryString;
srv.send();
}

Searching by user name

Finally, we add the findUser() function mentioned earlier, and a helper function.
private function parseUserName(name:String):String {
//replace all spaces with plus signs and return edited string
return name.replace(/ /g, '+');
}

private function findUser():void {
originalUserName = userName_txt.text;
parsedUserName = parseUserName(userName_txt.text);
status_txt.text = "Searching flickr.com for " + originalUserName;
queryString = "api_key=" + api_key + "&method=flickr.people.findByUsername&username=" + parsedUserName;
srv.addEventListener(ResultEvent.RESULT, foundUser);
callService(queryString);
userName_txt.setFocus(); //Save the user a keystroke/mouseclick
}

The Flickr API is pretty simple and straightforward. The URL is contained in the callService function. All calls to the API are composed of a querystring appended to the URL. The querystring will contain the method being called, the (necessary) API key, and any additional parameters.

If the Flickr user name has spaces in it, the API explorer on Flickr's site substitutes the + character for spaces. I found that it works just as well if we don't follow suit, but for good form and in case they later require it, I add the parser function to do the substitution. For those unfamiliar with regular expressions, the first parameter passed to the 'replace' function is a regex composed of the pattern being matched (/ /) - in this case a single space and a flag (g) which indicates that all instances of the pattern found should be treated. Leaving off the flag will result in only the first instance being treated.

You probably noticed that a foundUser function is mentioned in the addEventListener parameters. Let's write that:
private function foundUser(event:Event):void {
if (resultXML.@stat == "ok") {
user_id = resultXML.user.@id;
srv.removeEventListener(ResultEvent.RESULT, foundUser);
status_txt.text = originalUserName + " found. Searching for geotagged photos...";
getPhotos();
}
else {
//Our Princess is in another castle
status_txt.text = originalUserName + " not found.";
}
}

You can probably begin to see a pattern here. After the user enters a name and hits enter/return, we call the service. If it fails, we error out. If it succeeds, we move on to the next step. If that fails, we error out, or move on if it succeeds. We'll continue this way through to the end of the process. At this point we hopefully have the user_id of the Flickr user specified by our application's user.

Since the HTTPService relies on an HTTP request, chances are that the results will not be returned before our application is ready for them. Using the event listeners allows us to wait for the results and continue execution upon receiving them. Since we are using the same HTTPService object each time, we need to remove the old event listener and add the new one so that the appropriate handler function is called each time.

Retrieving photo data

Next step is to write the getPhotos() function:
private function getPhotos():void {
//get photos: flickr.people.getPublicPhotos
queryString = "api_key=" + api_key + "&method=flickr.people.getPublicPhotos&user_id=" + user_id + "&extras=geo&per_page=500";
srv.addEventListener(ResultEvent.RESULT, findTaggedPhotos);
callService(queryString);
}

We're setting the optional per_page parameter here to get the maximum possible photos. We could write the program to go through page by page, appending each time, but since this is just a simple example, I'll leave that as the proverbial exercise for the reader. We've also set the optional extras parameter so that the photo data will come back with latitude and longitude we can use to create our .kml file with later on. extras takes a comma delimited list, but we're just requesting the geo data for this application.

So we've requested the user's photo data, let's add the function that handles the results:
private function findTaggedPhotos(event:Event):void {
//clean up
srv.removeEventListener(ResultEvent.RESULT, findTaggedPhotos);
if (resultXML.@stat == "ok") {
//for each photo if lat/long data is included, numTaggedPhotos++
//else remove node from resultXML
for (var index:Number = 0; index < resultXML.photos.photo.length(); index++) {
if (resultXML.photos.photo[index].@latitude != 0) {
numTaggedPhotos++;
status_txt.text = originalUserName + " found. Searching for geotagged photos... " + numTaggedPhotos;
}
else {
delete resultXML.photos.photo[index];
index--;
}
}

//now we have a list of only the geotagged photos from this user...
//let the user know how many items were in the list and that we're now converting the data to kml format
if (numTaggedPhotos > 0) {
status_txt.text = numTaggedPhotos + " geotagged photos found for " + originalUserName + ". Converting to .kml";
convertToKML();
}
else {
status_txt.text = "No geotagged photos found for " + originalUserName;
}
}
else {
Alert.show("Flickr error code: " + resultXML.@stat + " Module: findTaggedPhotos");
}
}


Now that we ostensibly have the user's photo data things start getting interesting. On receiving valid photo data, we go through and remove all photo nodes that haven't been geotagged. There is the possibility that the user has tagged photos exactly on the Prime Meridian. To get around this, we could use Flickr's getWithGeoData method. Again, this is left as an exercise to the reader (read: yeah, neither of us wants to go to the trouble).

You may notice that we are changing our loop counter variable inside the loop when we delete. This is because deleting an XML node means that the next node immediately has the index the deleted node had. Since the counter variable will increment on the next loop iteration, we need to adjust for that.

Converting to KML

We're now down to a bit of data manipulation. This part is boring, but necessary. If you want to skip it and go on to the next section which covers file i/o, feel free. Here, we take the geo data and build the contents of the .kml file.

First, we need an XML object to store the kml data in (note that this is placed outside the Script block):
<mx:XMLListCollection id="kml"/>



And now a few more small items of business to run the show:
private var numTaggedPhotos:Number = 0;
private var xmlHeader:String = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>";
private var kmlWrapper:String = "<Folder> <name>FlickEarth</name> <visibility>0</visibility> <description>Output from FlickEarth: a sample Apollo app.</description><styleUrl>#noDrivingDirections</styleUrl>";


And finally the conversion function:
private function convertToKML():void {
var add:XML;
for (var index:Number = 0; index < resultXML.photos.photo.length(); index++) {
add = <Placemark />;

add.name = String(resultXML.photos.photo[index].@title);
add.description = "";
add.description = XML("<![CDATA[<img src = 'http://farm1.static.flickr.com/" + resultXML.photos.photo[index].@server + "/" + resultXML.photos.photo[index].@id + "_" + resultXML.photos.photo[index].@secret + "_m.jpg' /> ]" + "]>");
add.Point.coordinates = resultXML.photos.photo[index].@longitude + "," + resultXML.photos.photo[index].@latitude + "," + resultXML.photos.photo[index].@accuracy;

kml.addItem(add);
}
//save data out to the desktop
writeFile();
}

When written out to the file, this kml content can be handled by Google Earth. It does not currently work in Google Maps, since Maps doesn't support folders in the kml, although according to their help center, they plan on adding support for folders soon. If you view the raw XML data, you will notice that the CDATA block does not actually make it into the XML, and the HTML characters (<, >, &, etc.) get HTML encoded. This is not a problem for usability, only for readability. The XML object also appears to strip out the \ escape character. Again, not a problem, but if anyone figures out how to input HTML characters in an XML object, please feel free to comment. Now we're ready to take the final product and write it out to a file.

Saving the file

Let's add some objects to handle the file i/o:
public var saveFile:File;
public var stream:FileStream = null;


And then the writeFile() function:
private function writeFile():void {
//create the save file
saveFile = File.desktopDirectory.resolve( "flickr_photos.kml" );
stream = new FileStream();
//open the file and write the data out
stream.open( saveFile, FileMode.WRITE );
stream.writeMultiByte( xmlHeader + kmlWrapper + "" + kml.toXMLString() + "", File.systemCharset );
stream.close();

//clean up for the next go-round
saveFile = null;
stream = null;
status_txt.text = numTaggedPhotos + " geotagged photos found for " + originalUserName + ". .kml saved to Desktop.";
numTaggedPhotos = 0;
}


Note that using the desktopDirectory places the file on the user's desktop, regardless of which OS they are running. The Apollo runtime takes care of deciding what the actual path on the local volume is.

Testing

Now we're ready for testing the application. Save the file (if you haven't already). Go to Run > Run YourApplicationName. A window should open staged with a proscenium of the default system chrome. Enter a Flickr user name into the text input and hit enter/return. Assuming you have a working internet connection, and assuming that the user exists on Flickr, and assuming that the user has at least one geotagged photo, you should soon have a file named flickr_photos.kml sitting on your desktop. Open this with Google Earth, and you should see a placemark in the correct location for each of the user's geotagged photos. Clicking on a placemark should open up a pop-up in Earth that will pull the appropriate photo directly from Flickr, as per the HTML written in the .kml file. When you're done, close Earth and the running Apollo application.

Deploying

Now that we have the finished application, we are ready to deploy it, that is to export it as an installable .air file. Note that to install the .air file, users (including you) will need to have the Apollo runtime installed. The runtime can be downloaded here.

Go to File > Export. Expand the Apollo tree node and select Deployable AIR file. Click Next and select the project you wish to deploy (for me, FlickEarth). You will need to click on the -app.xml file. Click Next and specify the directory you want Flex Builder to create the .air file in. If the filename isn't already specified, give it one (for example MyApplication.air). Click Finish. Flex Builder will compile your project into an installable .air file which you can distribute to users. After installing the runtime, double click on the .air file to install it.

The last thing you'll want to be aware of is that if you're placing the .air file on a web server for distribution via download, you'll need to set the mime type on the server so that the user's browser knows how to handle it. Mike Chambers has a post on his blog that covers this quite nicely. If you're not sure how to set the mime type, contact your server admin.

Since Apollo is currently in its alpha release there are still a few undocumented features running around. I typically use OSX for my Flex development, and I noticed that while the .air file installs and runs fine (assuming that you leave the box checked which specifies running the application upon completion of the install) that it doesn't show up in my Applications folder. There may be other bugs that I haven't noticed yet in this particular example project, but overall Adobe seems to have done a pretty decent job with the first release of Apollo.

If you have any comments or questions, feel free to post them. I'll try to respond in a somewhat timely manner.

Labels:

0 Comments:

Post a Comment

<< Home