Starter Step

TabStrip for Titanium Mobile

Posted by: Brian Knorr on: February 28, 2011

Another UI component we found missing in Titanium Mobile was a TabStrip, like the one you would find in Facebook’s Three20 project for the iPhone where you can have as many tabs as you like and have them fit nicely on the top of the screen because they are scrollable.

After spending some time we came up with a solution that we believe works really well. It takes in any number of labels and will dynamically size the horizontal scroll width and provide arrow indicators that more tabs exist in either direction. Just pass in an onselect function and you can get the tab index for a selected tab – with this you can change the data on the screen. The code below uses the days of the week as an example and will swap out the table view’s data based on the tab selected.

function createTabStrip(options) {
 options = options || {};
 params = {
   labels: options.labels || [],
   onselect: options.onselect || null,
   top: options.top || .1,
   height: options.height || 40,
   backgroundColor: options.backgroundColor || '#000',
   gradientColor: options.gradientColor || '#444',
   selectedColor: options.selectedColor || '#fff',
   unselectedColor: options.unselectedColor || '#999',
   fontSize: options.fontSize || 14
 }

 var labelViews = [];
 var lastSelectedLabel = null;
 var totalWidth = 0;

 var containerView = Titanium.UI.createView({
   top:params.top,
   height:params.height,
   width:320,
   backgroundColor:params.backgroundColor,
   backgroundGradient: {
     type:'linear',
     colors:[
       {color:params.gradientColor,position:0.0},
       {color:params.backgroundColor,position:0.50},
       {color:params.gradientColor,position:1.0}
     ]
   }
 });

 var leftArrow = Ti.UI.createLabel({
   text:String.fromCharCode(171),
   font:{fontSize:params.height / 2,fontWeight:'bold'},
   color:params.selectedColor,
   height:params.height,
   width:15,
   top:params.top,
   left:2,
   textAlign:'left',
   visible:false
 });
 containerView.add(leftArrow);

 var scrollView = Titanium.UI.createScrollView({
   layout:'horizontal',
   top:params.top,
   left:17,
   height:params.height,
   width:286
 });
 containerView.add(scrollView);

 var rightArrow = Ti.UI.createLabel({
   text:String.fromCharCode(187),
   font:{fontSize:params.height / 2,fontWeight:'bold'},
   color:params.selectedColor,
   height:params.height,
   width:15,
   top:params.top,
   right:2,
   textAlign:'right',
   visible:false
 });
 containerView.add(rightArrow);

 scrollView.addEventListener('scroll', function(e) {
   leftArrow.visible = e.x > 5;
   rightArrow.visible = e.x < scrollView.contentWidth - scrollView.width;
 });

 containerView.labels = function(labels) {
   params.labels = labels;
   resetLabels();
 }

 containerView.selectTab = function(index) {
   select(labelViews[index]);
 }

 function resetLabels() {
   totalWidth = 0;
   labelViews = [];
   var oldLabels = scrollView.children;
   if (oldLabels) {
     for (var i = 0,count = oldLabels.length; i < count; i++) {
       scrollView.remove(oldLabels[i]);
     }
   }

   var labels = params.labels;
   for (var i = 0,count = labels.length; i < count; i++) {
      var button = createButton(labels[i], i);
      scrollView.add(button);
   }
   scrollView.contentWidth = totalWidth;
   rightArrow.visible = totalWidth > scrollView.width;
 }

 function createButton(title, index) {
   var buttonView = Ti.UI.createView({
     top:params.top,
     height:params.height
   });

   var label = Ti.UI.createLabel({
     text:title,
     font:{fontSize:params.fontSize,fontStyle:'bold'},
     width:'auto',
     textAlign:'center',
     height:params.height,
     touchEnabled:false
   });
   label.index = index;
   labelViews.push(label);
   buttonView.add(label);
   showAsUnSlected(label);
   buttonView.addEventListener('click', function(e) {
     select(e.source.children[0]);
   });
   buttonView.width = label.size.width + 20;
   totalWidth += buttonView.width;

   if (index == 0) {
     showAsSelected(label);
   }
   return buttonView;
 }

 function select(label) {
   if (lastSelectedLabel) {
     showAsUnSlected(lastSelectedLabel);
   }
   showAsSelected(label)
   if (params.onselect) {
     params.onselect(label.index);
   }
 }

 function showAsSelected(label) {
   label.color = params.selectedColor;
   label.getParent().borderWidth = 1;
   lastSelectedLabel = label;
 }

 function showAsUnSlected(label) {
   label.color = params.unselectedColor;
   label.getParent().borderWidth = 0;
 }

 resetLabels();
 return containerView;
}


