Back to work
Pre-release (signed AAB; awaiting store submission)2025–2026Founder & Mobile Engineer80% complete

DarkHorseStocks Mobile App

React Native + Expo companion app for DarkHorseStocks — Google/Apple sign-in, FCM push, cache-first sync, HTML-to-accordion blog renderer, signed AAB ready for Play Store.

Summary

The DarkHorseStocks mobile app is a React Native (Expo 51) companion to the web platform, built over 7 months and currently sitting at a signed release AAB ready for Play Store submission. It implements Google Sign-In + Apple Sign-In, a 4-tab navigation (Dashboard / Sectors / Tags / Explore) with a stack of modal screens (Login, Profile, Plans, BlogDetail, Search, Notifications, Contact, Account Deletion), Firebase Cloud Messaging push with platform-specific permission flows (mandatory on Android, optional on iOS per Apple Guideline 4.5.4), a custom HTML-to-accordion blog renderer that parses backend posts into collapsible H2 sections without using a WebView, and a cache-first sync layer over AsyncStorage with a background `PreloadService` that warms the cache on launch. The Plans screen is intentionally UI-only — in-app purchase backend integration is deferred.

Target user

Existing DarkHorseStocks subscribers (and prospective ones) who want a phone-native way to read curated stock ideas, get push alerts when a new blog drops, browse companies by sector/tag offline-friendly, and manage their subscription — without firing up the web dashboard.

