Visual Scoring Implementation Guide

Phase: 4 (4a-4c) Status: Complete PRs: #307, #308, #309


Overview

Phase 4 implements visual tap-to-score functionality for the iOS app, achieving feature parity with Android’s visual scoring system. This guide covers the complete implementation across three subphases:

  • Phase 4a: Target Face Visualization component
  • Phase 4b: Visual Scoring Integration with ActiveScoringView
  • Phase 4c: Shot Placement Persistence and SOLID Refactoring

Architecture

Component Hierarchy

ActiveScoringView
├── TargetFaceView (visual target)
│   ├── TargetFaceViewModel (ring geometry, scoring)
│   ├── ArrowMarker (visual arrow placements)
│   └── TapOverlay (gesture handling)
├── DynamicScoreButtons (button-based input)
│   └── ScoreButton
├── ArrowPlaceholder (current end arrows)
│   └── ArrowScoreCircle (unified display)
└── ActiveScoringViewModel (state management)

Key Files

ComponentFile Path
Target Face ViewViews/TargetFaceView.swift
Target Face ViewModelViewModels/TargetFaceViewModel.swift
Active Scoring ViewViews/ActiveScoringView.swift
Active Scoring ViewModelViewModels/ActiveScoringViewModel.swift
Arrow Score CircleComponents/ArrowScoreCircle.swift
Score ColorComponents/ArrowScoreCircle.swift (ScoreColor enum)
Scoring OperationsDI/ScoringOperations.swift
ProtocolsDI/Protocols.swift

Phase 4a: Target Face Visualization

TargetFaceView

A SwiftUI view that renders an archery target face with concentric scoring rings and arrow placements.

Key Features:

  • Renders rings based on scoring system (Standard 10-Ring, Indoor 5-Ring, Vegas 2-Ring, Field 5-Ring)
  • Displays arrow placements with score-based coloring
  • Tap overlay for scoring mode input
  • X-ring indicator for applicable scoring systems
  • Full accessibility support
struct TargetFaceView: View {
    @ObservedObject var viewModel: TargetFaceViewModel
    var onScoreTapped: ((Int, Double, Double, Bool) -> Void)?
 
    var body: some View {
        GeometryReader { geometry in
            let size = min(geometry.size.width, geometry.size.height)
            let radius = size / 2
 
            ZStack {
                // Rings from outside to inside
                ForEach(0..<viewModel.ringCount, id: \.self) { index in
                    let ringRadius = viewModel.getRingRadius(at: index, targetRadius: radius)
                    let color = viewModel.getRingColor(at: index)
                    Circle()
                        .fill(color.swiftUIColor)
                        .frame(width: ringRadius * 2, height: ringRadius * 2)
                }
 
                // Arrow placements
                ForEach(viewModel.arrowPlacements) { placement in
                    ArrowMarker(
                        x: placement.x,
                        y: placement.y,
                        score: placement.score,
                        isX: placement.isX,
                        targetRadius: radius
                    )
                }
 
                // Tap overlay for scoring mode
                if viewModel.showsTapOverlay {
                    TapOverlay(targetRadius: radius) { x, y in
                        handleTap(x: x, y: y, targetRadius: radius)
                    }
                }
            }
        }
    }
}

TargetFaceViewModel

Manages target rendering logic and score calculation.

Key Properties:

  • scoringSystem - Current scoring system (determines ring count and colors)
  • arrowPlacements - Array of arrow positions on target
  • displayMode - Scoring, review, or heatmap mode
  • ringCount - Number of rings based on scoring system

Key Methods:

// Calculate score from tap coordinates
func calculateScore(x: Double, y: Double) -> Int {
    let distance = sqrt(x * x + y * y)
    if distance > 1.0 { return 0 }  // Miss
 
    let ringWidth = 1.0 / Double(ringCount)
    let ringFromCenter = Int(distance / ringWidth)
    let ringIndex = ringCount - 1 - min(ringFromCenter, ringCount - 1)
 
    return getScoreForRing(at: ringIndex)
}
 
// Check X-ring hit
func isXRing(x: Double, y: Double) -> Bool {
    guard scoringSystem.hasXRing else { return false }
    let distance = sqrt(x * x + y * y)
    return distance <= Self.xRingRadiusFraction  // 0.05 = 5% of target radius
}

ArrowPlacement Model

struct ArrowPlacement: Identifiable, Equatable {
    let id = UUID()
    let x: Double      // Normalized X coordinate (-1 to 1)
    let y: Double      // Normalized Y coordinate (-1 to 1)
    let score: Int
    let isX: Bool
}

Coordinate System

All coordinates are normalized to the range -1 to 1:

  • (0, 0) = center of target
  • (1, 0) = right edge
  • (-1, 0) = left edge
  • (0, -1) = top edge
  • (0, 1) = bottom edge

This normalization enables:

  • Cross-device compatibility
  • Database storage as Float values
  • Easy distance calculations

Phase 4b: Visual Scoring Integration

Input Mode Toggle