var tableData = [
 [{title:'Monday Data'}],
 [{title:'Tuesday Data'}],
 [{title:'Wednesday Data'}],
 [{title:'Thursday Data'}],
 [{title:'Friday Data'}],
 [{title:'Saturday Data'}],
 [{title:'Sunday Data'}]
];

var window = Titanium.UI.createWindow({
 fullscreen:false,
 backgroundColor:'#fff',
 title:'TabStrip'
});

var tabStripView = createTabStrip({
 labels:['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'],
 onselect: function(index) {
    //This is so you don't get flickering
    tableView.hide();
    tableView.setData(tableData[index]);
    setTimeout(function() {
        tableView.show();
    }, 300);
 }
});

window.add(tabStripView);

var tableView = Ti.UI.createTableView({
 top:tabStripView.height
});
window.add(tableView);

var navGroup = Ti.UI.iPhone.createNavigationGroup({
 window:window
});

var main = Ti.UI.createWindow();
main.add(navGroup);
main.open();

tabStripView.selectTab(0);

Thumbnail Viewer for Titanium Mobile

Posted by: Brian Knorr on: February 23, 2011

We have started looking at Appcelerator’s Titanium Mobile SDK for developing iPhone and Android apps using Javascript…unfortunately they still have a ways to go with the promise of being cross-platform in supporting iPhone and Android with a single code base.  However if you are just targeting iPhone, then you must take a look at the latest Titanium 1.6 SDK.

We did however find a vital UI component missing from Titanium – the ability to view remote images (thumbnails) in a grid-like fashion with built in paging.  We knew that this would be a showstopper for us so I took a stab at building my own by wiring together pieces of the current Titanium UI components.  To try it out just follow the getting started instructions on Appcelerator’s site and then copy and paste the code below into the app.js file that is generated for you.  Please note that this only works on a Mac since it’s targeting the iPhone.  And it’s not the prettiest code and could use some refactoring before going prime-time.

The thumbnail viewer requires a plixi user id.  It uses this id to call the plixi api and retrieve a user’s uploaded photos for viewing as thumbnails.  The example below uses Arsenio Hall’s plixi id :)

