Engineering

How iPerspective is built

iPerspective is a social app that places people in small rooms based on how they answer questions. I built all of it myself: the Swift prototype, the Flutter app shipping on iOS and Android, and the Firebase and Python backend that forms rooms, schedules questions, and sends notifications. This page covers the architecture and the decisions I'd want to talk through.

iOS + AndroidLive on the App Store and Google Play from one codebase
130k+Lines of Dart across 30 feature modules
v1.4.4Current release, build 144
1 developerProduct, design, mobile, backend, and releases
The stack

What it runs on

Mobile app

  • Flutter and DartOne codebase for iOS and Android
  • RiverpodState management and dependency injection
  • go_routerNavigation and deep-link routing
  • Swift rootsStarted as a native Swift app. Shared helpers kept in parity, like the brand colors

Backend

  • Cloud FirestoreSecurity rules are the authorization layer
  • Python 3.12 Cloud FunctionsHTTP, callable, scheduled, and Firestore-triggered
  • Cloud SchedulerRoom formation and an hourly game scheduler driven by a JSON timetable
  • Firebase Auth and StorageEmail, Google, and Sign in with Apple. Profile and community photos

Platform and release

  • Push notificationsFirebase Cloud Messaging through APNs on iOS
  • Universal Links and App LinksAssociated Domains, with the association files hosted on this site
  • SubscriptionsRevenueCat paywall on StoreKit and Play Billing, shipped behind a remote flag
  • Crashlytics and AnalyticsPlus Python admin, audit, and deploy scripts
Architecture

How the pieces talk

The app reads and writes Firestore directly, with security rules enforcing who can see what. Anything that needs a global view of users, like forming rooms or scheduling questions, runs server-side in Python.

Swipe sideways to see the whole diagram.

Decisions

Problems worth talking about

Six decisions that shaped the app, with the tradeoffs behind them.

Data model

One write path for every answer

People answer questions in a lot of places: daily questions, community questions, games, and polls shared into a chat. If any of those kept its own table, every comparison in the app would drift from the truth by however much people played that game.

So every answer goes through a single method, QueueService.answer(), which writes the response together with a snapshot of the person's communities at the moment they answered. An earlier version didn't take that snapshot, which made comparing communities after the fact impossible. That lesson shaped this design.

QueueService.answer()
  ├─ users/{uid}/queue/…          // marks it answered
  ├─ contentAnswers/…/{uid}       // response + communities at answer time
  ├─ users/{uid}/answers/…        // the person's ledger
  └─ users/{uid}.identity.*       // becomes a matching axis

A new game is a new screen, not a new data model, and comparisons stay correct wherever people answer.

Matching

Hold everything, split on one thing

Every room comes from one roster shape: fields held constant for everyone, plus at most one field allowed to vary. "Ten Catholics in Ohio" holds religion and location. "Five men and five women, all nurses in Ohio" holds occupation and location and splits on gender. Control every variable except the one you're comparing, and the result means something.

hold   {occupation: "nursing", state: "OH"}    # same for everyone
split  "gender"                                # the one variable
groups ["male", "female"]                      # 5 and 5

Seven fields have 127 possible combinations and almost all are empty, so instead of enumerating them the builder scans what's actually present in the population. Any identity question becomes a matching field automatically, which means adding a new matching dimension is just writing a question. No migration and no deploy.

Scoring favors splits over holds, because relatability is a shared context plus a difference inside it, not sameness.

Product judgment

When the data said no

The first design built a group of people first, then picked a question for that group. With a few dozen early users, and only about one in five profiles listing a gender, that approach produced a poll that reached four people.

Rather than wait for scale, I flipped the order. A question lands in your queue because it matches a community you're in. You answer. Then you see up to five people who answered the same way. Answer-first works with one user.

The roster engine still exists and gets more useful as the community grows. The product works today at the size it actually is.

Notifications

Push that respects people

Direct messages use a request model. Anyone can send a first message, and it waits in the recipient's requests inbox until they accept. While a thread is pending, the function writes one notification document, which shows in the bell and is pushed by a single shared sender, so nobody gets two banners for one event. Once accepted, messages push directly with no bell entry, so a busy chat can't bury everything else.

Scheduled jobs group per person. Rooms in a cycle are created and expire together, so someone in six rooms used to get six pushes in the same minute. Now each run collects rooms per user first. One room keeps specific wording and deep-links to that room. Several rooms get one counted digest that lands on the home screen.

One push per event per person.

Security

Authorization lives in the rules

Circles are private group threads created from a shared answer: you and friends who picked the same option. Membership is the security model. If your uid is in participants, you can read and write. If it isn't, you can't tell the circle exists.

The rules also enforce a floor of two participants, so a circle can't be used as a one-on-one chat that skips the message-request gate. Circle IDs are deterministic, a_{contentId}_{optionId}_{uid}, so tapping "Start a circle" twice reopens the thread instead of creating a duplicate.

Tradeoff I chose on purpose: reading a circle that doesn't exist returns permission-denied instead of empty, so the client treats that error as "no circle" rather than leaking which circles exist.

Shipping

When the tooling broke

Deploys. The Firebase CLI hard-coded a legacy service account name that didn't exist on my project, which blocked every function deploy. I wrote a deploy script that calls gcloud functions deploy directly with explicit runtime, trigger, and build service accounts.

A name collision. A backend module called queue.py shadowed Python's standard library. urllib3 imports queue, so requests and firebase-admin failed with a circular-import error that named none of them. Renamed to poll_queue.py, with a comment so nobody renames it back.

Slow iOS builds. Builds to a physical iPhone had become painfully slow. The causes: Xcode's DerivedData on an aging external drive with a corrupted build database, a denied Local Network permission, and wireless debugging timeouts. Moving DerivedData to the internal SSD and fixing permissions brought build times back to normal.

For iOS teams

Where iOS fits in

iPerspective started as a native Swift app before I moved it to Flutter to reach Android. The iOS side is still where most of the platform work lives.

Swift first

The original prototype was written in Swift. My iOS background covers Swift, UIKit, MVVM, and Combine, and the Flutter code keeps parity with Swift helpers where it matters.

Shipped through the App Store

Signing, entitlements, App Store Connect, review, and ongoing releases for a live app with real users.

Platform integrations

Sign in with Apple, push through APNs, Universal Links with Associated Domains, and a StoreKit subscription paywall that ships dark behind a remote config flag.

Build and tooling

Xcode, CocoaPods, and debugging on physical devices, including tracking down the build slowdown described above.

Want to talk through any of this?

I'm happy to walk through the code, the data model, or the product decisions.