Users can switch between button-based and target-based score entry:

enum ScoringInputMode: String, CaseIterable {
    case buttons = "Buttons"
    case target = "Target"
}
 
// In ActiveScoringView
Picker("Input Mode", selection: $inputMode) {
    ForEach(ScoringInputMode.allCases, id: \.self) { mode in
        Text(mode.rawValue).tag(mode)
    }
}
.pickerStyle(.segmented)

Target Tap Handling

When user taps the visual target:

private func handleTargetTap(score: Int, x: Double, y: Double, isX: Bool) {
    Task { @MainActor in
        // Add visual arrow placement
        targetViewModel.addArrowPlacement(x: x, y: y, score: score, isX: isX)
 
        // Enter score with coordinates for persistence
        await viewModel.enterScore(Int32(score), isX: isX, x: x, y: y)
    }
}

Critical: The @MainActor annotation is required because KMP suspend functions may have thread requirements.

DynamicScoreButtons

Score buttons vary by scoring system to match Android:

Scoring SystemButtons
Standard 10-RingX, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, M
Indoor 5-RingX, 10, 9, 8, 7, 6, M
Vegas 2-RingX, 10, 9, M
Field 5-Ring5, 5, 5, 3, 1, M

ArrowScoreCircle

Unified component for displaying arrow scores consistently across all views:

enum ScoreColor {
    static func color(for score: Int, isX: Bool = false) -> Color {
        if isX && score == 10 { return .red }
        switch score {
        case 10: return .green
        case 8...9: return .blue
        case 6...7: return .orange
        case 1...5: return .gray
        case 0: return .gray
        default: return .gray
        }
    }
}
 
struct ArrowScoreCircle: View {
    let score: Int
    let isX: Bool
    let size: CircleSize
 
    enum CircleSize {
        case small   // 32pt - for end history
        case medium  // 44pt - for current end
    }
}

Phase 4c: Shot Placement Persistence

ArrowEntry Model

Consolidates score, X-ring flag, and placement coordinates:

struct ShotCoordinate: Equatable {
    let x: Double
    let y: Double
}
 
struct ArrowEntry: Equatable {
    let score: Int32
    let isX: Bool
    let placement: ShotCoordinate?
}

Coordinate Persistence Flow

  1. User taps target - Normalized coordinates calculated
  2. ViewModel stores locally - ArrowEntry added to currentEndArrows
  3. Repository saves - Coordinates passed to ScoringOperations
  4. KMP conversion - Swift Double to KotlinFloat
// In ScoringOperations.swift
let kmpXCoord: KotlinFloat? = xCoordinate.map { KotlinFloat(value: Float($0)) }
let kmpYCoord: KotlinFloat? = yCoordinate.map { KotlinFloat(value: Float($0)) }
 
let inputMethod: InputMethod = (xCoordinate != nil && yCoordinate != nil)
    ? .coordinateClick
    : .manual

Resume Scoring

When resuming a partially scored round:

func loadRound(roundId: Int32) async {
    // ... load round details ...
 
    // Check for incomplete end with existing arrows
    let incompleteEnd = roundDetails.ends.first { !$0.endScore.isCompleted }
    if let incompleteEnd = incompleteEnd, !incompleteEnd.arrows.isEmpty {
        let sortedArrows = incompleteEnd.arrows.sorted { $0.arrowNumber < $1.arrowNumber }
 
        // Convert to ArrowEntry structs
        currentEndArrows = sortedArrows.map { arrow in
            let placement: ShotCoordinate?
            if let x = arrow.xCoordinate?.floatValue,
               let y = arrow.yCoordinate?.floatValue {
                placement = ShotCoordinate(x: Double(x), y: Double(y))
            } else {
                placement = nil
            }
            return ArrowEntry(score: arrow.scoreValue, isX: arrow.isX, placement: placement)
        }
        currentArrowNumber = Int32(currentEndArrows.count + 1)
    }
}
 
// Restore visual placements to target
func restoreArrowPlacements(to targetViewModel: TargetFaceViewModel) {
    targetViewModel.clearArrowPlacements()
    for arrow in currentEndArrows {
        if let placement = arrow.placement {
            targetViewModel.addArrowPlacement(
                x: placement.x,
                y: placement.y,
                score: Int(arrow.score),
                isX: arrow.isX
            )
        }
    }
}

ScoringOperations Helper

Shared scoring operations extracted to eliminate duplication:

enum ScoringOperations {
    static func saveArrowScore(
        roundId: Int32,
        endNumber: Int32,
        arrowNumber: Int32,
        score: Int32,
        isX: Bool,
        xCoordinate: Double?,
        yCoordinate: Double?,
        roundDao: RoundDao,
        caller: String = "ScoringOperations"
    ) async throws {
        // Get or create end score
        let endScore = try await getOrCreateEndScore(...)
 
        // Check for existing arrow (resume case)
        let existingArrow = existingArrows.first { $0.arrowNumber == arrowNumber }
 
        if let existing = existingArrow {
            // UPDATE existing
            try await roundDao.updateArrowScore(arrowScore: updatedArrow)
        } else {
            // INSERT new
            try await roundDao.insertArrowScore(arrowScore: arrowScore)
        }
 
        // Update end score total
        try await updateEndScoreTotal(endScoreId: endScore.id, roundDao: roundDao)
    }
}

