┌─────────────────┐ │ 1. Create Model │ ├─────────────────┤ │ 2. ViewModel │ ├─────────────────┤ │ 3. View │ ├─────────────────┤ │ 4. Wire API │ ├─────────────────┤ │ 5. Add to Nav │ ├─────────────────┤ │ 6. Test │ └─────────────────┘

Adding Features

Step-by-step guide to adding a new screen to Archon.

🆕 New Screen ───────────── Model → ViewModel ViewModel → View View → Nav Nav → Tab
Developer Docs Archon iOS · Swift · SwiftUI API v1

Adding Features

Step-by-step guide to adding a new screen to Archon.

Convention Overview

Every new screen in Archon follows the same pattern. This consistency makes the codebase predictable and easy to navigate.

┌──────────┐     ┌────────────┐     ┌──────────┐
│  Model   │────▶│ ViewModel  │────▶│   View   │
│ (Codable)│     │(Observable)│     │(SwiftUI) │
└──────────┘     └─────┬──────┘     └──────────┘
                       │
                 ┌─────▼──────┐
                 │ APIClient  │
                 │ (Protocol) │
                 └────────────┘

Step 1: Create the Model

If your feature involves new data types, add them to Models/:

// Models/ArchonWidget.swift
struct ArchonWidget: Codable, Identifiable, Hashable {
    let id: UUID
    let projectId: UUID
    var name: String
    var config: WidgetConfig
    var createdAt: Date
    var updatedAt: Date
}

struct WidgetConfig: Codable, Hashable {
    var type: String
    var settings: [String: String]
}
Naming
Prefix with Archon for app-level models. Use plain names for sub-models (e.g., WidgetConfig).
Conformances
Always add Codable, Identifiable, and Hashable. Add Equatable if needed for comparisons.

Step 2: Create the ViewModel

// ViewModels/WidgetsViewModel.swift
@MainActor
class WidgetsViewModel: ObservableObject {
    @Published var widgets: [ArchonWidget] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let apiClient: APIClientProtocol

    init(apiClient: APIClientProtocol) {
        self.apiClient = apiClient
    }

    func loadWidgets(projectId: UUID) async {
        isLoading = true
        errorMessage = nil
        do {
            widgets = try await apiClient.request(
                method: .get,
                path: "/rest/v1/widgets?project_id=eq.\(projectId)"
            )
        } catch {
            errorMessage = error.localizedDescription
        }
        isLoading = false
    }

    func createWidget(name: String, projectId: UUID) async {
        // ...
    }

    func deleteWidget(_ widget: ArchonWidget) async {
        // ...
    }
}
@MainActor
All ViewModels are marked @MainActor to ensure UI updates happen on the main thread.
Protocol Injection
Always accept APIClientProtocol, never the concrete APIClient. This enables mock injection for tests.
@Published State
Expose @Published properties for every piece of UI state: data, loading, errors, selections.

Step 3: Create the View

// Views/Widgets/WidgetsView.swift
struct WidgetsView: View {
    @StateObject private var viewModel: WidgetsViewModel
    let projectId: UUID

    init(projectId: UUID, apiClient: APIClientProtocol) {
        self.projectId = projectId
        _viewModel = StateObject(wrappedValue: WidgetsViewModel(apiClient: apiClient))
    }

    var body: some View {
        List {
            if viewModel.isLoading {
                ProgressView()
            } else if let error = viewModel.errorMessage {
                ContentUnavailableView("Error", systemImage: "exclamationmark.triangle")
            } else {
                ForEach(viewModel.widgets) { widget in
                    NavigationLink(value: widget) {
                        WidgetRow(widget: widget)
                    }
                }
            }
        }
        .navigationTitle("Widgets")
        .task {
            await viewModel.loadWidgets(projectId: projectId)
        }
    }
}
StateObject + init
Use @StateObject with a custom init to inject dependencies. The ViewModel is created once and lives for the view's lifetime.
.task modifier
Use .task instead of .onAppear for async work. It automatically cancels when the view disappears.
Error States
Use ContentUnavailableView for empty and error states. Follow existing patterns in the codebase.

Step 4: Add to Navigation

Register the new screen in the appropriate NavigationStack:

// In the parent view's NavigationStack:
.navigationDestination(for: ArchonWidget.self) { widget in
    WidgetDetailView(widget: widget, apiClient: apiClient)
}

Step 5: Add to Tab Bar (if applicable)

If the feature is a new top-level tab, add it to MainTabView:

TabItem(label: "Widgets", systemImage: "widget.square") {
    NavigationStack {
        WidgetsView(projectId: currentProjectId, apiClient: apiClient)
    }
}

Step 6: Write Tests

ViewModel Tests
Create WidgetsViewModelTests.swift. Test load, create, delete, and error paths using MockAPIClient.
Model Tests
If you added new models, create decoding tests against fixture JSON.
Run Tests
Cmd + U in Xcode or xcodebuild test from the terminal.

Checklist

☐ Model conforms to Codable, Identifiable, Hashable
☐ ViewModel is @MainActor, accepts APIClientProtocol
☐ View uses @StateObject + .task for loading
☐ Screen registered in NavigationStack
☐ Tests pass with MockAPIClient
☐ No compiler warnings