iOS Phase 4b: Visual Scoring Integration Session

Date: November 28, 2025 Session Type: Bug Fixes & Android Parity Scope: Visual scoring integration, UI consistency, database operations PR: #308 - iOS Phase 4b Visual Scoring Bug Fixes

Overview

This session completed iOS Phase 4b - Visual Scoring Integration with extensive bug fixes and Android parity work. PR #308 contains 6 commits addressing critical issues discovered during smoke testing. The session focused on achieving feature and behavior parity with the Android implementation.

Context

Phase 4b Goals

  • Visual target tap-to-score integration
  • Score button layouts for all 4 scoring systems
  • X-ring vs 10 distinction (matching Android)
  • Consistent arrow score display across views
  • Resume scoring for incomplete rounds

Pre-Session Status

  • Visual scoring UI shell complete
  • Basic score entry working
  • Multiple bugs discovered during smoke testing
  • Android had more mature visual scoring implementation

Bugs Fixed

Bug 1: KMP Coroutine Crash

Symptom: App crashed when tapping visual target to enter score

Error:

ObjCExportCoroutines.kt crash when tapping visual target

Root Cause:

  • Task closure calling KMP suspend functions without @MainActor
  • Swift async Task creates new execution context
  • KMP suspend functions expect main thread dispatch

Fix Applied:

// Before (CRASH)
Task {
    await handleTargetTap(location: location)  // Calls KMP suspend
}
 
// After (WORKS)
Task { @MainActor in
    await handleTargetTap(location: location)
}

Pattern Established:

Any Task closure that calls KMP suspend functions must have @MainActor annotation

Files Changed:

  • ActiveScoringView.swift

Bug 2: Missing X Button on iOS

Symptom: iOS button scoring only showed numeric buttons (10, 9, 8…) while Android showed X button for X-ring hits

Root Cause:

  • iOS implementation used simple numeric buttons from validScores array
  • Android’s ScoreInputSection.kt had dedicated X button with special handling
  • Feature disparity between platforms

Fix Applied:

  • Created DynamicScoreButtons component matching Android’s ScoreInputSection.kt
  • X button displays for compound (0-10 + X) and Olympic (0-10 + X) scoring systems
  • X button passes isX: true explicitly to scoring function
// DynamicScoreButtons.swift
struct DynamicScoreButtons: View {
    let scoringSystem: ScoringSystem
    let onScoreSelected: (Int32, Bool) -> Void  // (score, isX)
 
    var body: some View {
        // Layout varies by scoring system
        // X button included for systems with X-ring
    }
}

Files Changed:

  • Components/DynamicScoreButtons.swift (NEW)
  • ActiveScoringView.swift

Bug 3: SQLite UNIQUE Constraint Crash

Symptom: App crashed when resuming a round with existing arrows

Error:

UNIQUE constraint failed: arrow_score.end_id, arrow_score.arrow_number

Root Cause:

  • enterScore() always used INSERT for arrow scores
  • When resuming incomplete round, arrows already existed in database
  • Duplicate INSERT violated unique constraint on (end_id, arrow_number)

Fix Applied:

  • Check for existing arrow at position before INSERT
  • Use UPDATE if arrow exists, INSERT if new
// UPSERT Pattern for Arrow Scores
let existingArrow = existingArrows.first { $0.arrowNumber == arrowNumber }
if let existing = existingArrow {
    // Arrow exists - UPDATE
    let updatedArrow = ArrowScore(
        id: existing.id,  // Preserve existing ID
        endId: endId,
        arrowNumber: arrowNumber,
        scoreValue: score,
        isX: isX
    )
    try await roundDao.updateArrowScore(arrowScore: updatedArrow)
} else {
    // New arrow - INSERT
    let newArrow = ArrowScore(
        id: 0,  // Auto-generated
        endId: endId,
        arrowNumber: arrowNumber,
        scoreValue: score,
        isX: isX
    )
    try await roundDao.insertArrowScore(arrowScore: newArrow)
}

Files Changed:

  • ActiveScoringRepositoryBridge.swift

Bug 4: X Shows as 10 (Display Bug)

Symptom: All 10-point hits displayed as “X” in score circles, even when scored via the “10” button

Root Cause:

  • enterScore(_ score:) had auto-detection logic: isX: score == 10
  • This converted ALL 10s to X-rings automatically
  • User intent (10 vs X) was lost