function createThumbnailViewer(props) {

 var that = {};
 var xhr = Titanium.Network.createHTTPClient();

 var photosWindow = Titanium.UI.createWindow({
   fullscreen:false,
   backgroundColor:'#fff',
   title:props.title
 });
 that.window = photosWindow;

 var refreshButton = Ti.UI.createButton({
   title:'Refresh',
   width:40,
   height:30
 });
 var photosContainer = Titanium.UI.createView();

 var imageUrlLists = [];
 var index = -1;
 var total = 0;

 var heightSpacer = 3.5;
 var widthSpacer = 8;
 var topOffset = heightSpacer;
 var imageDim = 70;
 var pageSize = 20;
 for (var i = 0,count = pageSize; i < count; i += 4) {
   var leftOffset = widthSpacer;
   for (var j = 0; j < 4; j++) {
     var photoView = Ti.UI.createImageView({
       top:topOffset,
       left:leftOffset,
       height:imageDim,
       width:imageDim
     });
     photosContainer.add(photoView);
     leftOffset += imageDim + widthSpacer;
   }
   topOffset += imageDim + heightSpacer;
 }

 photosWindow.add(photosContainer);

 var flexSpace = Titanium.UI.createButton({
   systemButton:Titanium.UI.iPhone.SystemButton.FLEXIBLE_SPACE
 });

 var previousButton = Ti.UI.createButton({
   title:'Prev',
   width:50,
   height:10
 });
 previousButton.addEventListener('click', function() {
   previous();
 });
 var nextButton = Ti.UI.createButton({
   title:'Next',
   width:50,
   height:10
 });
 nextButton.addEventListener('click', function() {
   next();
 });
 var nativespinner = Titanium.UI.createButton({
   systemButton:Titanium.UI.iPhone.SystemButton.SPINNER
 });

 var labelContainer = Ti.UI.createView({width:100,height:20});

 var label = Ti.UI.createLabel({
 color:'#fff'
 });

 labelContainer.add(label);

 photosWindow.toolbar = [previousButton, flexSpace, labelContainer, flexSpace, nextButton];

 refreshButton.addEventListener('click', function() {
   that.start();
 });

 that.start = function() {
   index = -1;
   imageUrlLists = [];
   total = 0;
   next();
 }

 photosWindow.rightNavButton = refreshButton;

 function next() {
   index++;
   if (index >= imageUrlLists.length) {
     load();
   } else {
     applyImageUrls();
   }
 }

 function previous() {
   index--;
   if (index >= 0) {
     applyImageUrls();
   }
 }

 function applyImageUrls() {
   var imageUrlList = imageUrlLists[index];
   label.text = (index * pageSize + imageUrlList.length) + " of " + total;
   var photos = photosContainer.children;
   var i = 0,count = imageUrlList.length;
   while (i < count) {
     photos[i].image = imageUrlList[i];
     i++;
   }
   if (count < pageSize) {
     while (i < pageSize) {
       photos[i].image = null;
       i++;
     }
   }
   previousButton.enabled = index > 0;
   nextButton.enabled = imageUrlLists.length * pageSize <= total;
 }

 function load() {
   xhr.onload = function() {
     var json = JSON.parse(this.responseText);
     var list = json.List;
     total = json.CollectionCount;
     var imageUrlList = [];
     var i = 0, count = list.length;
     while (i < count) {
       imageUrlList[i] = list[i].ThumbnailUrl;
       i++;
     }
     imageUrlLists[index] = imageUrlList;
     photosWindow.toolbar = [previousButton, flexSpace, labelContainer, flexSpace, nextButton];
     applyImageUrls();
   };
   previousButton.enabled = false;
   nextButton.enabled = false;
   photosWindow.toolbar = [previousButton, flexSpace, nativespinner, flexSpace, nextButton];
   var url = "http://api.plixi.com/api/tpapi.svc/json/users/"+props.plixiUserId+"/photos?getcount=true&ps=" + pageSize + "&ind=" + (index * pageSize);
   Ti.API.info("Calling url  = " + url);
   xhr.open("GET", url);
   xhr.send();
 }
 return that;
}

var thumbnailViewer = createThumbnailViewer({
 plixiUserId:'6236650',
 title:'Aresenio Hall'
});

var navGroup = Ti.UI.iPhone.createNavigationGroup({
 window:thumbnailViewer.window
});

var main = Ti.UI.createWindow();
main.add(navGroup);
main.open();

thumbnailViewer.start();

Introducing DynaDroid

Posted by: Brian Knorr on: April 25, 2010

We recently released our popular mobile travel application TripCase on Android, and though we liked developing with the Android framework more than iphone, blackberry, and winmo, we ran into some cumbersome concepts like the Android ActivityTripCase has many forms, work-flows, viewable and editable data, interactions with the device, and makes many calls to our Rails back-end server.  So in order to streamline our development efforts on Android we created a small micro-framework that we are now calling DynaDroid and would like to open it up to the Android community for use.

Here are some of the features you get with DynaDroid:

And here is what is coming:

And it’s very easy to get started…just checkout the DynaDroid source from the subversion repository found here.  The source is setup as an example Android application so all you have to do is import it into Eclipse or IntelliJ and just run it.

We would really like your feedback on the framework and if you have any questions or issues please visit the DynaDroid Google Group.

Enjoy!

Tags: , ,

Passing parameters to acts_as_state_machine events

Posted by: Jake Dempsey on: January 19, 2010

This morning I ran into a small roadblock working with the acts_as_state_machine gem. My code started like this:

class Communication < ActiveRecord::Base
  include AASM
  aasm_column :status

  aasm_state :draft
  aasm_state :pending
  aasm_state :approved
  aasm_state :rejected

  aasm_event :submit do
    transitions :to => :pending, :from => :draft
  end

  aasm_event :approve do
    transitions :to => :approved, :from => [:pending, :rejected]
  end

  aasm_event :reject do
    transitions :to => :rejected, :from => [:pending, :approved], :guard => :validate_rejection_reason
  end

  aasm_initial_state :draft

  def validate_rejection_reason
    if self.rejection_reason
      true
    else
      raise AASM::InvalidTransition.new('A rejection reason is required')
    end
  end
