Stock4Q 2.0

Stock4Q 2.0 is a rewrite to convert the original UIWebView based app to a native app. It took me much longer to get back to the rewrite; native apps were the new hip thing 2 years ago, but better late than never =).

Overview:

  • What is Stock4Q?
  • Screenshots
  • iOS Layouts
  • Mobile Analytics

Stock4Q is a clean and elegant way to manage stock portfolios. The intuitive design and clean interface is meant to enrich your mobile experience with a singular aim - “real-time stock quotes at your finger tips”. Stock4Q provides streaming stock quotes and portfolio tracking. Create multiple portfolios and track gains and losses in real-time. Control the frequency of stock quotes (1sec, 2sec, 5sec or more), set low/high price triggers to enable active highlighting within portfolio. More details at https://www.stock4q.com and you can view demo video at https://youtu.be/KpJP5YiMThU

  • Real-time stock quotes
  • Refresh rate of 1sec, 2sec, 5sec or more
  • Sort portfolio by symbol, price, change, %change
  • Track absolute and percentage gains/losses
  • View trading volume, market value, gains, and more
  • 1-click access to quotes, charts & news from portfolio
  • Manage multiple portfolios
  • Active real-time green/red highlighting based on buy/sell price

Screenshots

Yes, the native app is faster, more responsive, and the animation in charts is a lot better compared to DOM based SVG rendering. My biggest gripe is with “layouts” in iOS native. Having used HTML/CSS/JS for more than a decade, the current state of layouts in iOS is like HTML tables in 2000 where it looked like the entire web was being build on nested tables (hey it worked, I even wrote a DAG implementation using HTML tables with CSS borders). I tried very hard to use autolayout in VFL but it is very limiting and what works in iOS8 is broken in iOS9. In the end only the login and sign-up screeens are implemented using VFL and all the other pages are good old UITableViews with dynamic layout.

Stock4Q iOS app needs to support both iPhone and iPad, multiple screen sizes for different iPhone/iPad models, and portrait and landscape modes. All this adds up to a lot of screen sizes. I ended up with a couple of common layout patterns:

  1. Percentage width based field/label/image
  2. Dynamic number of columns based on screen width

The “percentage width” based layout works for most screens where I use form and block elements have width relative to screen width and their horizontal placement is usually horizontal center (calculated dynamically for each element). This also works for fixed width element like form fields and buttons where you don’t want your iPad buttons to become too stretchy. The “dynamic column” layout is used for grid like in real-time stock quotes screens which has a total of 8 columns but there is progressive disclosure based on screen width where iPhone4 displays 4 columns in portrait mode, iPhone 5 gets 6 columns in landscape mode and iPads get 8 columns in both portrait and landscape.

// get responsive x-bounds based on screen width
+ (CGRect)getResponsiveXBounds {
    float ratio = 0.8;
    CGSize sz = [SqUtil getScreenSize];
    float totalWidth = ([SqUtil isPreIOS8] && ![SqUtil isPortrait]) ? sz.height : sz.width;
    float width = ratio * totalWidth;
    float x = (totalWidth - width) / 2;
    return CGRectMake(x, 0, width, 0);
}

// dynamic resizing and placement of screen elements
- (void)viewDidLayoutSubviews {
    [super viewDidLayoutSubviews];

    // calculate and set component width based on screen width
    CGRect xbounds = [SqUIHelper getResponsiveXBounds];
    float baseH = [SqUIHelper getDefaultTextFieldHeight];

    // set the frame for textfield
    _field.frame = CGRectMake(xbounds.origin.x, kVPad, xbounds.size.width, baseH);
}

// determine width ratio and number of columns
- (void)initColumnFrames:(CGRect*)rects {
    float screenWidth = [[UIScreen mainScreen] bounds].size.width;
    float widthRatio = .9;
    int numCols = 4;                    // iPhone - portrait
    if (screenWidth > 1000) {           // iPad - landscape
        numCols = 8;
        widthRatio = .85;
    } else if (screenWidth > 750) {     // iPad - portrait
        numCols = 8;
    } else if (screenWidth > 700) {     // iPhone 6 Plus - landscape
        numCols = 7;
    } else if (screenWidth > 550) {     // iPhone 5,6 - landscape
        numCols = 6;
    } else if (screenWidth > 420) {     // iPhone 4 - landscape
        numCols = 5;
    }
    ...
}

