Equipment Comparison Guide

Phase: 5c Status: Complete PR: #314


Overview

Phase 5c implements Equipment Comparison for iOS, achieving feature parity with Android’s existing comparison functionality. This feature allows archers to compare performance statistics between two bow setups side-by-side, helping identify which equipment configuration performs better under specific conditions.

Key Features:

  • Select two bow setups from all available setups
  • View performance stats side-by-side (average score, accuracy, consistency)
  • Calculate deltas (B - A) with color-coded indicators
  • Time-range filtering with persistence
  • Drift statistics comparison when available

Architecture

Component Structure

EquipmentComparisonView
├── EquipmentComparisonViewModel (36 TDD tests)
│   └── EquipmentComparisonRepositoryProtocol (ISP compliant)
│       └── EquipmentComparisonBridge
│           ├── BowSetupDao (setup listing)
│           └── RoundDao (stats queries)
├── Setup Selection Picker (Menu-based)
├── Comparison Result Cards
├── Delta Display (color-coded)
└── Time Range Filter (@AppStorage persisted)

Data Models

// Stats for comparison display
struct ComparisonSetupStats {
    let setupId: Int64
    let totalArrows: Int32
    let averageScore: Double
    let accuracy: Double    // 10-ring %
    let consistency: Double // 9+ %
    let xCount: Int32
    let tenCount: Int32
    let roundCount: Int32
}
 
// Drift statistics
struct ComparisonDriftStats {
    let setupId: Int64
    let xDriftAverage: Float
    let yDriftAverage: Float
    let arrowCount: Int32
}
 
// Computed differences (B - A)
struct ComparisonDeltas {
    let averageScoreDelta: Double
    let accuracyDelta: Double
    let consistencyDelta: Double
    let arrowCountDelta: Int32
}
 
// Complete comparison result
struct EquipmentComparisonResult {
    let setupAName: String
    let setupBName: String
    let setupAStats: ComparisonSetupStats
    let setupBStats: ComparisonSetupStats
    let setupADrift: ComparisonDriftStats?
    let setupBDrift: ComparisonDriftStats?
    let deltas: ComparisonDeltas
}

Key Files

ComponentFileLinesDescription
ViewModelEquipmentComparisonViewModel.swift255Core logic, TDD
ViewEquipmentComparisonView.swift462UI components
BridgeEquipmentComparisonBridge.swift49KMP adapter
ProtocolProtocols.swift+60ISP-compliant interface
TestsEquipmentComparisonViewModelTests.swift72736 tests

EquipmentComparisonViewModel

TDD Implementation

36 tests written before implementation, covering:

CategoryTestsDescription
Initial State5Default values, empty selections
Load Setups4Success, error, sorting
Select Setup A4Selection, B auto-clear
Select Setup B4Selection, A exclusion
Compare8Success, error, empty data handling
Time Range5Selection, persistence, re-compare
Reset3State clearing
Edge Cases3Same setup, empty names

Published Properties

@MainActor
class EquipmentComparisonViewModel: ObservableObject {
    @Published private(set) var isLoading: Bool = false
    @Published private(set) var errorMessage: String?
    @Published private(set) var availableSetups: [BowSetup] = []
    @Published private(set) var setupA: BowSetup?
    @Published private(set) var setupB: BowSetup?
    @Published private(set) var comparisonResult: EquipmentComparisonResult?
    @Published private(set) var selectedTimeRange: AnalyticsTimeRange = .thirtyDays
 
    // Computed: Setups available for B (excludes A)
    var availableSetupsForB: [BowSetup] { ... }
 
    // Computed: Can perform comparison
    var canCompare: Bool { setupA != nil && setupB != nil }
}

Parallel Data Fetching

func compare() async {
    guard let setupA = setupA, let setupB = setupB else { return }
 
    isLoading = true
    let startDate = selectedTimeRange.startTimestamp()
 
    do {
        // Fetch all stats in parallel
        async let statsATask = repository.getEquipmentPerformanceStats(setupId: setupA.id, startDate: startDate)
        async let statsBTask = repository.getEquipmentPerformanceStats(setupId: setupB.id, startDate: startDate)
        async let driftATask = repository.getEquipmentDriftStats(setupId: setupA.id, startDate: startDate)
        async let driftBTask = repository.getEquipmentDriftStats(setupId: setupB.id, startDate: startDate)
 
        let (statsA, statsB, driftA, driftB) = try await (statsATask, statsBTask, driftATask, driftBTask)
 
        // Convert and calculate deltas...
    } catch {
        errorMessage = "Failed to compare setups: \(error.localizedDescription)"
    }
}

