38
iOS at Work: Integrating iOS Apps with Back End Systems Matt Galloway (Freelance Mobile Developer Extraordinaire) Tulsa Dev Lunch February 13, 2013 Wednesday, February 13, 13

Tulsa Dev Lunch iOS at Work

Embed Size (px)

DESCRIPTION

This the deck I used for a talk on integrating iOS into back office systems at the February 13, 2013 Tulsa Dev Lunch.

Citation preview

Page 1: Tulsa Dev Lunch iOS at Work

iOS at Work:Integrating iOS Apps with

Back End Systems

Matt Galloway

(Freelance Mobile Developer Extraordinaire)

Tulsa Dev LunchFebruary 13, 2013

Wednesday, February 13, 13

Page 2: Tulsa Dev Lunch iOS at Work

What about

Android?

Wednesday, February 13, 13

Page 3: Tulsa Dev Lunch iOS at Work

Android sucks.(Especially for business.)

Wednesday, February 13, 13

Page 4: Tulsa Dev Lunch iOS at Work

Most Consistent API

Consistent Hcxrdwcxre

--

Best Secur it .Y

1=eel G-ood Kumb·,cx Pseudo Open

Source-ness

Wednesday, February 13, 13

Page 5: Tulsa Dev Lunch iOS at Work

That said, most of what I’m gonna say about iOS applies to Android too.Meh.

Wednesday, February 13, 13

Page 6: Tulsa Dev Lunch iOS at Work

Think mobile!

Think now!

Wednesday, February 13, 13

Page 7: Tulsa Dev Lunch iOS at Work

Characteristics of Mobile

not a keyboard/mouse paradigm

unreliable low bandwidth high latency network connection

small screen

limited processing power and local storage

limited battery life

hostile work environment

untapped resources: camera(s), accelerometers, GPS, phone, speaker, mic, LED flash

Wednesday, February 13, 13

Page 8: Tulsa Dev Lunch iOS at Work

Wednesday, February 13, 13

Page 9: Tulsa Dev Lunch iOS at Work

Mobile web or die.

Wednesday, February 13, 13

Page 10: Tulsa Dev Lunch iOS at Work

Awesome Dashboard

"App"

Wednesday, February 13, 13

Natrve (iOS, Android, Blackberry, Windows Phone) App Window

Awesome Dashboard

"App"

Flll~d with a single We.bVIeW widget loaded

With your mobile web content.

Page 11: Tulsa Dev Lunch iOS at Work

Wednesday, February 13, 13

Page 12: Tulsa Dev Lunch iOS at Work

When the web won’t do.

Performance/Responsiveness/UX.

Complex local data store.

Network optional.

Hardware control.Sophisticated UI.

3D/accelerated graphics.

Wednesday, February 13, 13

Page 13: Tulsa Dev Lunch iOS at Work

How are enterprise mobile

apps different?

Complex local data stores.

Integration with back office

systems.

Wednesday, February 13, 13

Page 14: Tulsa Dev Lunch iOS at Work

,..__---------~----~~~

Mob.1le Inte_gr~t:1on J>os &- J>on'ts ,

OV\

Cove-r -the Y\et)

Wednesday, February 13, 13

0

I I I I I I I I I I I I I I I I I I I

Crf ~oLA he>. ve -t:.o) /

&-443 f>ov-ts B$Z>

I I I I I I I I I I I I

Page 15: Tulsa Dev Lunch iOS at Work

""' -+l a... . - • ~ (J H o ;.J (/) <U

:J cS ... >l-<Sw hZ.

_g ""'~ cu(/)V)

3~~ ""Q_.

_J (/) . ~h

l-I.

Protot _yp ·,ccxrash EV\terpr ·,se A rch.atecture

H H '"" ~ '"" • ~ LU