In the first version on Stock4Q app I’d used homegrown click analytics that captured events on each screen, aggregated and stored them locally in iOS SQLite db and submitted them to stock4q REST APIs in nightly batch windows. This way my stock4q server would not get overwhelmed with millions of REST API calls and I still get detailed mobile app events. Its not a scalable platform for mobile analytics as adding new events implies adding more page IDs in mobile app and REST API, but given the overall simplicity of the solution and the fact that I have only added a couple of new screens in the iOS app over the last 4 years , there is no beating the built in reports using SQL queries in my server database. Another big benefit is the fine-grained user tracking with tie-back to portfolios that is not possible with anonymous event tracking provided by common analytics platforms because of iTunes Store policy surrounding that.

In Stock4q version 2.0 I added single sign-on with Facebook and FB has done an awesome job with having a SDK that is simple and just works. I didn’t even realize that mobile analytics was turned on automatically in FBSDK. I included the CoreKit.framework file and the AppEvents api was neatly hidden in there. I just need to add 1 line of code to start logging screen events and events do show up in Facebook Mobile Analytics dashboard in real-time. This is what impressed me, I’ve used Google Analytics for more than a decade and its the standard for website tracking but real-time events are not its forte. The real-time event analytics in Facebook SDK actually work in real-time and I used it during the time of App Development in Simulator as well. You can track app installs as well as events. By default Facebook will allow you to uniquely identify a user using IDFA (Apple Advertising Identifier), but at the time of app submission to iTunes Store there is an explicit question asking if the app uses IDFA for anything besides ads and that nixed the use of IDFA for me as I’m not using FB Ad platform. I didn’t use it but looks like FB Mobile Ads are nicely baked into the SDK, no wonder their mobile revenue is shooting up =).

Note to Developers: If you’re using FBSDK for app events only, you need to login to developer.facebook.com, go to Settings and set a value of “No” for the question “Collect IDFA with App Events”. Otherwise your app submission may be rejected by iTunes Store.

/*
    --- possible values for MACHINE-INFO ---
     @"i386"      on 32-bit Simulator
     @"x86_64"    on 64-bit Simulator
     @"iPod1,1"   on iPod Touch
     @"iPod2,1"   on iPod Touch Second Generation
     @"iPod3,1"   on iPod Touch Third Generation
     @"iPod4,1"   on iPod Touch Fourth Generation
     @"iPhone1,1" on iPhone
     @"iPhone1,2" on iPhone 3G
     @"iPhone2,1" on iPhone 3GS
     @"iPad1,1"   on iPad
     @"iPad2,1"   on iPad 2
     @"iPad3,1"   on 3rd Generation iPad
     @"iPhone3,1" on iPhone 4 (GSM)
     @"iPhone3,3" on iPhone 4 (CDMA/Verizon/Sprint)
     @"iPhone4,1" on iPhone 4S
     @"iPhone5,1" on iPhone 5 (model A1428, AT&T/Canada)
     @"iPhone5,2" on iPhone 5 (model A1429, everything else)
     @"iPad3,4"   on 4th Generation iPad
     @"iPad2,5"   on iPad Mini
     @"iPhone5,3" on iPhone 5c (model A1456, A1532 | GSM)
     @"iPhone5,4" on iPhone 5c (model A1507, A1516, A1526 (China), A1529 | Global)
     @"iPhone6,1" on iPhone 5s (model A1433, A1533 | GSM)
     @"iPhone6,2" on iPhone 5s (model A1457, A1518, A1528 (China), A1530 | Global)
     @"iPad4,1"   on 5th Generation iPad (iPad Air) - Wifi
     @"iPad4,2"   on 5th Generation iPad (iPad Air) - Cellular
     @"iPad4,4"   on 2nd Generation iPad Mini - Wifi
     @"iPad4,5"   on 2nd Generation iPad Mini - Cellular
     @"iPhone7,1" on iPhone 6 Plus
     @"iPhone7,2" on iPhone 6
*/
+ (NSString*)getDeviceInfo {
    // get machine info
    struct utsname systemInfo;
    uname(&systemInfo);
    NSString *machineInfo = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding];

    // get screen size
    CGSize screenSize = [SqUtil getScreenSize];

    // get os version
    NSString *osVersion = [[UIDevice currentDevice] systemVersion];

    // get current region and lang
    NSString *region = [[NSLocale preferredLanguages] objectAtIndex:0];
    NSString *lang = [[[NSBundle mainBundle] preferredLocalizations] objectAtIndex:0];

    // form device info string
    return [NSString stringWithFormat:@"machine=%@;os=iOS;osver=%@;w=%i;h=%i;region=%@;lang=%@",
            machineInfo, osVersion, (int)screenSize.width, (int)screenSize.height, region, lang];
}