KotlinLong Interop Pattern

Problem

KMP generates different Swift types for nullable vs non-nullable Long:

Kotlin TypeSwift TypeUsage
LongInt64Direct use
Long?KotlinLong?Requires wrapper

Solution in EquipmentComparisonBridge

class EquipmentComparisonBridge: EquipmentComparisonRepositoryProtocol {
 
    func getEquipmentPerformanceStats(setupId: Int64, startDate: Int64?) async throws -> EquipmentPerformanceStats? {
        // KMP signature: startDate: Long = 0L (non-nullable with default)
        // Uses Int64 directly since KMP method takes non-nullable Long
        let effectiveStartDate: Int64 = startDate ?? 0
        return try await roundDao.getEquipmentPerformanceStats(bowSetupId: setupId, startDate: effectiveStartDate)
    }
 
    func getEquipmentDriftStats(setupId: Int64, startDate: Int64?) async throws -> EquipmentDriftStats? {
        // KMP signature: startDate: Long? = null (nullable)
        // Requires KotlinLong wrapper for optional interop
        let kotlinStartDate: KotlinLong? = startDate.map { KotlinLong(value: $0) }
        return try await roundDao.getEquipmentDriftStats(bowSetupId: setupId, startDate: kotlinStartDate)
    }
}

Pattern Summary

// For non-nullable Long with default value in KMP:
let swiftValue: Int64 = optionalSwiftValue ?? 0
dao.method(param: swiftValue)
 
// For nullable Long? in KMP:
let kotlinValue: KotlinLong? = optionalSwiftValue.map { KotlinLong(value: $0) }
dao.method(param: kotlinValue)

@AppStorage Persistence Pattern

Problem

Store user preferences (like selected time range) across app sessions.

Solution

struct EquipmentComparisonView: View {
    @StateObject private var viewModel: EquipmentComparisonViewModel
 
    // Store enum rawValue as String
    @AppStorage("equipmentComparisonTimeRange") private var storedTimeRange: String = AnalyticsTimeRange.thirtyDays.rawValue
 
    var body: some View {
        NavigationView {
            contentView
                .task {
                    // Restore persisted time range on appear
                    if let savedRange = AnalyticsTimeRange(rawValue: storedTimeRange) {
                        await viewModel.setTimeRange(savedRange)
                    }
                    await viewModel.loadSetups()
                }
        }
    }
 
    // On time range change, persist and update
    private var timeRangeFilterSection: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 12) {
                ForEach(AnalyticsTimeRange.allCases, id: \.self) { range in
                    TimeRangeChipView(
                        label: range.label,
                        isSelected: viewModel.selectedTimeRange == range
                    ) {
                        Task {
                            await viewModel.setTimeRange(range)
                            storedTimeRange = range.rawValue  // Persist
                        }
                    }
                }
            }
        }
    }
}

Pattern Summary

  1. Declare @AppStorage with String type and default value
  2. Restore on appear in .task modifier
  3. Persist on change immediately after ViewModel update
  4. Use enum rawValue for type-safe storage

Delta Visualization

Color Coding Convention

Delta ValueColorMeaning
Positive (> 0)GreenSetup B performs better
Negative (< 0)RedSetup A performs better
Zero (= 0)GrayNo difference

Implementation

private func deltaItem(title: String, value: Double, suffix: String = "") -> some View {
    VStack(alignment: .center, spacing: 4) {
        Text(title)
            .font(.caption)
            .foregroundColor(.secondary)
 
        HStack(spacing: 2) {
            Text(formatDelta(value))
                .font(.body)
                .fontWeight(.medium)
                .foregroundColor(deltaColor(for: value))
            if !suffix.isEmpty {
                Text(suffix)
                    .font(.caption)
                    .foregroundColor(deltaColor(for: value))
            }
        }
    }
}
 
private func formatDelta(_ value: Double) -> String {
    let formatted = String(format: "%.2f", abs(value))
    if value > 0 {
        return "+\(formatted)"
    } else if value < 0 {
        return "-\(formatted)"
    } else {
        return formatted
    }
}
 
private func deltaColor(for value: Double) -> Color {
    if value > 0 { return .green }
    else if value < 0 { return .red }
    else { return .secondary }
}

Integration Points

AnalyticsTabView

Two entry points to Equipment Comparison:

// 1. Toolbar button
.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
        Button(action: { showEquipmentComparison = true }) {
            Image(systemName: "chart.bar.doc.horizontal")
        }
    }
}
 
