iOS Phase 3a: Equipment Hub Implementation Guide

Implemented: 2025-11-28 PR: #301 Status: COMPLETE Approach: Test-Driven Development (TDD)


Overview

The Equipment Hub provides iOS users with a centralized view for managing all 10 equipment types. This phase demonstrates the power of KMP shared code - 95% of the business logic comes from existing Kotlin DAOs, with Swift providing only the UI layer.

Key Innovation: A single EquipmentListViewModel handles all 10 equipment types using a type-erased EquipmentItem protocol, reducing code duplication significantly.


Architecture

Component Diagram

┌─────────────────────────────────────────────────────────────┐
│                     EquipmentTabView                        │
│                           │                                 │
│                    EquipmentHubView                         │
│                    ┌─────────────────┐                     │
│                    │  Summary Card   │                     │
│                    │  (Total Count)  │                     │
│                    └────────┬────────┘                     │
│                    ┌────────┴────────┐                     │
│          ┌─────────┴─────────┬───────┴──────────┐         │
│          ▼                   ▼                   ▼         │
│   Bow Components    String & Stab      Arrows & Acc       │
│   ─────────────     ───────────────    ─────────────       │
│   • Riser           • BowString        • Arrow            │
│   • Limbs           • Stabilizer       • Accessory        │
│   • Sight           • Weight                              │
│   • Rest                                                  │
│   • Plunger                                               │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                   EquipmentListView                         │
│                   ┌──────────────────┐                     │
│                   │ Equipment Items  │                     │
│                   │ (Swipe to Delete)│                     │
│                   └──────────────────┘                     │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                 EquipmentListViewModel                      │
│        (Single ViewModel for all 10 types)                 │
│                         │                                  │
│              EquipmentRepositoryBridge                     │
│                         │                                  │
│    ┌──────┬──────┬──────┼──────┬──────┬──────┐            │
│    ▼      ▼      ▼      ▼      ▼      ▼      ▼            │
│ Riser  Arrow  Limbs  Sight  Stab  Plunger ...             │
│  Dao    Dao    Dao    Dao    Dao    Dao                    │
│                    (10 KMP DAOs)                           │
└─────────────────────────────────────────────────────────────┘

Data Flow

  1. EquipmentHubView loads on appearance
  2. Calls viewModel.loadEquipmentCounts()
  3. EquipmentListViewModel uses async let to fetch all 10 types concurrently
  4. EquipmentRepositoryBridge converts KMP Flow to async/await via FlowUtils.first()
  5. Counts are stored in equipmentCounts dictionary keyed by EquipmentType.name
  6. User taps category → navigates to EquipmentListView with specific type
  7. EquipmentListView loads items for that type
  8. Swipe-to-delete triggers confirmation dialog, then viewModel.deleteEquipment()

Key Patterns

1. Type-Erased EquipmentItem Protocol

The challenge: Display 10 different equipment types in a single generic list view.

The solution: A protocol that extracts common display properties:

protocol EquipmentItem {
    var equipmentId: Int64 { get }
    var equipmentBrand: String { get }
    var equipmentModel: String { get }
    var equipmentType: EquipmentType { get }
}

Each equipment type gets a wrapper struct:

struct RiserItem: EquipmentItem {
    let riser: Riser
 
    var equipmentId: Int64 { riser.id }
    var equipmentBrand: String { riser.brand }
    var equipmentModel: String { riser.model }
    var equipmentType: EquipmentType { .riser }
}
 
struct ArrowItem: EquipmentItem {
    let arrow: Arrow
 
    var equipmentId: Int64 { arrow.id }
    var equipmentBrand: String { arrow.brand }
    var equipmentModel: String { arrow.model }
    var equipmentType: EquipmentType { .arrow }
}
// ... 8 more wrappers

Why This Works:

  • Single EquipmentListView works for all types
  • Single EquipmentListViewModel handles all CRUD operations
  • Type-safe at compile time, but type-erased at runtime for generic display

2. Async Let Concurrent Fetching

Fetching 10 equipment counts sequentially would be slow. Using async let makes them concurrent:

func loadEquipmentCounts() async {
    do {
        // All 10 calls start immediately (concurrent)
        async let risers = repository.getAllRisers()
        async let arrows = repository.getAllArrows()
        async let limbs = repository.getAllLimbs()
        async let sights = repository.getAllSights()
        async let stabilizers = repository.getAllStabilizers()
        async let plungers = repository.getAllPlungers()
        async let rests = repository.getAllRests()
        async let bowStrings = repository.getAllBowStrings()
        async let weights = repository.getAllWeights()
        async let accessories = repository.getAllAccessories()
 
        // Wait for all to complete (parallel await)
        let (risersResult, arrowsResult, limbsResult, sightsResult,
             stabilizersResult, plungersResult, restsResult,
             bowStringsResult, weightsResult, accessoriesResult) = try await (
            risers, arrows, limbs, sights, stabilizers,
            plungers, rests, bowStrings, weights, accessories
        )
 
        // Build counts dictionary
        equipmentCounts = [
            EquipmentType.riser.name: risersResult.count,
            EquipmentType.arrow.name: arrowsResult.count,
            // ... etc
        ]
    } catch {
        errorMessage = "Failed to load equipment counts: \(error.localizedDescription)"
    }
}

