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

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

Analytics (KISSmetrics)

Philosophy: Track funnel steps, not UI interactions. We use ~12 meaningful events with properties, not 50+ granular button taps.

Do NOT add:

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):

Adding New Events:

  1. Ask: "Is this a funnel step that shows conversion/drop-off?"
  2. If no, don't add an event - use properties on existing events instead
  3. If yes, add to AnalyticsEvent enum and document in this table
  4. 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"])