• LU (J (J

<(_ <(_ LU-tJ LU-tJ - ~ (U - ~ (U :s :s

h '"" h '"" 4- 4-'""~ '""~ r- r-r-~ r-~ (/) (/)

LU LU<!. LU LU<!. p!. z~ p!. z~

• •

Bus·aness Present~ t·aon D~t~ Access

Lo.9·ac

Wednesday, February 13, 13

sG.'-

D~t~

Stov-e

Page 16: Tulsa Dev Lunch iOS at Work

. -

...Q 0 ~

Wednesday, February 13, 13

EV\ tev-pv-·,se A v-c h ·,tectuv-e)(

D~t~ Access

-~

D~t~

Stov-e

Page 17: Tulsa Dev Lunch iOS at Work

Present~ t·aon )

Bus·aness Lo.9·ac;

&- D~ t~ Access

Wednesday, February 13, 13

D~t~

Stov-e

Page 18: Tulsa Dev Lunch iOS at Work

"EV\tev-pv-·ase" Av-ch.atectuv-e <;tu·ack·,e Mob·,r,z.cx t·aoV\ t=·,x

Present~ t·aon )

Bus·aness Lo.9·ac;

&- D~ t~ Access

Wednesday, February 13, 13

D~t~

Stov-e

. -

...Q 0 ~

'/ oLA'\\ V\eeO to •

bLA. ,\0 -t 'n \S.

Page 19: Tulsa Dev Lunch iOS at Work

Disclaimer: I’m

not .NET developer,

but I experimented a

little in college.

Wednesday, February 13, 13

Page 20: Tulsa Dev Lunch iOS at Work

In Visual Studio...1.) Create a Web Project

2.) Create a new Entity Model

3.) Reverse engineer Entity Model

from Database

4.) Create a WCF Data Service

5.) Add your Entity Model Class to

the Service class declaration

6.) Configure data access.

Wednesday, February 13, 13

Page 21: Tulsa Dev Lunch iOS at Work

http://www.hanselman.com/blog/CreatingAnODataAPIForStackOverflowIncludingXMLAndJSONIn30Minutes.aspxSource:

[JSONPSupportBehavior]public class Service : DataService<YourEnterpriseEntities>{ // This method is called only once to initialize service-wide policies. public static void InitializeService(DataServiceConfiguration config) { // config.SetEntitySetAccessRule("*", EntitySetRights.AllRead); config.SetEntitySetAccessRule("Locations", EntitySetRights.AllRead); config.SetEntitySetAccessRule("Customers", EntitySetRights.All); config.SetEntitySetAccessRule("SalesOrders", EntitySetRights.All); config.SetEntitySetAccessRule("Secrets", EntitySetRights.None); //Set a reasonable paging site config.SetEntitySetPageSize("*", 25); config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V2; }}

Wednesday, February 13, 13

Page 22: Tulsa Dev Lunch iOS at Work

http:LLyourhost.comLservice.svcLCustomers()

http:LLyourhost.comLservice.svcLCustomers(34)

http:LLyourhost.comLservice.svcLCustomers()? $filter=substringof('itactile',Name) or substringof('Galloway' ,ContactLastName)& $format=json

http:LLyourhost.comLservice.svcLCustomers(34)? $expand=Sales0rders$format=json

Wednesday, February 13, 13

Page 23: Tulsa Dev Lunch iOS at Work

http:LLyourhost.comLservice.svcL?~format=json

{ "d" • • { "Enti tySets" : [ "Batches", "Drawings", "DrawingTypes", "Elements", "ElernentAnswers", "ElernentAnswerPhotoes", "ElernentGroups", "Elernenticons", "ElernentQuestions", "ElernentRequirernents", "ElernentTypes", "LocationMetaDatas", "LocationMetaDataFields", "Locations", "PickListirnages", "Projects", "StoreAccesses", "sysdiagrarns", "TestTables", "tlkDivisions", "UpdElernents", "UpdElernentAnswers", "UpdElernentAnswerPhotoes", "UpdLocationMetaDatas", "Users" ]

} }

Wednesday, February 13, 13

Page 24: Tulsa Dev Lunch iOS at Work

http://yourhost.com/service.svc/ElementTypes?Sfor.mat=json

{ "d" : [ { " metadata": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1)", "type": "YourDataModel.ElementType" }, "elementTypeid": 1, "name": "POS 1&2 Camera", "elementGroupid": 1, "lastModified": "\/Date(1340728631167)\/", "active": true, "elementiconid" : 3 4, "Elements" : { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / Elements" } } , "ElementGroup": { "_deferred": { "uri": "http://yourhost.com/service.svc/ElementTypes(1)/ElementGroup" } } , "Elementicon" : { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / Elementicon" } }, "ElementRequirements": { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / ElementReguirements" } }, "DrawingTypes": { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) /DrawingTypes " } }, "ElementQuestions": { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / ElementOuestions" } }

} ' { " metadata": { "uri": "http: //yourhost.com/service.svc / ElementTypes(2)", "type": "YourDataModel.ElementType" }, "elementTypeid": 2, "name": "POS 3&4 Camera", "elementGroupid": 1, "lastModified": "\/Date(1340728631167)\/",

