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:
Taskclosure 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
@MainActorannotation
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
validScoresarray - Android’s
ScoreInputSection.kthad dedicated X button with special handling - Feature disparity between platforms
Fix Applied:
- Created
DynamicScoreButtonscomponent matching Android’sScoreInputSection.kt - X button displays for compound (0-10 + X) and Olympic (0-10 + X) scoring systems
- X button passes
isX: trueexplicitly 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
isXtofalse(matching Android behavior) - Caller must explicitly pass
isX: truewhen 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
ArrowScoreChipcomponent existed in different files - Each had slightly different color logic
- No single source of truth for score-to-color mapping
Fix Applied:
- Created unified
ArrowScoreCirclecomponent - 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):
| Score | Color | Notes |
|---|---|---|
| X | Red | X-ring hit |
| 10 | Green | Inner gold |
| 8-9 | Blue | Gold ring |
| 6-7 | Orange | Red ring |
| 1-5 | Gray | Outer rings |
| M (0) | Gray | Miss |
Files Changed:
Components/ArrowScoreCircle.swift(NEW)ActiveScoringView.swiftRoundDetailView.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 redAndroid Parity Achieved
Score Button Layouts
All 4 scoring systems now match Android:
| Scoring System | Buttons | X Button |
|---|---|---|
| Olympic (0-10 + X) | 0-10 + X | Yes |
| Compound (0-10 + X) | 0-10 + X | Yes |
| Indoor (0-10) | 0-10 | No |
| Field (0-6) | 0-6 | No |
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
Components/ArrowScoreCircle.swift- Unified arrow score displayComponents/DynamicScoreButtons.swift- Score button grid (optional, may be inline)
Modified Files
ActiveScoringView.swift- Visual target tap handling, @MainActor fixActiveScoringViewModel.swift- Explicit isX flag, resume scoringActiveScoringRepositoryBridge.swift- UPSERT pattern for arrowsRoundDetailView.swift- Use ArrowScoreCircle for consistency
Testing Performed
Manual Testing
-
Visual Target Scoring:
- Tap target to enter score - No crash
- X-ring area registers as X - Correct
- Ring boundaries calculate correctly - Verified
-
Button Scoring:
- All 4 scoring systems tested
- X button enters X (not 10)
- 10 button enters 10 (not X)
-
Resume Round:
- Create round, enter 3 arrows, exit
- Resume round - 3 arrows loaded
- Enter 4th arrow - No crash
- Complete end - All arrows saved
-
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.
Related Documentation
Session Files
- PR #308 - Phase 4b bug fixes
- Agent 3 Review - Posted on PR #308
Code References
iosApp/ArcheryApprentice/Screens/ActiveScoringView.swiftiosApp/ArcheryApprentice/ViewModels/ActiveScoringViewModel.swiftiosApp/ArcheryApprentice/Repositories/ActiveScoringRepositoryBridge.swiftiosApp/ArcheryApprentice/Components/ArrowScoreCircle.swift(NEW)iosApp/ArcheryApprentice/Screens/RoundDetailView.swift
Pattern Documentation
- kmp-ios-patterns - Updated with new patterns from this session
- active-scoring-guide - Active scoring implementation
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)