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
| Component | File Path |
|---|---|
| Target Face View | Views/TargetFaceView.swift |
| Target Face ViewModel | ViewModels/TargetFaceViewModel.swift |
| Active Scoring View | Views/ActiveScoringView.swift |
| Active Scoring ViewModel | ViewModels/ActiveScoringViewModel.swift |
| Arrow Score Circle | Components/ArrowScoreCircle.swift |
| Score Color | Components/ArrowScoreCircle.swift (ScoreColor enum) |
| Scoring Operations | DI/ScoringOperations.swift |
| Protocols | DI/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 targetdisplayMode- Scoring, review, or heatmap moderingCount- 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 System | Buttons |
|---|---|
| Standard 10-Ring | X, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, M |
| Indoor 5-Ring | X, 10, 9, 8, 7, 6, M |
| Vegas 2-Ring | X, 10, 9, M |
| Field 5-Ring | 5, 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
- User taps target - Normalized coordinates calculated
- ViewModel stores locally - ArrowEntry added to currentEndArrows
- Repository saves - Coordinates passed to ScoringOperations
- 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
: .manualResume 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 Suite | Tests | Coverage |
|---|---|---|
| TargetFaceViewModelTests | 37 | Ring colors, score calculation, X-ring detection, arrow placement |
| ActiveScoringViewIntegrationTests | 13 | Target tap handling, mode switching, coordinate persistence |
| ScoreColorTests | 25 | All color mappings for score ranges |
| ArrowScoreCircleTests | 17 | Display text, size, color integration |
| ActiveScoringViewModelTests | 47 | Resume 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
: .manual4. 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")Related Documentation
- kmp-ios-patterns - KMP iOS integration patterns
- active-scoring-guide - Original active scoring guide
- 2025-11-28-ios-phase-4b-visual-scoring-session - Phase 4b session notes
- testing-strategy - iOS TDD patterns
Last Updated: 2025-11-29 Status: Phase 4 complete