On-Device AI Story Card Generation
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
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.
func startStoriesListener(communityCode: String) {
guard !communityCode.isEmpty else {
stories = []
stopStoriesListener()
return
}
stopStoriesListener()
isLoading = true
storiesListener = db.collection("communities")
.document(communityCode)
.collection("stories")
.addSnapshotListener { [weak self] snapshot, error in
guard let self else { return }
if let error {
DispatchQueue.main.async {
self.errorMessage = "Failed to load stories."
self.isLoading = false
}
return
}
guard let snapshot else { return }
let fetched = snapshot.documents.compactMap { doc in
try? doc.data(as: StoryCard.self)
}
DispatchQueue.main.async {
self.stories = fetched.sorted { $0.createdDate > $1.createdDate }
self.isLoading = false
}
}
}
func stopStoriesListener() {
storiesListener?.remove()
storiesListener = nil
}
Structured AI Generation with Foundation Models
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
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
}
}