A resident logs what they see, it lands on the neighborhood map, and Foundation Models turns a cluster of reports into something worth sharing.
Overview
CommonSight helps Detroit neighborhoods document issues and organize around them. Residents log what they see, the app maps it, and an on-device model turns clusters of observations into story cards with context and a call to action.
I joined a codebase someone else had started and took over the full iOS layer: Firebase Auth, a Firestore schema scoped per neighborhood, Cloud Messaging for coalitions, MapKit, and the Foundation Models flow. Most of it was new to me going in.
The problem
A resident noticing rising property taxes or another empty storefront has nowhere to put that observation where it turns into anything.
Individual observations are a few sentences long and give no one a reason to care or act on their own.
Neighborhood data has to stay scoped to a neighborhood, syncing live across accounts without loading the whole city.
On-device AI has several distinct availability states, and any of them can leave a resident staring at a blank screen.
Approach
01
Auth first, then data
I got sign up, sign in, and session persistence working against real accounts before building anything on top, so the data layer was designed against real users from day one.
02
Scope the schema
Firestore is structured per neighborhood and the map subscribes only to what is relevant to the neighborhood in view, instead of pulling everything and filtering on the client.
03
Live map through listeners
Firestore listeners feed MapKit directly, so a submitted observation appears on the map without a refresh or a trip through a list view.
04
Narrative Alchemy
Selected observations go to Foundation Models and come back as a story card with a title, narrative, and call to action, shaped from messy resident input into something readable and shareable.
Technical highlights
Five availability states - The generator checks available, notEligible, notEnabled, modelNotReady, and unknown, each with its own message and a handcrafted fallback narrative so a card is always produced.
Neighborhood-scoped Firestore - Data is partitioned by community code, which keeps queries small and keeps one neighborhood out of another neighborhood results.
Cloud Messaging - Coalition campaigns and member updates run through Firebase Cloud Messaging, wired into the in-app coalition views.
Groundtruth reporting - A structured logging flow with category, location, and description, so observations arrive consistent enough for the model to work with.
Screens
Home Screen
Events, coalition spotlights, and your own submissions in one hub.
New Observation
Pick a category, drop a location, describe what is happening, submit.
Story Card
Raw observations become a narrative people can read and share.
Coalition View
Where scattered observations turn into organized action.
Code
On-Device AI Story Card Generation
Swift
Checks Apple Intelligence availability across five states, falls back gracefully to a handcrafted narrative when the model isn't ready, and assembles a StoryCard before persisting it to Firestore.
@MainActor
func createStoryCard(from selectedIds: [UUID], observations: [Observation],
authorId: String, authorName: String, communityCode: String?) async -> StoryCard? {
let selectedObs = observations.filter { selectedIds.contains($0.id) }
guard !selectedObs.isEmpty else { return nil }
isGeneratingStory = true
defer { isGeneratingStory = false }
let availability = checkModelAvailability()
let spec: CuratedStoryCardPrompt
switch availability {
case .available:
do {
spec = try await generateCuratedStoryCard(from: selectedObs)
} catch {
errorMessage = "Story generation failed. Using a draft story instead."
spec = fallbackStorySpec(from: selectedObs)
}
case .notEligible:
errorMessage = "Apple Intelligence isn't available on this device."
spec = fallbackStorySpec(from: selectedObs)
case .notEnabled:
errorMessage = "Apple Intelligence is disabled. Enable it in Settings to generate stories."
spec = fallbackStorySpec(from: selectedObs)
case .modelNotReady:
errorMessage = "Apple Intelligence is still preparing. Try again soon."
spec = fallbackStorySpec(from: selectedObs)
case .unknown:
errorMessage = "Story generation isn't available right now."
spec = fallbackStorySpec(from: selectedObs)
}
let card = StoryCard(
id: UUID(), title: spec.title.isEmpty ? "Community Story Card" : spec.title,
narrative: spec.narrative.isEmpty ? generateNarrative(for: selectedObs) : spec.narrative,
callToAction: spec.callToAction.isEmpty ? "Join us in addressing these community needs." : spec.callToAction,
observationIds: selectedIds, authorId: authorId, authorName: authorName,
tags: normalizedStoryTags(from: spec.tags, fallback: selectedObs),
createdDate: Date(), lastModifiedDate: Date(), status: .draft
)
stories.append(card)
_ = await saveStoryCard(card, communityCode: communityCode)
return card
}
Firestore Real-Time Listener
Swift
Attaches a live snapshot listener to a community's story collection, decoding documents into StoryCard models and keeping the local array sorted by date with automatic cleanup on detach.
Defines a @Generable schema for type-safe on-device output, then uses LanguageModelSession to generate a coalition-ready story card grounded strictly in the selected observations.
@Generable(description: "Coalition-ready story card content for community organizing")
struct CuratedStoryCardPrompt {
@Guide(description: "A compelling, coalition-ready title under 12 words")
var title: String
@Guide(description: "A clear, community-centered narrative (120–220 words) connecting observations to a shared problem. Plain, respectful tone.")
var narrative: String
@Guide(description: "A concrete call to action the coalition can take next (1–2 sentences).")
var callToAction: String
@Guide(description: "1–5 short tags in lowercase kebab-case (e.g., 'traffic-safety')", .count(1...5))
var tags: [String]
}
// ---
private func generateCuratedStoryCard(from observations: [Observation]) async throws -> CuratedStoryCardPrompt {
let session = LanguageModelSession(
model: SystemLanguageModel.default,
instructions: "You are a community storytelling assistant. Create a coalition-ready story card that is accurate, respectful, and grounded only in the observations provided."
)
let response = try await session.respond(
to: buildStoryPrompt(from: observations),
generating: CuratedStoryCardPrompt.self,
includeSchemaInPrompt: true
)
return response.content
}
Address Validation & Geocoding
Swift
Validates a user-entered address before submission by geocoding it with CLGeocoder, caching the result to avoid redundant network calls, and surfacing inline error messages when the address can't be verified.
func submitObservation() async {
let trimmedLocation = locationName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedLocation.isEmpty else {
addressValidationMessage = "Please enter a real address."
return
}
if let validatedCoordinate {
await createAndSubmit(with: validatedCoordinate)
return
}
isValidatingAddress = true
let geocodedCoordinate = await geocodeAddress(from: trimmedLocation)
isValidatingAddress = false
guard let geocodedCoordinate else {
addressValidationMessage = "We couldn't verify that address. Please enter a real address."
return
}
validatedCoordinate = geocodedCoordinate
await createAndSubmit(with: geocodedCoordinate)
}
func geocodeAddress(from address: String) async -> CLLocationCoordinate2D? {
do {
let placemarks = try await CLGeocoder().geocodeAddressString(address)
return placemarks.first?.location?.coordinate
} catch {
return nil
}
}
What is next
Next, I'd add push notifications so coalition members know right away when new observations or campaigns are posted in their neighborhood. I'd also build out a moderation layer so community leads can review and flag submissions before they go live. After that, I'd expand the Narrative Alchemy flow so it can take multiple related observations and turn them into one stronger, more complete story card. I'd also start tracking usage more intentionally so I can see which neighborhoods are most active and where people are dropping off in the reporting flow.
Outcome
1iOS engineer on the build
5Services integrated
5AI availability states handled
3Months in development
I started by taking apart code someone else wrote, which is a different skill from starting clean, and most of the stack was unfamiliar. What I keep coming back to is the fallback work: an app that generates a decent story card when Apple Intelligence is unavailable is far more useful to a resident than one that explains why it cannot. Designing for the degraded path first changed how the whole feature came out.