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
| Component | File | Lines | Description |
|---|---|---|---|
| ViewModel | EquipmentComparisonViewModel.swift | 255 | Core logic, TDD |
| View | EquipmentComparisonView.swift | 462 | UI components |
| Bridge | EquipmentComparisonBridge.swift | 49 | KMP adapter |
| Protocol | Protocols.swift | +60 | ISP-compliant interface |
| Tests | EquipmentComparisonViewModelTests.swift | 727 | 36 tests |
EquipmentComparisonViewModel
TDD Implementation
36 tests written before implementation, covering:
| Category | Tests | Description |
|---|---|---|
| Initial State | 5 | Default values, empty selections |
| Load Setups | 4 | Success, error, sorting |
| Select Setup A | 4 | Selection, B auto-clear |
| Select Setup B | 4 | Selection, A exclusion |
| Compare | 8 | Success, error, empty data handling |
| Time Range | 5 | Selection, persistence, re-compare |
| Reset | 3 | State clearing |
| Edge Cases | 3 | Same 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 Type | Swift Type | Usage |
|---|---|---|
Long | Int64 | Direct 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
- Declare @AppStorage with String type and default value
- Restore on appear in
.taskmodifier - Persist on change immediately after ViewModel update
- Use enum rawValue for type-safe storage
Delta Visualization
Color Coding Convention
| Delta Value | Color | Meaning |
|---|---|---|
| Positive (> 0) | Green | Setup B performs better |
| Negative (< 0) | Red | Setup A performs better |
| Zero (= 0) | Gray | No 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
| Feature | Android | iOS | Notes |
|---|---|---|---|
| Setup Selection | Yes | Yes | Menu-based picker |
| Side-by-Side Stats | Yes | Yes | Card-based layout |
| Delta Display | Yes | Yes | Color-coded |
| Time Range Filter | Yes | Yes | @AppStorage persisted |
| Drift Stats | Yes | Yes | When available |
iOS Feature Parity: ~88% (Equipment Comparison complete)
Related Documentation
- Phase 5b Individual Equipment Analytics - Per-equipment stats
- Phase 5a Analytics Dashboard - Foundation
- KMP iOS Patterns - Integration patterns
- Unit Testing Guide - TDD patterns
Last Updated: 2025-11-30 Status: Phase 5c complete