Wednesday, February 13, 13

Page 25: Tulsa Dev Lunch iOS at Work

http://yourhost.com/service.svc/ElementTypes(l)?Sformat=json& Sexpand=ElementGroup { "d" : { " metadata" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)", "type" : "QTSecurityModel . ElementType" }, "elementTypei d": 1, "name" : "POS 1&2 Camera", "elementGroupid" : 1 , "lastModif i ed" : "\/Date( 1340728631167) \/", "active": true, "elementiconi d" : 34, "Elements" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/Elements" } } , "ElementGroup": { "_metadata": { "uri": "http://yourhost.com/service.svc/ElementGroups(1)", "type": "QTSecurityModel.ElementGroup" }, "elementGroupid": 1, "name": "Cameras", "sortOrder": 1, "lastModified": "\/Date(1340289282327)\/", "active": true, "ElementTypes": { "_deferred": { "uri": "http://yourhost.com/service.svc/ElementGroups(1)/ElementTypes" } } } , "Element i con" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/Elementi con" } }, "ElementRequirements" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/ElementReguirements" } } , "DrawingTypes" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/DrawingTypes" } }, "ElementQuestions" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/ElementOuestions" } } } }

Wednesday, February 13, 13

Page 26: Tulsa Dev Lunch iOS at Work

The Mobile Dev

POV

Wednesday, February 13, 13

Page 27: Tulsa Dev Lunch iOS at Work

+(id) syncRequest: (NSString *) urlString error:(NSError **) error { NSLog(@"syncRequest: %@",urlString); urlString=[SyncHelper addJsonToUri:urlString]; // Adds ?$format=json to URL NSURL *url = [NSURL URLWithString:urlString]; NSError *internalError = nil; NSURLResponse *response=nil; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; if (HTTP_USER!=nil && [HTTP_USER length]>0 && HTTP_PASSWORD!=nil && [HTTP_PASSWORD length]>0) { NSString *authStr = [NSString stringWithFormat:@"%@:%@",HTTP_USER,HTTP_PASSWORD]; NSData *authData = [authStr dataUsingEncoding:NSASCIIStringEncoding]; NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodingWithLineLength:80]]; [request setValue:authValue forHTTPHeaderField:@"Authorization"]; }

NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&internalError]; if (!internalError) { internalError=nil; NSDictionary *interimDict = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers|NSJSONReadingAllowFragments error:&internalError]; if (internalError!=nil) { NSLog(@"Error parsing JSON from syncRequest: %@ ",[internalError debugDescription]); if (error!=nil) *error=internalError; return nil; } NSDictionary *errorDict = [interimDict objectForKey:@"error"]; if (errorDict!=nil) { NSDictionary *messageDict = [errorDict objectForKey:@"message"]; NSString *errorMessage = [messageDict objectForKey:@"value"]; if (error!=nil) *error=[NSError errorWithDomain:ERROR_DOMAIN code:4000 localizedDescription:[NSString stringWithFormat:@"Error received from server: %@",errorMessage]]; return nil; }