end

This is a basic example using AASM to provide an approval process for my Communication model. I wanted the ability to just call instance.reject(“some reason”) or instance.reject!(“some reason”). I was really thinking to hard about it. The assm_event just creates some methods for me….so why not just decorate those methods using ruby aliases. This is what I ended up with:

  include AASM
  aasm_column :status

  aasm_state :draft
  aasm_state :pending
  aasm_state :approved
  aasm_state :rejected

  aasm_event :submit do
    transitions :to => :pending, :from => :draft
  end

  aasm_event :approve do
    transitions :to => :approved, :from => [:pending, :rejected]
  end

  aasm_event :reject do
    transitions :to => :rejected, :from => [:pending, :approved], :guard => :validate_rejection_reason
  end
  alias old_reject reject
  alias old_reject! reject!

  aasm_initial_state :draft

  def reject(reason)
    self.rejection_reason = reason
    self.old_reject
  end

  def reject!(reason)
    self.rejection_reason = reason
    self.old_reject!
  end

  def validate_rejection_reason
    if self.rejection_reason
      true
    else
      raise AASM::InvalidTransition.new('A rejection reason is required')
    end
  end

Notice the use of alias for reject and reject!. Now you have to call reject with a rejection reason. There may be another way to get parameters in to the event call….but this is how I rolled mine :)

New has_class_attr plugin

Posted by: Jake Dempsey on: November 3, 2009

I wrote this as a plugin….but it really has nothing to do with rails. I guess it could be packaged as a gem, but its so small that it probably doesn’t make sense. You ever create class variables or class instance variables and then create getters for them for use as statics?

Old Way:

class MyClass
  @items = [1,2,3]

  def self.items
    @items
  end
end

MyClass.items  #[1,2,3]

New Way:

class MyClass
  has_class_attr :items, :data => [1,2,3]
end

MyClass.items  #[1,2,3]

Install:

  .script/plugin install git://github.com/angelo0000/has_class_attr.git

New has_duration_field plugin

Posted by: Jake Dempsey on: November 1, 2009

Building on my previous post I created the has_duration_field plugin. As described, I wanted to represent a duration of time on a field in my model. Using the proxy_field plugin, I built the DurationField class and added it as the proxy for my columns tblk and tduty.

Before the has_duration_field plugin:

class Block < ActiveRecord::Base
  proxy_field [:tduty, :tblk], :as => DurationField
end

I just implemented my DurationField class and threw it in my lib dir. As I did that I realized that really using proxy_field and the implementation of DurationField would make a neat little plugin.

With the plugin you can do this:

class Block < ActiveRecord::Base
  has_duration_field [:tduty, :tblk]
end

The plugin just provides an implementation of the DurationField class for you and an easy way to make your column proxy to a DurationField.

Install:

./script/plugin install git://github.com/angelo0000/has_duration_field.git

New proxy_field plugin

Posted by: Jake Dempsey on: October 31, 2009

I spent some time this week and wrote some small plugins. I’ll do a small write for each to explain the purpose and usage. The first plugin is the proxy_field plugin. For Bidbuddy (personal project) I only have about 10 models, but for each model there are a ton of fields that each represent a time duration. For example a field may represent how long a specific aircraft is used for a given schedule. The time is a duration so I have to store the value in seconds in the database. What I found was that I had helpers that would convert those column values in to hours, minutes, days, etc. While this works, it doesn’t feel right. I would rather just get the value of the column and call to_hours on it. I could open the Integer class and add my to_hours method to it, but I would like something a bit more generic that could be reused for other situations. What I came up with was this syntax:

class Block < ActiveRecord::Base
  proxy_field [:tduty, :tblk], :as => DurationField
end

class DurationField
  def initialize(seconds)
    @seconds = seconds
  end

  def to_hours
    @seconds / 60 / 60
  end

  #Other useful methods would go here
  #to_seconds, to_minutes, to_days, to_weeks, etc...
end