Performance: 10 parallel calls complete in the time of 1 sequential call (assuming no contention).

3. FlowUtils.first() Pattern

KMP DAOs return Flow<List<T>> for reactive updates. For simple fetch operations, we only need the first emission:

// In EquipmentRepositoryBridge
func getAllRisers() async throws -> [Riser] {
    let flow = riserDao.getAllRisers()
    return try await FlowUtils.shared.first(flow: flow) as! [Riser]
}

Note: The force cast as! [Riser] is safe because:

  • KMP Flow<List> is guaranteed to emit [Riser]
  • The KMP type system ensures this at compile time
  • We’re just helping Swift’s type inference

4. KMP Enum Comparison via .name

KMP enums bridge to Swift as classes, not native Swift enums. Direct == comparison fails:

// ❌ WRONG - KMP enums are classes, not Swift enums
if type == .riser { ... }  // Compiler error or incorrect behavior
 
// ✅ CORRECT - Compare via .name property
switch type.name {
case EquipmentType.riser.name:
    return "Risers"
case EquipmentType.arrow.name:
    return "Arrows"
// ...
}

File Structure

iosApp/ArcheryApprentice/ArcheryApprentice/
├── DI/
│   └── EquipmentRepositoryBridge.swift    # KMP DAO → Swift adapter
├── ViewModels/
│   └── EquipmentListViewModel.swift       # Unified ViewModel (all 10 types)
├── Views/Equipment/
│   ├── EquipmentHubView.swift             # Main hub screen
│   └── EquipmentListView.swift            # Generic list with swipe-delete
└── Screens/
    └── EquipmentTabView.swift             # Tab entry point

ArcheryApprenticeTests/
└── EquipmentListViewModelTests.swift      # 20+ TDD tests

EquipmentHubView Implementation

The hub displays three category groups with navigation to list views:

struct EquipmentHubView: View {
    @StateObject private var viewModel: EquipmentListViewModel
 
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 20) {
                    summaryCard          // Total equipment count
                    equipmentCategoriesSection
                }
                .padding()
            }
            .navigationTitle("Equipment")
            .task {
                await viewModel.loadEquipmentCounts()
            }
            .refreshable {
                await viewModel.loadEquipmentCounts()
            }
        }
    }
 
    private var equipmentCategoriesSection: some View {
        VStack(alignment: .leading, spacing: 16) {
            Text("Categories").font(.title2).fontWeight(.semibold)
 
            categoryGroup(title: "Bow Components",
                         types: [.riser, .limbs, .sight, .rest, .plunger])
 
            categoryGroup(title: "String & Stabilization",
                         types: [.bowString, .stabilizer, .weight])
 
            categoryGroup(title: "Arrows & Accessories",
                         types: [.arrow, .accessory])
        }
    }
 
    private func categoryGroup(title: String, types: [EquipmentType]) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.subheadline)
                .foregroundColor(.secondary)
                .textCase(.uppercase)
 
            ForEach(types, id: \.name) { type in
                NavigationLink {
                    EquipmentListView(equipmentType: type)
                } label: {
                    equipmentTypeRow(type: type)
                }
            }
        }
    }
}

EquipmentListView with Swipe-to-Delete

struct EquipmentListView: View {
    let equipmentType: EquipmentType
    @StateObject private var viewModel: EquipmentListViewModel
    @State private var showingDeleteConfirmation = false
    @State private var itemToDelete: (id: Int64, type: EquipmentType)?
 
    var body: some View {
        Group {
            if viewModel.isLoading && viewModel.equipment.isEmpty {
                loadingView
            } else if viewModel.equipment.isEmpty {
                emptyStateView
            } else {
                equipmentList
            }
        }
        .task {
            await viewModel.loadEquipment(type: equipmentType)
        }
        .alert("Delete Equipment", isPresented: $showingDeleteConfirmation) {
            Button("Cancel", role: .cancel) { itemToDelete = nil }
            Button("Delete", role: .destructive) {
                if let item = itemToDelete {
                    Task {
                        _ = await viewModel.deleteEquipment(id: item.id, type: item.type)
                    }
                }
            }
        } message: {
            Text("Are you sure you want to delete this equipment?")
        }
    }
 