Fix Applied:

  • Default isX to false (matching Android behavior)
  • Caller must explicitly pass isX: true when X button pressed
// Before (WRONG)
func enterScore(_ score: Int32) async {
    await enterScore(score, isX: score == 10)  // Auto-detect BAD
}
 
// After (CORRECT - matches Android)
func enterScore(_ score: Int32) async {
    await enterScore(score, isX: false)  // Default false, caller specifies
}

Android Reference (ScoreInputSection.kt):

// Android explicitly passes isX based on which button was pressed
onXClick = { viewModel.enterScore(10, isX = true) }
onScoreClick = { score -> viewModel.enterScore(score, isX = false) }

Files Changed:

  • ActiveScoringViewModel.swift

Bug 5: Inconsistent Score Display Colors

Symptom: End history view used different colors than current end display for arrow scores

Root Cause:

  • Duplicate ArrowScoreChip component existed in different files
  • Each had slightly different color logic
  • No single source of truth for score-to-color mapping

Fix Applied:

  • Created unified ArrowScoreCircle component
  • Single source of truth for all arrow score display
  • Two sizes: .small (32pt) and .medium (44pt)
// Components/ArrowScoreCircle.swift
struct ArrowScoreCircle: View {
    let score: Int32
    let isX: Bool
    let size: Size
 
    enum Size {
        case small   // 32pt - for end history
        case medium  // 44pt - for current end
    }
 
    var backgroundColor: Color {
        if isX { return .red }
        switch score {
        case 10: return .green
        case 8, 9: return .blue
        case 6, 7: return .orange
        case 1...5: return .gray
        default: return .gray  // Miss (M)
        }
    }
 
    var displayText: String {
        if isX { return "X" }
        if score == 0 { return "M" }
        return "\(score)"
    }
}

Color Scheme (matches Android exactly):

ScoreColorNotes
XRedX-ring hit
10GreenInner gold
8-9BlueGold ring
6-7OrangeRed ring
1-5GrayOuter rings
M (0)GrayMiss

Files Changed:

  • Components/ArrowScoreCircle.swift (NEW)
  • ActiveScoringView.swift
  • RoundDetailView.swift

Key Patterns Established

Pattern 1: KMP Enum Comparison in Swift

KMP enums bridge to Swift as reference types, not value types. Direct comparison fails.

// WRONG - Reference comparison
let inProgress = rounds.filter { $0.status == RoundStatus.inProgress }
 
// CORRECT - Compare by name property
let inProgress = rounds.filter { $0.status.name == RoundStatus.inProgress.name }

Why: KMP enums become classes in Swift, and == compares object identity, not enum case.


Pattern 2: Explicit isX Flag (Android Parity)

Never auto-detect X-ring status. Caller must explicitly specify.

// WRONG - Auto-detection loses user intent
func enterScore(_ score: Int32) async {
    await enterScore(score, isX: score == 10)
}
 
// CORRECT - Default false, caller specifies
func enterScore(_ score: Int32) async {
    await enterScore(score, isX: false)
}
 
// Caller explicitly passes true for X button
viewModel.enterScore(10, isX: true)

Pattern 3: Resume Scoring (Load Existing Arrows)

When resuming incomplete round, load existing arrow data into view state.

// Load existing arrows when resuming incomplete end
let incompleteEnd = roundDetails.ends.first { !$0.endScore.isCompleted }
if let incompleteEnd = incompleteEnd, !incompleteEnd.arrows.isEmpty {
    // Sort by arrow number to preserve order
    let sortedArrows = incompleteEnd.arrows.sorted {
        $0.arrowNumber < $1.arrowNumber
    }
    currentEndScores = sortedArrows.map { $0.scoreValue }
    currentEndXRings = sortedArrows.map { $0.isX }
    currentArrowNumber = Int32(currentEndScores.count + 1)
}

Pattern 4: UPSERT for Arrow Scores

Check for existing record before INSERT to handle resume scenarios.

// Check for existing arrow before INSERT
let existingArrow = existingArrows.first { $0.arrowNumber == arrowNumber }
if let existing = existingArrow {
    try await roundDao.updateArrowScore(arrowScore: updatedArrow)
} else {
    try await roundDao.insertArrowScore(arrowScore: newArrow)
}

New Components

ArrowScoreCircle.swift

Unified arrow score display component with two sizes.

Location: iosApp/ArcheryApprentice/Components/ArrowScoreCircle.swift