// save event object in SQLite database as JSON clob
+ (BOOL)saveObject:(NSString *)objectName :(NSString *)objectJson {
    BOOL success = FALSE;
    sqlite3 *smDb;
    sqlite3_stmt *stmt;
    [SqUtil logDebug:@"SqSqlDb.saveObject: kSqDbPath = %@", kSqDbPath];

    if (sqlite3_open([kSqDbPath UTF8String], &smDb) == SQLITE_OK) {

        //find object ID, if it already exists
        int existingId = -1;
        if (sqlite3_prepare_v2(smDb, kSqDbSelectId, -1, &stmt, NULL) == SQLITE_OK) {
            if (sqlite3_bind_text(stmt, 1, [objectName UTF8String], -1, SQLITE_STATIC) == SQLITE_OK) {
                if (sqlite3_step(stmt) == SQLITE_ROW) {
                    existingId = sqlite3_column_int(stmt, 0);
                }
            }
            sqlite3_finalize(stmt);
        }

        //insert or update object json
        NSString *data = objectJson;
        int prepared = 0;
        if (existingId == -1) {
            if (sqlite3_prepare_v2(smDb, kSqDbInsertObj, -1, &stmt, NULL) == SQLITE_OK) {
                prepared = 1;
                if (sqlite3_bind_int(stmt, 1, kSqDbObjVersion) == SQLITE_OK) {
                    if (sqlite3_bind_text(stmt, 2, [objectName UTF8String], -1, SQLITE_STATIC) == SQLITE_OK) {
                        if (sqlite3_bind_text(stmt, 3, [data UTF8String], -1, SQLITE_STATIC) == SQLITE_OK) {
                            prepared = 2;
                        }
                    }
                }
            }
        } else {
            if (sqlite3_prepare_v2(smDb, kSqDbUpdateObj, -1, &stmt, NULL) == SQLITE_OK) {
                prepared = 1;
                if (sqlite3_bind_int(stmt, 1, kSqDbObjVersion) == SQLITE_OK) {
                    if (sqlite3_bind_text(stmt, 2, [objectName UTF8String], -1, SQLITE_STATIC) == SQLITE_OK) {
                        if (sqlite3_bind_text(stmt, 3, [data UTF8String], -1, SQLITE_STATIC) == SQLITE_OK) {
                            if (sqlite3_bind_int(stmt, 4, existingId) == SQLITE_OK) {
                                prepared = 2;
                            }
                        }
                    }
                }
            }
        }

        if (prepared == 2) {
            if ([SqUtil isDebugMode]) {
                if (existingId == -1) {
                    [SqUtil logDebug:@"SqSqlDb.saveObject: NEW name:%@",objectName];
                } else {
                    [SqUtil logDebug:@"SqSqlDb.saveObject: EXISTING name:%@, id:%d",objectName,existingId];
                }
            }
            if (sqlite3_step(stmt) == SQLITE_ERROR) {
                if ([SqUtil isDebugMode]) {
                    NSString *err = [[NSString alloc] initWithUTF8String:sqlite3_errmsg(smDb)];
                    [SqUtil logDebug:@"SqSqlDb.saveObject: save failed name:%@ error:%@", objectName, err];
                }
            } else {
                success = TRUE;
                [SqUtil logDebug:@"SqSqlDb.saveObject: saved name:%@", objectName];
            }
        }
        if (prepared > 0) {
            sqlite3_finalize(stmt);
        }
        sqlite3_close(smDb);
    }
    return success;
}

// BaseController override so that every page logs events using FB
- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    if (_reportUsage) {
        [SqUtil logDebug:@"SqBaseController.viewDidAppear():: reporting usage for %@, pageId = %i", self.restorationIdentifier, _pageId];

        // log click event in FB
        [FBSDKAppEvents logEvent:[SqUsage getPageTitle:_pageId] valueToSum:1];
        ...
}