    private var equipmentList: some View {
        List {
            ForEach(viewModel.equipment, id: \.equipmentId) { item in
                NavigationLink {
                    Text("Detail view for \(item.equipmentBrand) \(item.equipmentModel)")
                } label: {
                    equipmentRow(item: item)
                }
                .swipeActions(edge: .trailing, allowsFullSwipe: false) {
                    Button(role: .destructive) {
                        itemToDelete = (item.equipmentId, item.equipmentType)
                        showingDeleteConfirmation = true
                    } label: {
                        Label("Delete", systemImage: "trash")
                    }
                }
            }
        }
    }
}

EquipmentRepositoryBridge

Adapts 10 KMP DAOs to a single Swift protocol:

@MainActor
class EquipmentRepositoryBridge: EquipmentRepositoryProtocol {
 
    private let riserDao: RiserDao
    private let arrowDao: ArrowDao
    private let limbsDao: LimbsDao
    // ... 7 more DAOs
 
    init(riserDao: RiserDao, arrowDao: ArrowDao, ...) {
        self.riserDao = riserDao
        self.arrowDao = arrowDao
        // ... etc
    }
 
    // Get All Methods - Use FlowUtils.first() pattern
    func getAllRisers() async throws -> [Riser] {
        let flow = riserDao.getAllRisers()
        return try await FlowUtils.shared.first(flow: flow) as! [Riser]
    }
 
    // Delete Methods - Direct async calls
    func deleteRiser(id: Int64) async throws {
        try await riserDao.deleteRiserById(id: id)
    }
 
    // ... 9 more get/delete pairs
}

Testing Strategy (TDD)

The implementation followed TDD with 20+ tests covering:

ViewModel Tests

class EquipmentListViewModelTests: XCTestCase {
 
    // Test loading equipment counts
    func testLoadEquipmentCountsSuccess() async {
        let mockRepo = MockEquipmentRepository()
        mockRepo.risers = [createRiser(), createRiser()]
        mockRepo.arrows = [createArrow()]
 
        let viewModel = EquipmentListViewModel(repository: mockRepo)
        await viewModel.loadEquipmentCounts()
 
        XCTAssertEqual(viewModel.getCount(for: .riser), 2)
        XCTAssertEqual(viewModel.getCount(for: .arrow), 1)
        XCTAssertEqual(viewModel.totalEquipmentCount, 3)
    }
 
    // Test loading equipment list
    func testLoadEquipmentForTypeSuccess() async {
        let mockRepo = MockEquipmentRepository()
        mockRepo.risers = [createRiser(brand: "Hoyt")]
 
        let viewModel = EquipmentListViewModel(repository: mockRepo)
        await viewModel.loadEquipment(type: .riser)
 
        XCTAssertEqual(viewModel.equipment.count, 1)
        XCTAssertEqual(viewModel.equipment.first?.equipmentBrand, "Hoyt")
    }
 
    // Test delete functionality
    func testDeleteEquipmentSuccess() async {
        let mockRepo = MockEquipmentRepository()
        mockRepo.risers = [createRiser(id: 1)]
 
        let viewModel = EquipmentListViewModel(repository: mockRepo)
        await viewModel.loadEquipment(type: .riser)
 
        let success = await viewModel.deleteEquipment(id: 1, type: .riser)
        XCTAssertTrue(success)
    }
 
    // Test error handling
    func testLoadEquipmentHandlesError() async {
        let mockRepo = MockEquipmentRepository()
        mockRepo.shouldThrowError = true
 
        let viewModel = EquipmentListViewModel(repository: mockRepo)
        await viewModel.loadEquipment(type: .riser)
 
        XCTAssertNotNil(viewModel.errorMessage)
        XCTAssertTrue(viewModel.equipment.isEmpty)
    }
}

Equipment Types Reference

TypeIconSingularPluralNotes
Riserrectangle.portraitRiserRisersBrand/Model
Limbsarrow.left.and.rightLimbsLimbsBrand/Model
SightscopeSightSightsBrand/Model
Restarrow.down.to.lineRestRestsBrand/Model
Plungercircle.circlePlungerPlungersBrand/Model
BowStringlassoStringStringsMaker/Type
Stabilizerline.3.horizontalStabilizerStabilizersBrand/Model
WeightscalemassWeightWeightsLocation/Name
Arrowarrow.up.rightArrow SetArrowsBrand/Model
AccessorybagAccessoryAccessoriesName/Type

Future Phases

Phase 3b: Equipment Add/Edit

  • Create forms for all 10 equipment types
  • Reuse EquipmentItem protocol for form data

Phase 3c: Equipment Details

  • Type-specific detail views
  • Display all equipment fields

Phase 3d: Bow Setup Management

  • Combine equipment into bow setups
  • Link to rounds for equipment tracking


Tags: ios phase-3 equipment tdd kmp Status: Complete PR: #301