Features:

  • Two sizes: .small (32pt) and .medium (44pt)
  • Consistent colors matching Android exactly
  • Handles X-ring, numeric scores, and misses
  • Single source of truth for score display

Usage:

// Current end (larger)
ArrowScoreCircle(score: 10, isX: false, size: .medium)
 
// End history (smaller)
ArrowScoreCircle(score: 10, isX: true, size: .small)  // Shows "X" in red

Android Parity Achieved

Score Button Layouts

All 4 scoring systems now match Android:

Scoring SystemButtonsX Button
Olympic (0-10 + X)0-10 + XYes
Compound (0-10 + X)0-10 + XYes
Indoor (0-10)0-10No
Field (0-6)0-6No

Color Scheme

Exact match with Android’s ScoreInputSection.kt:

  • X = Red
  • 10 = Green
  • 8-9 = Blue
  • 6-7 = Orange
  • 1-5 = Gray
  • M = Gray

X-ring Distinction

  • X button explicitly passes isX: true
  • 10 button passes isX: false
  • Display correctly shows “X” vs “10”
  • Database stores distinction correctly

Files Changed Summary

New Files

  1. Components/ArrowScoreCircle.swift - Unified arrow score display
  2. Components/DynamicScoreButtons.swift - Score button grid (optional, may be inline)

Modified Files

  1. ActiveScoringView.swift - Visual target tap handling, @MainActor fix
  2. ActiveScoringViewModel.swift - Explicit isX flag, resume scoring
  3. ActiveScoringRepositoryBridge.swift - UPSERT pattern for arrows
  4. RoundDetailView.swift - Use ArrowScoreCircle for consistency

Testing Performed

Manual Testing

  1. Visual Target Scoring:

    • Tap target to enter score - No crash
    • X-ring area registers as X - Correct
    • Ring boundaries calculate correctly - Verified
  2. Button Scoring:

    • All 4 scoring systems tested
    • X button enters X (not 10)
    • 10 button enters 10 (not X)
  3. Resume Round:

    • Create round, enter 3 arrows, exit
    • Resume round - 3 arrows loaded
    • Enter 4th arrow - No crash
    • Complete end - All arrows saved
  4. Score Display:

    • Current end shows correct colors
    • End history shows correct colors
    • X displays as “X” (red)
    • 10 displays as “10” (green)

Edge Cases Verified

  • Resume with 0 arrows - Works
  • Resume with partial end - Works
  • Complete all ends, start new - Works
  • Mix of X and 10 in same end - Displays correctly

Lessons Learned

1. @MainActor for KMP Suspend Calls

Swift Task closures run on arbitrary threads by default. KMP suspend functions may have thread requirements.

Rule: Always add @MainActor to Task closures that call KMP suspend functions.

2. Don’t Auto-Detect User Intent

Auto-converting 10 to X seemed helpful but lost information. Match the reference implementation (Android) behavior.

Rule: Let callers explicitly specify all parameters rather than inferring.

3. UPSERT Prevents Constraint Violations

Simple INSERT fails when resuming partial data entry. Always check for existing records in scenarios where data may already exist.

Rule: For any database write where records might exist, use UPSERT pattern.

4. Single Component for Repeated UI

Duplicate components with similar but different logic cause inconsistency. Create one component and use it everywhere.

Rule: Extract shared UI into reusable components immediately.


Session Files

  • PR #308 - Phase 4b bug fixes
  • Agent 3 Review - Posted on PR #308

Code References

  • iosApp/ArcheryApprentice/Screens/ActiveScoringView.swift
  • iosApp/ArcheryApprentice/ViewModels/ActiveScoringViewModel.swift
  • iosApp/ArcheryApprentice/Repositories/ActiveScoringRepositoryBridge.swift
  • iosApp/ArcheryApprentice/Components/ArrowScoreCircle.swift (NEW)
  • iosApp/ArcheryApprentice/Screens/RoundDetailView.swift

Pattern Documentation


Follow-Up Items

  • Add unit tests for ArrowScoreCircle component
  • Add unit tests for resume scoring logic
  • Document UPSERT pattern in kmp-ios-patterns
  • Verify edge case: maximum arrows per end

Timeline

  • Session Date: 2025-11-28
  • PR Created: 2025-11-28
  • PR Merged: Pending
  • Phase 4b Status: Complete (6 bugs fixed, Android parity achieved)
  • Next Phase: Phase 4c (Tournament Scoring) or Phase 5 (Authentication)