Reading Data

Wednesday, February 13, 13

Page 28: Tulsa Dev Lunch iOS at Work

id retVal = [interimDict objectForKey:@"d"]; if ([retVal isKindOfClass:[NSDictionary class]] && [((NSDictionary *)retVal) objectForKey:@"results"]!=nil) { return [((NSDictionary *)retVal) objectForKey:@"results"]; } else { return retVal; } } else { NSLog(@"Error: unable to complete web request because - %@",[internalError localizedDescription]); if (error!=nil) *error=internalError; return nil; }}

If result is a list, an NSArray

of NSMutableDictionary’s is

returned.

Otherwise, an NSMutableDictionary is returned.

Wednesday, February 13, 13

Page 29: Tulsa Dev Lunch iOS at Work

+(BOOL) insertEntity:(id) entity entityName:(NSString *)entityName error:(NSError **) error{ NSString *urlString = [SyncHelper urlStringForEntity:entityName]; // Turns “EntityName” into “http://yourserver/service.svc/EntityName NSURL *url = [NSURL URLWithString:urlString]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];

[request setHTTPMethod:@"POST"];[request addValue:@"Application/json" forHTTPHeaderField:@"content-type"];[request addValue:@"Application/json" forHTTPHeaderField:@"accept"];[request setValue:@"gzip" forHTTPHeaderField:@"Accept-Encoding"];

if (HTTP_USER!=nil && [HTTP_USER length]>0 && HTTP_PASSWORD!=nil && [HTTP_PASSWORD length]>0) { NSString *authStr = [NSString stringWithFormat:@"%@:%@",HTTP_USER,HTTP_PASSWORD]; NSData *authData = [authStr dataUsingEncoding:NSASCIIStringEncoding]; NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodingWithLineLength:80]]; [request setValue:authValue forHTTPHeaderField:@"Authorization"]; } NSError *internalError = nil; NSData *payload = [NSJSONSerialization dataWithJSONObject:entity options:NSJSONWritingPrettyPrinted error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } [request setHTTPBody: payload]; NSHTTPURLResponse *response = nil;

internalError = nil; NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } NSString *responseStatus = [NSHTTPURLResponse localizedStringForStatusCode:[response statusCode]]; if ([response statusCode]!=201) { if (error!=nil) *error = [NSError errorWithDomain:ERROR_DOMAIN code:100

localizedDescription:[NSString stringWithFormat:@"HTTP ERROR (%i) %@",[response statusCode],responseStatus]]; } return [response statusCode]==201; }

Inserting New Data

Wednesday, February 13, 13

Page 30: Tulsa Dev Lunch iOS at Work

+(BOOL) updateEntity:(NSMutableDictionary *)entity forKeys:(NSArray *)keys error:(NSError **) error { NSDictionary *metadata = [entity valueForKey:@"__metadata"]; if (metadata==nil) return NO; NSURL *url = [NSURL URLWithString:[metadata valueForKey:@"uri"]]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; [request setHTTPMethod:@"POST"];

[request addValue:@"MERGE" forHTTPHeaderField:@"X-HTTP-Method"]; [request addValue:@"Application/json" forHTTPHeaderField:@"content-type"];[request addValue:@"Application/json" forHTTPHeaderField:@"accept"];[request setValue:@"gzip" forHTTPHeaderField:@"Accept-Encoding"];

if (HTTP_USER!=nil && [HTTP_USER length]>0 && HTTP_PASSWORD!=nil && [HTTP_PASSWORD length]>0) { NSString *authStr = [NSString stringWithFormat:@"%@:%@",HTTP_USER,HTTP_PASSWORD]; NSData *authData = [authStr dataUsingEncoding:NSASCIIStringEncoding]; NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodingWithLineLength:80]]; [request setValue:authValue forHTTPHeaderField:@"Authorization"]; } NSMutableDictionary *payloadDict = [NSMutableDictionary dictionaryWithCapacity:10]; [payloadDict setValue:metadata forKey:@"__metadata"]; for (NSString *key in keys) { [payloadDict setValue:[entity valueForKey:key] forKey:key]; } NSError *internalError = nil; NSData *payload = [NSJSONSerialization dataWithJSONObject:payloadDict options:NSJSONWritingPrettyPrinted error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } [request setHTTPBody: payload]; NSHTTPURLResponse *response = nil;

