iOS Navigation Patterns

This document describes the SwiftUI navigation patterns used in the iOS app, covering hierarchical navigation, modal presentation, deep links, and testing strategies.

Core Navigation Architecture

All tab views use NavigationStack as the root container:

struct RoundsTabView: View {
    @State private var showingCreateRound = false
 
    var body: some View {
        NavigationStack {
            RoundListView(
                repository: repo,
                onCreateRoundTapped: { showingCreateRound = true }
            )
            .sheet(isPresented: $showingCreateRound) {
                RoundCreationView(onRoundCreated: { _ in
                    refreshCounter += 1
                })
            }
        }
    }
}

Use NavigationLink with inline destination for list-to-detail navigation:

NavigationLink {
    RoundDetailView(
        roundId: round.id,
        repository: repository,
        activeScoringRepository: activeScoringRepository
    )
} label: {
    RoundCardView(round: round)
}
.buttonStyle(PlainButtonStyle())  // Prevent NavigationLink styling

Hub Pattern for Feature Organization

Hub views group related features with navigation options:

struct OnlineHubView: View {
    var body: some View {
        NavigationStack {
            List {
                NavigationLink(destination: TournamentListView()) {
                    OnlineHubRow(title: "Tournaments", ...)
                }
 
                NavigationLink(destination: LeaderboardBrowserView()) {
                    OnlineHubRow(title: "Global Leaderboard", ...)
                }
            }
            .listStyle(.insetGrouped)
            .navigationTitle("Online")
        }
    }
}

Dependency Injection

DependencyContainer Pattern

Centralized dependency injection provides services to views:

struct RoundsTabView: View {
    private var roundRepository: (any RoundRepositoryProtocol)? {
        DependencyContainer.shared.roundRepositoryBridge
    }
 
    var body: some View {
        if let repo = roundRepository {
            RoundListView(repository: repo)
        } else {
            // Test environment fallback
            UnavailableView()
        }
    }
}

Protocol-Based Injection

All dependencies use protocols for testability:

struct RoundDetailView: View {
    let roundId: Int32
    let repository: any RoundRepositoryProtocol
    let activeScoringRepository: any ActiveScoringRepositoryProtocol
}

Optional Dependencies for Test Environments

Handle nil dependencies gracefully when KMP database isn’t available:

@ViewBuilder
private var myPublishedRoundsView: some View {
    if let repo = roundPublishingRepository {
        MyPublishedRoundsView(repository: repo)
    } else {
        VStack {
            Image(systemName: "exclamationmark.triangle")
            Text("Not Available")
        }
    }
}

Sheet for Creation/Edit Forms

Use .sheet() for modal presentation of forms:

@State private var showingCreateRound = false
 
Button { showingCreateRound = true } label: {
    Image(systemName: "plus")
}
.sheet(isPresented: $showingCreateRound) {
    RoundCreationView(onRoundCreated: { newRound in
        refreshCounter += 1  // Trigger list refresh
    })
}

Callback Pattern for Modal Results

Pass callbacks for modal completion handling:

RoundCreationView(onRoundCreated: { createdRound in
    // Handle creation result
})
 
VerifyScoreSheet(
    request: request,
    onDismiss: { showVerifySheet = false },
    onVerify: { imageData, notes in
        await submitVerification(imageData: imageData, notes: notes)
    }
)

State Management

ViewModel Pattern

Use @StateObject with dedicated ViewModels:

struct RoundListView: View {
    @StateObject private var viewModel: RoundListViewModel
 
    init(repository: any RoundRepositoryProtocol) {
        _viewModel = StateObject(wrappedValue:
            RoundListViewModel(repository: repository)
        )
    }
}

Loading/Error/Content States

Consistent state pattern for async content:

@ViewBuilder
private var contentView: some View {
    if viewModel.isLoading {
        loadingView
    } else if let errorMessage = viewModel.errorMessage {
        errorView(message: errorMessage)
    } else if viewModel.filteredRounds.isEmpty {
        emptyStateView
    } else {
        roundsListView
    }
}

Refresh Patterns

Counter-based refresh for external triggers:

struct RoundsTabView: View {
    @State private var refreshCounter: Int = 0
 
    RoundListView(refreshCounter: refreshCounter)
        .sheet(isPresented: $showingCreateRound) {
            RoundCreationView(onRoundCreated: { _ in
                refreshCounter += 1  // Trigger refresh
            })
        }
}
 