SOLID Refactoring (Phase 4c)

Protocol Extraction

Repository protocols moved to DI/Protocols.swift:

protocol ActiveScoringRepositoryProtocol {
    func getRoundWithDetails(roundId: Int32) async throws -> RoundWithDetails?
    func saveArrowScore(
        roundId: Int32,
        endNumber: Int32,
        arrowNumber: Int32,
        score: Int32,
        isX: Bool,
        xCoordinate: Double?,
        yCoordinate: Double?
    ) async throws
    func completeEnd(roundId: Int32, endNumber: Int32) async throws
    func updateRoundStatus(roundId: Int32, status: RoundStatus) async throws
    func getEndsForRound(roundId: Int32) async throws -> [EndScoreWithArrows]
}

Parallel Array Elimination

Before (fragile):

@Published var currentEndScores: [Int32] = []
@Published var currentEndXRings: [Bool] = []
@Published var currentEndCoordinates: [ShotCoordinate?] = []

After (single source of truth):

@Published var currentEndArrows: [ArrowEntry] = []
 
// Computed properties for backward compatibility
var currentEndScores: [Int32] { currentEndArrows.map { $0.score } }
var currentEndXRings: [Bool] { currentEndArrows.map { $0.isX } }

Preview Repository Consolidation

Four duplicate preview repositories consolidated into one:

class PreviewEquipmentRepository: EquipmentRepositoryProtocol {
    func getAllRisers() async throws -> [Riser] { [] }
    func getAllArrows() async throws -> [Arrow] { [] }
    // ... all other methods return empty/defaults
}

Test Coverage

Phase 4 Tests

Test SuiteTestsCoverage
TargetFaceViewModelTests37Ring colors, score calculation, X-ring detection, arrow placement
ActiveScoringViewIntegrationTests13Target tap handling, mode switching, coordinate persistence
ScoreColorTests25All color mappings for score ranges
ArrowScoreCircleTests17Display text, size, color integration
ActiveScoringViewModelTests47Resume scoring, X-ring edge cases, coordinate persistence

Total: 501+ tests passing

Test Patterns

// TargetFaceViewModel scoring tests
func testScoreAtCenter() {
    viewModel.setScoringSystem(.standard10Ring)
    let score = viewModel.calculateScore(x: 0.0, y: 0.0)
    XCTAssertEqual(score, 10)
}
 
func testXRingDetection() {
    viewModel.setScoringSystem(.standard10Ring)
    let isX = viewModel.isXRing(x: 0.02, y: 0.02)  // Within 5% radius
    XCTAssertTrue(isX)
}
 
// ActiveScoringViewModel resume tests
func testResumeWithPartialEnd() async {
    // Load round with 3 arrows in incomplete end
    await viewModel.loadRound(roundId: 1)
    XCTAssertEqual(viewModel.currentEndArrows.count, 3)
    XCTAssertEqual(viewModel.currentArrowNumber, 4)
}

Key Patterns

1. Normalized Coordinates

All coordinates use -1 to 1 range for cross-device compatibility:

// Convert tap to normalized coordinates
let normalizedX = Double(tapX / targetRadius)
let normalizedY = Double(tapY / targetRadius)

2. KotlinFloat Conversion

Swift Double to KMP KotlinFloat for database storage:

let kmpXCoord: KotlinFloat? = xCoordinate.map { KotlinFloat(value: Float($0)) }

3. Input Method Detection

Automatically detect input method based on coordinate presence:

let inputMethod: InputMethod = (xCoordinate != nil && yCoordinate != nil)
    ? .coordinateClick
    : .manual

4. UPSERT for Resume

Check for existing records before INSERT to handle resume scenarios:

let existingArrow = existingArrows.first { $0.arrowNumber == arrowNumber }
if let existing = existingArrow {
    try await roundDao.updateArrowScore(...)  // UPDATE
} else {
    try await roundDao.insertArrowScore(...)  // INSERT
}

Accessibility

Target Face

.accessibilityElement(children: .contain)
.accessibilityLabel(accessibilityDescription)
 
private var accessibilityDescription: String {
    let placements = viewModel.arrowPlacements
    if placements.isEmpty {
        return "Target face with \(viewModel.ringCount) scoring rings. No arrows placed."
    }
    let total = placements.reduce(0) { $0 + $1.score }
    return "Target face with \(placements.count) arrows, total \(total) points"
}

Tap Overlay

.accessibilityLabel("Target face")
.accessibilityHint("Tap to record arrow placement")
.accessibilityAddTraits(.isButton)

Arrow Markers

.accessibilityLabel(isX ? "X-ring hit, \(score) points" : "\(score) point arrow")


Last Updated: 2025-11-29 Status: Phase 4 complete