← Portfolio

Appetize

An iMessage extension that settles where a group eats, inside the thread they are already in.

Swift · SwiftUI · Messages · MapKit · Django · Postgres · AWS App Runner · Claude 3.5 Sonnet · Yelp Fusion · 2024

Opening Appetize from the iMessage app drawer and setting up an event.

The host sets constraints and sends one message bubble. Everyone in the chat swipes on nearby restaurants from inside it, and when the last person finishes, the bubble becomes the answer.

It is an iMessage app extension over a Django API. Nothing to install, no link to open, no leaving the conversation.

Client

The extension is a stack of MSMessagesAppViewControllers. Hosting, profile, and dietary settings are UIKit controllers off a storyboard, while the swipe deck and the result screen are SwiftUI views wrapped in UIHostingController. Splitting it that way kept the card gestures, the loading states, and the result animation declarative without rewriting the navigation that Messages already dictates.

The host screen reads three sliders and the MKMapView region, converts the visible span into a radius in miles, and mints a random 64-bit event id. That event ships as an MSMessage on a fresh MSSession, with the id, formality, price, time, and centre coordinate packed into the message URL's query string. The session is the whole trick: every later insert replaces the same bubble instead of stacking a new one, so a multi-person decision occupies a single row of the thread from setup to result.

Sign in with Apple runs once through ASAuthorizationController. The returned identifier goes into the keychain and becomes the user's primary key for every request after that, so nobody types a password inside a message extension.

Backend

Django REST Framework over Postgres on AWS App Runner. Four models carry everything. User is keyed by the Apple identifier and stores diet, price ceiling, and transportation as JSON so new options do not need a migration. Event is keyed by the id in the message URL and is many-to-many to both attendees and restaurants, with a separate swiped set and a cached final recommendation. Restaurant is a flattened Yelp business keyed by its Yelp id. Preference is one row per swipe, holding a +1 or a -1.

Creating an event writes the row, queries Yelp Fusion around the coordinate, drops anything outside the radius, and persists each business joined to that event. Knowing when the group is done is a set comparison rather than a job: the swipe endpoint returns the roster of people who have finished, and the client checks it against the conversation's participant count to recognise the last swiper.

Ranking

Two Claude 3.5 Sonnet calls run through LangChain, both with structured output against a Pydantic schema. The first runs per person and ranks the event's restaurants against that user's diet, budget, and transport, returning five with a one-line pro and con. That is the swipe deck, and it is what makes fifty nearby restaurants worth swiping through at all.

The second runs once, triggered by the last swiper, over only the restaurants somebody actually swiped on. It reconciles every attendee's likes and dislikes into three ranked picks with reasons, then caches the result on the event so reopening the bubble replays it instead of paying for another generation. Restaurants enter both prompts as small integer ids and are mapped back afterwards, which keeps the prompts short and turns a hallucinated id into a lookup miss instead of a plausible wrong answer.

Screens

The Appetize hosting screen with formality, price, and time sliders above a map
Hosting. Three sliders and a draggable map region.
An Appetize restaurant card showing a dish photo, rating, tags, and pros and cons
Swiping. Each card is scored against your own constraints.
The Appetize result screen presenting a chosen restaurant with call and map buttons
Result. The pick, the reason, and a way to act on it.

Limits

Completion is detected on the client, against the participants the extension can see, so a group where one person never opens the bubble stalls with no timeout. State is pulled on open rather than pushed, which means progress is invisible until you tap in. Yelp caps the search radius at 40km, generous in a city and thin outside one.