struct RoundListView: View {
    var refreshCounter: Int
 
    var body: some View {
        // ...
        .onChange(of: refreshCounter) { _, _ in
            viewModel.loadRoundsSync()
        }
    }
}

Handle verification deep links with validation:

struct VerificationDeepLinkView: View {
    let token: String
    let verificationService: any VerificationServiceProtocol
    let onDismiss: () -> Void
 
    var body: some View {
        NavigationStack {
            Group {
                if isLoading {
                    loadingView
                } else if let error = error {
                    errorView(error)
                } else if verificationRequest != nil {
                    verifyContentView
                }
            }
        }
        .task {
            await loadVerificationRequest()
        }
    }
}
  1. Extract token from URL
  2. Look up request by token
  3. Validate user is signed in
  4. Validate user is not the requester
  5. Present verification flow
private func loadVerificationRequest() async {
    guard let user = currentUser else {
        error = "Please sign in to verify scores"
        return
    }
 
    let request = try await verificationService.getRequestForVerification(token: token)
 
    if request.requesterId == user.uid {
        error = "You cannot verify your own score"
    } else if request.status.name != VerificationRequestStatus.pending.name {
        error = "This verification link is no longer valid"
    } else {
        verificationRequest = request
    }
}

Mock Repository Pattern

Create unified mock repositories for navigation tests:

class MockRoundNavigationRepository {
    var roundsToReturn: [Round] = []
    var roundDetailsToReturn: RoundWithDetails?
    var shouldFail = false
    var shouldFailOnUpdate = false
 
    func getAllRounds() async throws -> [Round] {
        if shouldFail { throw MockError() }
        return roundsToReturn
    }
}
 
extension MockRoundNavigationRepository: RoundRepositoryProtocol {}
extension MockRoundNavigationRepository: RoundDetailRepositoryProtocol {}

Test that navigation data is available:

func testRoundList_providesRoundData_forNavigation() async {
    let testRounds = createTestRounds()
    mockRepository.roundsToReturn = testRounds
 
    let viewModel = RoundListViewModel(repository: mockRepository)
    await viewModel.loadRounds()
 
    let selectedRound = viewModel.filteredRounds.first { $0.id == 1 }
    XCTAssertNotNil(selectedRound, "Round should be available from list")
}

Action Button State Tests

Test context-appropriate actions:

func testRoundDetail_startButton_availableForPlannedRound() async {
    let plannedRound = createTestRound(status: .planned)
    mockRepository.roundDetailsToReturn = createTestRoundWithDetails(round: plannedRound)
 
    let viewModel = RoundDetailViewModel(repository: mockRepository)
    await viewModel.loadRound(roundId: 1)
 
    XCTAssertTrue(viewModel.canStartRound, "Start button should be available")
    XCTAssertFalse(viewModel.canResumeRound, "Resume should NOT be available")
}

Component Patterns

Reusable Row Components

Create consistent row components for lists:

struct OnlineHubRow: View {
    let title: String
    let description: String
    let systemImage: String
    let color: Color
 
    var body: some View {
        HStack(spacing: 16) {
            Image(systemName: systemImage)
                .font(.title)
                .foregroundColor(color)
                .frame(width: 44, height: 44)
 
            VStack(alignment: .leading, spacing: 4) {
                Text(title).font(.headline)
                Text(description).font(.subheadline).foregroundColor(.secondary)
            }
        }
        .padding(.vertical, 8)
    }
}

Status Badge Components

Create status-aware badge components:

struct RoundStatusBadge: View {
    let status: RoundStatus
 
    var body: some View {
        Text(statusText)
            .font(.caption)
            .fontWeight(.semibold)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(statusColor.opacity(0.2))
            .foregroundColor(statusColor)
            .cornerRadius(6)
    }
 
    private var statusColor: Color {
        switch status {
        case .planned: return .blue
        case .inProgress: return .green
        case .completed: return .gray
        case .paused: return .orange
        default: return .gray
        }
    }
}

Best Practices

  1. Use NavigationStack at tab level: Each tab owns its navigation stack
  2. Inject dependencies via protocols: Enables testing and preview support
  3. Handle nil dependencies gracefully: Support test environments
  4. Use callbacks for modal results: Clean communication pattern
  5. Separate ViewModel from View: @StateObject for state management
  6. Test navigation state thoroughly: Verify data availability and action states
  7. Create reusable components: Consistent UI patterns across views
  8. Use .task for async loading: Clean async/await integration