✦
┌─────────────────┐
│ 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
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