§ 01Stack
01Primary
React NativeExpoReactJavaScript
02Infrastructure
EAS BuildGradleApple App Store ConnectFirebase project darkhorsestocks-689c5Expo Application Services
03Integrations
Firebase Cloud MessagingGoogle Sign-InApple Sign-InDarkHorseStocks REST APIExpo Notifications
04UI / Frontend
NativeWindTailwindCSSLucide React NativeClass Variance Authorityreact-native-render-htmlreact-native-svgCustom HTML parser
§ 02Key features
  1. 01

    Shipped a signed Android release build (`darkhorsestocks-release.aab`, 31.3 MB, signed with `darkhorsestocks-release.keystore` via `darkhorsestocks-key-alias`) plus an iOS production profile in EAS with `ascAppId 6756486172` configured for App Store submission — both reproducible via `npm run prebuild:production` + `npm run android:release` / EAS submit.

  2. 02

    Built a custom HTML-to-accordion blog renderer (`src/utils/htmlParser.js` + `CustomAccordion`) that parses backend blog posts (HTML strings from `/api/blog-posts/:id`) into an AST of H2-anchored sections, then renders each section as a collapsible accordion with `react-native-render-html` for the rich inner content — sidestepping WebView startup cost and giving the UI native scroll progress tracking.

  3. 03

    Implemented Firebase Cloud Messaging with platform-specific permission UX: on Android the app shows a hard-blocking `NotificationBlockScreen` if the user denies permissions (notifications are mandatory for the product), and on iOS the prompt is non-blocking to comply with Apple Guideline 4.5.4 — gated through a single `NotificationConfig` module that maps `Platform.OS` to the requirement level.

  4. 04

    Designed a cache-first sync architecture: a `PreloadService` runs on app launch to warm AsyncStorage with homepage sections, sectors, tags, and recent companies in the background, then a `SyncService` hands components cached data instantly and refreshes from `/api/*` in parallel — 13 modular cache modules (`/src/services/cacheService/*`) handle auth, blogs, companies, sectors, tags, homepage, and management state with explicit TTL policies.

  5. 05

    Wired Google Sign-In with separate web + platform-specific client IDs (so the iOS bundle, Android package, and the web ID share a Firebase project but get the right OAuth token for each surface) plus Apple Sign-In via `expo-apple-authentication` on iOS only — both flows feed a single backend JWT exchange and AsyncStorage-backed session restore.

  6. 06

    Built an in-app broadcast notification feed (timeline format, infinite scroll, read-state tracking against the backend's `UserNotificationRead` join table) wired through a dedicated `NotificationContext` that subscribes to FCM events, updates a badge counter, and renders foreground notifications inline instead of via the system tray.

  7. 07

    Implemented a version-gating system (`VersionService` + `/api/app-version/check`) that compares the installed app version against a server-side minimum and force-routes the user to the Play Store update URL if they're below floor — so the team can deprecate breaking API changes without supporting indefinite older clients.

  8. 08

    Wrote dev-experience tooling — `scripts/update-ip.js` auto-detects the host machine's LAN IP and rewrites `.env` so emulator-to-backend connectivity survives Wi-Fi network changes; `npm run start:fresh` chains IP detection + backend health check + cache clear + Expo start so a single command always boots into a known-good state.

§ 03Hardest problems
  1. The backend stores blog bodies as HTML strings (legacy from the web frontend). Dropping that into a React Native WebView gave 600–1200ms cold-start before paint, broke scroll progress tracking (WebView scroll events are sandboxed), and didn't respect the app's dark mode. Solved with a custom parser in `src/utils/htmlParser.js` that uses `htmlparser2` to walk the DOM, partitions on `<h2>` tags into sections, and emits an AST of `{ title, contentHtml }` chunks. Each chunk is rendered through `react-native-render-html` inside a `CustomAccordion`, so the outer scroll view is native (with throttled-16ms progress bar updates feeding `BlogProgressContext`) and the inner rich text is rendered RN-natively with theme-aware colors. Paint is instant, scroll progress works, dark mode just works.

  2. Notifications are mandatory for this product on Android (the entire alert flow assumes push delivery) but Apple Guideline 4.5.4 forbids gating core app functionality on push permissions. Two codepaths is the obvious failure mode — they drift. The solve is a single `NotificationConfig.REQUIRE_NOTIFICATIONS` flag computed from `Platform.OS`, consumed by `App.js` which conditionally mounts a `NotificationBlockScreen` only when `Platform.OS === 'android' && !granted`. The notification service itself, the FCM token registration, the foreground handler, the background handler, the channel setup — all single codepath. Only the UX gate is platform-conditional, which means iOS and Android can't diverge in behavior unintentionally.

  3. The backend runs at `http://localhost:8000` for dev, which is meaningless from an emulator/device — they need the host LAN IP. Hardcoding the IP works until you change Wi-Fi networks; setting it manually in `.env` works until you forget. Solved with `scripts/update-ip.js` which uses `os.networkInterfaces()` to find the first non-internal IPv4 address and rewrites `.env` in-place, plus `scripts/check-backend.js` that hits a health endpoint at the new IP before Expo boots — so `npm run start:fresh` is a single command that always produces a working dev loop regardless of network. Production builds read from `.env.production` and point at `https://backend-reports.darkhorsestocks.in`, with the environment switch happening in `src/config/environment.js`.

§ 04What I learned
  • L01
    Tailwind-via-NativeWind beats a component library for a small RN app.

    The temptation was to pull in NativeBase or Tamagui, but every component library has friction (theme provider boilerplate, opinionated variants, breaking changes across majors) and the actual surface area of this app is small enough that 65 custom components + utility classes + a Class Variance Authority shim for variants ended up being less code and faster to iterate on than a library-based approach would have been.

  • L02
    Cache-first with background preload is the right default for a content app.

    The first version fetched on every screen mount, and the cold-screen-mount latency (~400–800ms over a slow connection) made the whole app feel sluggish. Switching to cache-first — `SyncService` hands components the cached payload synchronously, then revalidates from the network in the background — made screens feel instant even on cold launches, with the `PreloadService` warming the cache during the splash so the user's first interaction is a cache hit.

  • L03
    Honest scoping beats overscoping.

    The Plans screen is hardcoded UI with a `Coming Soon` alert on the CTA because in-app purchase (RevenueCat / native StoreKit / Play Billing) is a real piece of work — bridging product IDs to the existing web Subscription table, handling restore-purchase flows, and dealing with App Store/Play Store policy review — and it's worth shipping the rest of the app rather than blocking on it. The `linkedin_blurb` and resume bullets reflect that honestly: monetization is deferred, not done.

§ 05By the numbers
lines of code
~13332
commits
125
screens
14
contexts
6
components
~65
cache modules
13
notification submodules
8
build aab size mb
31.3
development span months
7
ios min deployment
15.0
expo sdk
51