internalError = nil; NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } NSString *responseStatus = [NSHTTPURLResponse localizedStringForStatusCode:[response statusCode]]; if ([response statusCode]!=204) { if (error!=nil) *error = [NSError errorWithDomain:ERROR_DOMAIN code:5000

localizedDescription:[NSString stringWithFormat:@"HTTP ERROR (%i) %@",[response statusCode],responseStatus]]; } return [response statusCode]==204;}

Updating Data

Wednesday, February 13, 13

Page 31: Tulsa Dev Lunch iOS at Work

What to Do With an NSMutableDictionary?

1.) Parse into proper objects

2.) Create a wrapper object that stores NSMutableDictionary internally

3.) Use Objective-C Categories to add field-like methods to NSMutableDictionary

But don’t just [object objectForKey: @“propertyName“]

Wednesday, February 13, 13

Page 32: Tulsa Dev Lunch iOS at Work

//// NSMutableDictionary+Customer.m// Yale Cleaners//// Created by Matt Galloway on 8/31/12.// Copyright (c) 2012 Architactile LLC. All rights reserved.//

#import "NSMutableDictionary+Customer.h"

@implementation NSMutableDictionary (Customer)

#pragma mark - Customer Custom Getters

-(NSString *) mobileNumber { return [self filteredObjectForKey:@"Mobile_no"];}

-(NSString *) sendEmail { return [self filteredObjectForKey:@"SendEmail"];}

-(NSString *) sendReceipt { return [self filteredObjectForKey:@"SendReceipt"];}

-(NSString *) sendText { return [self filteredObjectForKey:@"SendText"];}

-(NSString *) username { return [self filteredObjectForKey:@"User_Name"];}

-(NSString *) uri { return [self filteredObjectForKey:@"uri"];}

-(NSString *) address { return [self filteredObjectForKey:@"address"];

-(NSString *) area { return [self filteredObjectForKey:@"area"];}

-(NSString *) charge { return [self filteredObjectForKey:@"charge"];}

-(NSString *) city { return [self filteredObjectForKey:@"city"];}...

Cate

gory

Exa

mple

Wednesday, February 13, 13

Page 33: Tulsa Dev Lunch iOS at Work

Use HTTPS +

Authentication

(at a minimum)

Wednesday, February 13, 13

Page 34: Tulsa Dev Lunch iOS at Work

Local Data Store?

Meet SQLite &

CoreData

Wednesday, February 13, 13

Page 35: Tulsa Dev Lunch iOS at Work

CoreData is one of iOS’s

greatest advantages over

Android for business apps.

Wednesday, February 13, 13

Page 36: Tulsa Dev Lunch iOS at Work

PrtmaryK_e_;y __ --1

OJeCt Pr • AttllbU folderNam j sonl.ast'li name: proJectld syn<Corn

IC!S e

~ocM1ed

!)lete nships Re ~l i O

draw-ng­elementRe

ypes qul rements fc::

1

• Anr butt~ hc: dlypc: JSOnW~t~Od i fied loatoorMet~DJ.tJfield ld ,~e

pockl stCho ces required • Rclattonships location~c:tt~Oata <

--:

Louuonvet.lO.lt.l-, Annb~o~tcs

jsonlilst Y.od I fled ocatton~ctOJDiltald vo~lutBool

v.llueno~t

VJ.Iuelnt value Text

Rc ta11onsh1 ps

!ocatton

-

-.;

