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
EquipmentHubViewloads on appearance- Calls
viewModel.loadEquipmentCounts() EquipmentListViewModelusesasync letto fetch all 10 types concurrentlyEquipmentRepositoryBridgeconverts KMP Flow to async/await viaFlowUtils.first()- Counts are stored in
equipmentCountsdictionary keyed byEquipmentType.name - User taps category → navigates to
EquipmentListViewwith specific type EquipmentListViewloads items for that type- 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 wrappersWhy This Works:
- Single
EquipmentListViewworks for all types - Single
EquipmentListViewModelhandles 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
| Type | Icon | Singular | Plural | Notes |
|---|---|---|---|---|
| Riser | rectangle.portrait | Riser | Risers | Brand/Model |
| Limbs | arrow.left.and.right | Limbs | Limbs | Brand/Model |
| Sight | scope | Sight | Sights | Brand/Model |
| Rest | arrow.down.to.line | Rest | Rests | Brand/Model |
| Plunger | circle.circle | Plunger | Plungers | Brand/Model |
| BowString | lasso | String | Strings | Maker/Type |
| Stabilizer | line.3.horizontal | Stabilizer | Stabilizers | Brand/Model |
| Weight | scalemass | Weight | Weights | Location/Name |
| Arrow | arrow.up.right | Arrow Set | Arrows | Brand/Model |
| Accessory | bag | Accessory | Accessories | Name/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
Related Documentation
- iOS Development Roadmap - Phase overview
- KMP Patterns - FlowUtils and type bridging patterns
- Testing Strategy - TDD approach
Tags: ios phase-3 equipment tdd kmp Status: Complete PR: #301