// 2. Action card in analytics overview
NavigationLink(destination: equipmentComparisonView) {
    ActionCard(
        title: "Compare Equipment",
        icon: "chart.bar.doc.horizontal",
        description: "Compare two setups side-by-side"
    )
}

DependencyContainer

class DependencyContainer {
    var equipmentComparisonBridge: EquipmentComparisonBridge?
 
    func configure(with database: ArcheryKmpDatabase) {
        equipmentComparisonBridge = EquipmentComparisonBridge(
            bowSetupDao: database.bowSetupDao(),
            roundDao: database.roundDao()
        )
    }
}

MainTabView

// Passed through to AnalyticsTabView
if let comparisonRepo = DependencyContainer.shared.equipmentComparisonBridge {
    AnalyticsTabView(
        repository: analyticsRepo,
        comparisonRepository: comparisonRepo
    )
}

Protocol Design (ISP Compliant)

Interface Segregation Principle: only 3 methods needed for comparison.

/// Protocol for equipment comparison data access
/// ISP-compliant: minimal interface for comparison feature
protocol EquipmentComparisonRepositoryProtocol {
    /// Get all available bow setups for selection
    func getAllBowSetups() async throws -> [BowSetup]
 
    /// Get performance statistics for a bow setup
    func getEquipmentPerformanceStats(setupId: Int64, startDate: Int64?) async throws -> EquipmentPerformanceStats?
 
    /// Get drift statistics for a bow setup
    func getEquipmentDriftStats(setupId: Int64, startDate: Int64?) async throws -> EquipmentDriftStats?
}

Test Coverage

EquipmentComparisonViewModelTests (36 tests)

Test Categories:

// Initial State (5 tests)
func testInitialState_isLoadingFalse()
func testInitialState_noSetups()
func testInitialState_noComparisonResult()
func testInitialState_cannotCompare()
func testInitialState_defaultTimeRange()
 
// Load Setups (4 tests)
func testLoadSetups_success_setsAvailableSetups()
func testLoadSetups_error_setsErrorMessage()
func testLoadSetups_sortsAlphabetically()
func testLoadSetups_setsIsLoadingDuringLoad()
 
// Select Setup (8 tests)
func testSelectSetupA_setsSetup()
func testSelectSetupA_clearsComparisonResult()
func testSelectSetupA_clearsSetupBIfSame()
func testSelectSetupB_setsSetup()
func testSelectSetupB_cannotSelectSameAsA()
func testSelectSetupB_clearsComparisonResult()
func testAvailableSetupsForB_excludesSetupA()
func testCanCompare_trueWhenBothSelected()
 
// Compare (8 tests)
func testCompare_success_setsComparisonResult()
func testCompare_error_setsErrorMessage()
func testCompare_calculatesDeltas()
func testCompare_handlesEmptyStatsForSetupA()
func testCompare_handlesEmptyStatsForSetupB()
func testCompare_includesDriftIfAvailable()
func testCompare_setsIsLoadingDuringCompare()
func testCompare_doesNothingIfCannotCompare()
 
// Time Range (5 tests)
func testSetTimeRange_updatesSelectedTimeRange()
func testSetTimeRange_recomparesIfBothSelected()
func testSetTimeRange_doesNotCompareIfNotBothSelected()
 
// Reset (3 tests)
func testResetComparison_clearsAll()
func testClearError_clearsErrorMessage()
 
// Edge Cases (3 tests)
func testEmptySetupName_showsFallback()

Bug Fixes Included

KotlinLong Interop Fix

Fixed IndividualEquipmentAnalyticsBridge which was causing build failures:

// BEFORE (Bug)
func getDriftStats(..., startDate: Int64?) async throws -> ... {
    return try await equipmentStatsDao.getRiserDriftStats(riserId: equipmentId, startDate: startDate)
    // Error: Cannot convert Int64? to KotlinLong?
}
 
// AFTER (Fixed)
func getDriftStats(..., startDate: Int64?) async throws -> ... {
    let kotlinStartDate: KotlinLong? = startDate.map { KotlinLong(value: $0) }
    return try await equipmentStatsDao.getRiserDriftStats(riserId: equipmentId, startDate: kotlinStartDate)
}

Missing Import Fix

Added missing Combine import to IndividualEquipmentPerformanceView.swift.


Feature Parity Status

FeatureAndroidiOSNotes
Setup SelectionYesYesMenu-based picker
Side-by-Side StatsYesYesCard-based layout
Delta DisplayYesYesColor-coded
Time Range FilterYesYes@AppStorage persisted
Drift StatsYesYesWhen available

iOS Feature Parity: ~88% (Equipment Comparison complete)



Last Updated: 2025-11-30 Status: Phase 5c complete