C Locat_oo_n __ ----,~ 19 Auro butes OJddrusl .lddreu2 City folde r'\ arne htghCr.mc:Locat.on J S.onuu~od oticd

loutoOf'lld loutlonNu m ber l'ame state ~urveyCiodT me surveyor sut\leySct'leduled-IIM

:~:~::~:d~~ cotN\ple-Le syt'I(Complete I J ' ~ 2op

Re :ltoonshlps >dto'lwings Elemc:ntRc:qu rc:rrc:f'll

e emet~tRequiremc:nu Anrobutes D•aw r.g ::__j 0r.Jwingiy_JX ___ 1 • Attribute~ ·--..-j~ ~----+ locattonl\'etaOata e ementRequirementld

Attributes 1---------drolw n!lld project JSOt'll...lS tMOCifled dr.w,,ngTypeld fa en~rne m.lXAllcw.ed JSOnl...lstMOOif1ed m nRc:qu•rcd name heoght Relauonshops

Re atton~h ps JSOf'll.astMod•f1ed ,.----+-----------1-- ,..> drav. n!jType

c:oc:mc:ntRcquirc:mcnts scale 1,..:::::1

OementTyoe E ementCroup 9 Annblltes

dril'Wings A::llamc: i---1-~---+----------+--+~c: cmc:ntTypc

c c:mentType5 wlctth l := ===------:!-------------lL...--~~~ocatoon ----'

ects J Re ·" •onship~ d ~ !---t-~PI'OJ ____ __,. d t.lWlngTypt "

• Ann b"tc:s c:lememTypeld elementGroupld ,conl tlename JsonLut'-1oct'lc:d Jsonl..ut'Aocif,c:d n;amc: narrc: sortOrder Rc: o'!t on\hops

Rel.'lt on·""s~h"'t pc;s===i drilv.ongTypu /\'.,.» elementCroup

c:lementQ .. estlons c:lementRequ lremen ts c:lc:mc:nts

~ Eltmc:ntQue stlon

Attrobutes

eemenu _ 1,_ ~ t.~ ~ cc Element l ess

AUt ib'->ttS dra\~o, ng(()Q(dX 1\.J ~;::·~~;=~:n V\ er w ·,t. h elementld clcmentll.umber

jsonl..ut'-1oditied l c: c:mc:ntQI.esbonld nelpPnotofo ename

n.1me ~ 'f RelatoOnSh pS m 0 t. ~~ s !-------\c:iementAnswc:rs no help-tltt

nchesMax nches\4ln json~t\lod • tied pnoto.AI owNOte~

:>hotoM.1xCount ohotoM nCount photoRc:qu rel'l.otcs p cl(L siC no cc:s quest Of'ITtxt quest on- 'fpe required sortOrdc:r tc:xtMaxlength

Rc: iltooqhips

E c:mentAnswc:r Auro butes

OJnswc: rlnt .'lnswerText created On e emen!An~"erld gpslatttudc: gpslong tude jsonLmVoct ofoed IJ.stl'-'\od ned

Re iltoons"'lps e ement

0 emc ntA.11s"'..: rPhoto Aur iba.tes

crc;atcdOn etementAnswerPhotold gpsUtotude

gpslottgltude J t'ludong jsonLOJst'Aodoficd .'lnMod fied ~otorden~e

photoNotts

Code ~nd no

SQL •

: :::~~~;~;:rs "<;_-------------------,fmentAn~"-C!rPhotos +u

Wednesday, February 13, 13

Page 37: Tulsa Dev Lunch iOS at Work

To Recap...

Android sucks.

Mobilize your web assets.

Consider the mobile web first.

Use RESTful APIs.

Avoid SQL & SOAP.

CoreData is way worth it.Wednesday, February 13, 13

Page 38: Tulsa Dev Lunch iOS at Work

Matt Galloway

(Freelance Mobile Developer Extraordinaire)

[email protected]

918-808-3072

Wednesday, February 13, 13