ios-swift
iOS Swift Development Standards
These rules apply automatically when working with Swift files in the iOS app.
CRITICAL RULES
Adding New Files
Do NOT modify .pbxproj programmatically.
When creating new Swift files, ask the user to add them to Xcode manually.
Comprehensive Previews Instead of UI Testing
When working on View code, create comprehesnive Previews to allow Ryan to test the various states.
Architecture: MVVM with SwiftUI
Pattern: Model-View-ViewModel with protocol-oriented data access
Data Flow:
View → ViewModel → DataProvider → Specific Provider → Firebase
ViewModel Design (Critical)
ViewModels should be thin integration shells, not logic containers.
| Concern | Where It Belongs |
|---|---|
| Coordinating async calls | ViewModel |
| UI state management | ViewModel |
| Pure business logic | Static factories on types, extensions |
| Data transformation | Model extensions |
Good - Injectable dependencies with defaults:
init(provider: DataProvider = DataProvider(),
functions: RekoDayFunctions = FirebaseFunctions())
Anti-pattern - Hardcoded dependencies:
// WRONG - cannot test
private var addressProvider = AddressProvider()
private var provider = DataProvider()
Extracting Pure Logic
Don't create new types just for testability. Place logic where it belongs.
Before - Logic buried in ViewModel:
class CreateRekoDayViewModel {
private func createMeetingInfo() -> RekoMeetingInformation? {
// Logic buried here
}
}
After - Pure, testable, lives with its type:
extension RekoMeetingInformation {
static func from(
date: Date,
day: RekoMeetDayType,
frequency: RekoMeetFrequencyType
) -> RekoMeetingInformation? {
let calendar = Calendar(identifier: .iso8601)
let components = calendar.dateComponents([.hour, .minute], from: date)
guard let hour = components.hour,
let minutes = components.minute else {
return nil
}
return RekoMeetingInformation(
meetingDay: day,
meetingFrequency: frequency,
meetingHour: hour,
meetingMinute: minutes
)
}
}
Property Wrapper Rules
| Scenario | Use This | Why |
|---|---|---|
| View creates/owns ViewModel | @StateObject |
View manages lifecycle |
| ViewModel passed from parent | @ObservedObject |
Parent manages lifecycle |
| Simple value state in View | @State |
SwiftUI manages |
| Two-way binding to child | @Binding |
Passes reference |
@StateObject + Dependency Injection:
// View uses defaults - fine for production
@StateObject var viewModel = CartViewModel()
// Tests control dependencies directly
let viewModel = CartViewModel(
provider: fakeProvider,
functions: LocalFunctions()
)
Thread Safety
- Mark all ViewModels with
@MainActor - Use
[weak self]in ALL closures to prevent retain cycles - Use
Task { @MainActor in }for async work that updates UI
Memory Management
// CORRECT - prevents retain cycle
someAsyncOperation { [weak self] result in
guard let self else { return }
self.handleResult(result)
}
// WRONG - creates retain cycle
someAsyncOperation { result in
self.handleResult(result)
}
Swift Actors
When to use: Shared mutable state accessed from multiple async contexts (caches, rate limiters, coordinators).
Compiler-enforced safety:
actor Cache {
private var data: [String: Any] = [:]
func set(_ key: String, _ value: Any) { data[key] = value }
}
cache.set("key", value) // Compiler error
await cache.set("key", value) // Correct
Reentrancy warning - every await is a potential interleaving point:
actor Counter {
var value = 0
func increment() async {
let current = value
await someAsyncWork()
value = current + 1 // BUG: value may have changed during await
}
}
Contain async spread with pure synchronous logic:
// Pure, synchronous, no concurrency concerns
struct PriceCalculator {
func total(for items: [CartItem]) -> Int { ... }
}
// Async shell coordinates, calls into pure logic
@MainActor
class CartViewModel: ObservableObject {
func checkout() async {
let total = PriceCalculator().total(for: items) // sync
await paymentService.charge(total) // async at boundary
}
}
Bridging Legacy Delegate Patterns
Bridge Apple's delegate APIs to Swift concurrency:
class LocationProvider: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var continuation: AsyncStream<CLLocation>.Continuation?
var locations: AsyncStream<CLLocation> {
AsyncStream { continuation in
self.continuation = continuation
manager.delegate = self
manager.startUpdatingLocation()
continuation.onTermination = { _ in
self.manager.stopUpdatingLocation()
}
}
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
locations.forEach { continuation?.yield($0) }
}
}
Error Handling
- Avoid force unwraps (
!) - they crash the app - Use
guard letorif letfor optionals - Provide meaningful error states in ViewModels
Analytics (KISSmetrics)
Philosophy: Track funnel steps, not UI interactions. We use ~12 meaningful events with properties, not 50+ granular button taps.
Do NOT add:
- Generic
viewScreenorbuttonTapevents - Events for settings, navigation, or non-funnel actions
- Events without clear funnel purpose
Available Events (AnalyticsEvent enum in AnalyticsHelper.swift):
| Event | When to Fire | Required Properties |
|---|---|---|
signedUp |
New user created (auto in AccountInfo) | method |
roleSelected |
User picks buyer/seller role | role |
stripeOnboardingStarted |
Seller starts Stripe setup | - |
stripeOnboardingCompleted |
Stripe verification passes | - |
productListed |
New product created | price, reko_count |
itemViewed |
User views item detail | item_id, seller_id, price |
addedToCart |
Item added to cart | item_id, quantity, price |
checkoutStarted |
Pay button tapped | cart_total, item_count, payment_method |
paymentCompleted |
Stripe payment succeeds | amount, item_count |
orderCompleted |
Order marked complete | order_id, seller_id |
rekoCreationStarted |
User enters create reko | - |
rekoCreated |
Reko saved successfully | - |
User Identification (automatic in AccountInfo.setUser):
Analytics.identifyUser(userId)- called on login/signupAnalytics.setUserTraits(traits)- sets segmentation propertiesAnalytics.clearIdentity()- called on sign out
Adding New Events:
- Ask: "Is this a funnel step that shows conversion/drop-off?"
- If no, don't add an event - use properties on existing events instead
- If yes, add to
AnalyticsEventenum and document in this table - Keep total event count under 20
Example - Correct:
// Funnel step: user completes checkout
Analytics.recordEvent(.paymentCompleted, withProperties: [
"amount": total,
"item_count": items.count
])
Example - Incorrect (don't do this):
// NOT a funnel step - just navigation
Analytics.recordEvent(.viewScreen, withProperties: ["label": "Settings"])
Analytics.recordEvent(.buttonTap, withProperties: ["label": "Help"])