This allows me to proxy any ActiveRecord field into another object. It basically allows you to deserialize any column data.

Old Way:

  def seconds_to_hours(seconds)
    seconds / 60 / 60
  end

  b = Block.find(CONDITIONS_HERE)
  puts seconds_to_hours(b.tduty)

New Way:

  b = Block.find(CONDITIONS_HERE)
  puts b.tduty.to_hours

Install:

  ./script/plugin install git://github.com/angelo0000/proxy_field.git

I have not decided yet how to handle nil columns. Its not a technical challenge but more of a design decision. The plugin could return nil when you called the method to get the proxy: b.tduty would return nil. Or that could be up to the proxy object class to return nil for each method if the inializer got a nil. In my duration example to_hours method would nil if @seconds was nil. I’m not sure what I would prefer. I would love a suggestion…

iPhone Performance – NSDateFormatter

Posted by: Jake Dempsey on: October 15, 2009

Brian and I have been working on improving the startup of TripCase and have found some interesting items. Our startup on TripCase slow…eventually we think we will move to a more facebook style model where much of your data is stored locally on the device. This will allow us to show the main view of TripCase without the 6-N seconds of time to talk to the server first. While the page loads with the cached data we can asyncronously retrieve the live data and update the view….but I digress.

We were debugging our message stream on the main view and noticed that the first cell was taking almost a full second to create. All subsequent cells were taking on the order of .15 – .20 seconds. After some NSLogs here and there we finally tracked down the culprit! In our app we implemented a “time ago” feature that tells you relatively how long ago something occurred. It is a nice feature and lets users see that something was created a few minutes ago, a few hours ago, etc. Its pretty granular and makes for a good experience. The times we convert to timeago are given to the client through our server and are all in GMT. This means that in timeago we need to create a NSDateFormatter with a mask that includes timezone. Our poor performing code looked something like the following:

+(NSString *)timeAgo:(NSString *)_date {
	static NSDateFormatter *parser = nil;
	if (parser == nil) {
		parser = [[NSDateFormatter alloc] init];
                [parser setLocale:[[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"] autorelease]];      
		[parser setFormatterBehavior:NSDateFormatterBehavior10_4];
		[parser setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss z"];
	}
	NSDate *date = [parser dateFromString:[_date stringByAppendingString:@" GMT"]];
	double littleOverAWeek = 11520.0f;
	NSDate *now = [NSDate date];
	double deltaMinutes = fabs([date timeIntervalSinceDate:now]) / 60.0f;
	if(deltaMinutes < littleOverAWeek){
		return [NSString stringWithFormat:@"%@ ago", [self distanceOfTimeInWords:deltaMinutes]];
	} else {
		return [NSString stringWithFormat:@"on %@", [DateFormatter shortDateFromDate:date]];
	}
}

In the above method, we create a NSDateFormatter with a mask of “yyyy-MM-dd’T'HH:mm:ss z”. Seems pretty harmless right? Wrong! Brian and I removed the “z” and timed the creation of the NSDateFormatter. We saw that with the “z” in the mask, the creation of the NSDateFormatter was .7 seconds slower!! 700 milleseconds is a lifetime. We saw that during startup we create two NSDateFormatters that were doing this…that is 1.5 seconds of wasted time that we wanted to remove.

In the docs we noticed that you can set the timezone for the NSDateFormatter explicitly. We did that and the .7 seconds of invocation time went away…BAM! We just shaved 1.5 seconds from our startup time. Our new code looks something like this:

+(NSString *)timeAgo:(NSString *)_date {
	static NSDateFormatter *parser = nil;
	if (parser == nil) {
		parser = [[NSDateFormatter alloc] init];
                [parser setLocale:[[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"] autorelease]];       
		[parser setFormatterBehavior:NSDateFormatterBehavior10_4];
		[parser setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss"];
		[parser setTimeZone:[NSTimeZone timeZoneWithName:@"GMT"]];
	}
	NSDate *date = [parser dateFromString:_date];
	double littleOverAWeek = 11520.0f;
	NSDate *now = [NSDate date];
	double deltaMinutes = fabs([date timeIntervalSinceDate:now]) / 60.0f;
	if(deltaMinutes < littleOverAWeek){
		return [NSString stringWithFormat:@"%@ ago", [self distanceOfTimeInWords:deltaMinutes]];
	} else {
		return [NSString stringWithFormat:@"on %@", [DateFormatter shortDateFromDate:date]];
	}
}

Two things to notice in the above code:
1. We set the timezone context directly on the NSDateFormatter which allowed us to remove the “z” from the mask.
2. Doing this also allowed us to remove the ugly code that was appending GMT to the time returned from the server.

All of our timings were done on a 3G device. In all we shaved roughly 1.5 seconds because this pattern was used in two places during startup. Score 1 for super sleuthing!

TripCase in Latest Apple Commercial

Posted by: Brian Knorr on: September 22, 2009

Last night Apple’s latest commercial premiered with our mobile travel app, TripCase, as the first application demoed in the advertisement.

It ran during Heroes, House, and Gossip Girl, and Apple plans on running it for the next several months in various time slots.

If you haven’t seen it yet you can view it on Apple’s website here:

http://www.apple.com/iphone/gallery/ads/#dine-large

Custom UIAlertView with TableView Part 2

Posted by: Jake Dempsey on: September 16, 2009

I did a write up a while back on a custom UIAlertView that displays a list of options to the user. I’ve gotten some questions on how to use the component so this is a quick write up on just how to do that. Let me first say that I have not done iPhone development for a few months….I have been hard at work getting TripCase (http://tripcase.com) working on Windows Mobile. I hopped back into XCode this evening and felt lost!

So to recap the component, the intent is that you have a list of options you want to show your user. In our case we are geocoding a location that can be resolved to multiple places. We want our user to choose from a list of places returned from our geocoding service. We could have taken the user to another page and had them make a selection and then taken them back to the original page. We weren’t crazy about that…so we rolled this little gem.

Also, just as an fyi, this may not even be needed anymore. I have not looked at how the 3.x sdk would handle this. There might be a nicer approach to this all together. But for those who have asked, here is a sample ApplicationDelegate class body that should get you going. I’ll also attach a zipped up version of the sample app for you to download and use.

- (void)applicationDidFinishLaunching:(UIApplication *)application {    

    // here we just create a button to invoke our alert view.  this of course would be done by some event in your code somewhere.
	UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect];
	button.frame = CGRectMake(60, 100, 200, 30);
	[button setTitle:@"Invoke the Alert View" forState:UIControlStateNormal];
	[button addTarget:self action:@selector(createAlertView) forControlEvents:UIControlEventTouchUpInside];
	[window addSubview:button];
    [window makeKeyAndVisible];
}

-(void)createAlertView {
	[[[[AlertTableView alloc] initWithCaller:self data:[NSArray arrayWithObjects:@"one", @"two", @"three", @"four", @"five", @"six", nil] title:@"Did you mean..." andContext:nil] autorelease] show];
}


-(void)didSelectRowAtIndex:(NSInteger)row withContext:(id)context {
	[[[UIAlertView alloc] initWithTitle:@"Selection Made" message:[NSString stringWithFormat:@"Index %d clicked", row] delegate:self cancelButtonTitle:@"Ok" otherButtonTitles:nil] show];
}

//-(UIView*) viewForOptionAtRow:(NSUInteger)row {
//}

This sample app just creates a button and throws it on the screen. The click handler for the button is where the use of the new AlertTableView comes into play. The AlertTableView is created with an array of option strings. The caller is “self” because I want this same class to handle the callbacks for the component. I am passing in a nil context because I dont need a context for this small example. I then show the AlertTableView as you would a regular UIAlertView.

Once we have created our new alert, there are two important delegate methods we have at our disposal. The first is didSelectRowAtIndex which will be called when the user clicks either an option in our list, or the cancel button. This method will receive the index of the selected row or -1 if cancel is clicked. It will also get the context that was passed into the creation of the alert. This is where you actually “do” something based on your users selection. The other method you optionally implement is viewForOptionAtRow. This method gives you the ability to customize the view that is displayed in the option list.

That’s pretty much it…. Of course the component needs work..it uses absolute postioning…and makes some bad assumptions…but it gets the job done.

Link to project
Ok so wordpress wont let me upload a zip file…? So I just googled for a free file hosting service and dumped it here Example App.

Follow

Get every new post delivered to your Inbox.