Active Scoring Implementation Guide

Phase: 2d Estimated Effort: 12-16 hours Prerequisites: Phase 2b (Persistence) and 2c (Round List) complete


Overview

Implement the core scoring experience where users tap score buttons to record arrows during a round. This is the most complex Phase 2 component, requiring state management, validation, and database persistence.


Architecture

View Components

  1. ActiveScoringView - Main scoring screen container
  2. ScoreButtonGrid - Tappable score buttons (0-10 + X)
  3. EndProgressHeader - Current end/arrow display
  4. RunningTotalCard - Score summary and totals

ViewModel

ActiveScoringViewModel - Manages:

  • Current end/arrow state
  • Arrow scores collection
  • Running totals
  • Database persistence
  • Navigation to next end/completion

Implementation Guide

ActiveScoringViewModel

import Shared
import Combine
 
class ActiveScoringViewModel: ObservableObject {
    @Published var currentEndNumber: Int = 1
    @Published var currentArrowNumber: Int = 1
    @Published var currentEndScores: [Int] = []
    @Published var runningTotal: Int = 0
    @Published var currentEndTotal: Int = 0
    @Published var isEndComplete: Bool = false
    @Published var isRoundComplete: Bool = false
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
 
    private let round: Round
    private let roundRepository: RoundRepository
    private let participantId: String
 
    init(round: Round, roundRepository: RoundRepository, participantId: String = "local_user") {
        self.round = round
        self.roundRepository = roundRepository
        self.participantId = participantId
 
        loadCurrentProgress()
    }
 
    // MARK: - Scoring Actions
 
    func scoreArrow(_ value: Int, isX: Bool = false) {
        guard currentArrowNumber <= round.numArrows else { return }
 
        currentEndScores.append(value)
        currentEndTotal += value
        currentArrowNumber += 1
 
        if currentArrowNumber > round.numArrows {
            isEndComplete = true
        }
    }
 
    func undoLastArrow() {
        guard !currentEndScores.isEmpty else { return }
 
        let lastScore = currentEndScores.removeLast()
        currentEndTotal -= lastScore
        currentArrowNumber -= 1
        isEndComplete = false
    }
 
    func completeEnd() async {
        guard isEndComplete else { return }
 
        isLoading = true
        errorMessage = nil
 
        do {
            // Save end to database
            let nextEndNumber = try await roundRepository.recordCompletedEndAndAdvance(
                roundId: Int64(round.id),
                participantId: participantId,
                endNumber: Int32(currentEndNumber),
                arrows: currentEndScores.map { Int32($0) },
                total: Int32(currentEndTotal)
            )
 
            // Update state
            runningTotal += currentEndTotal
            currentEndScores = []
            currentEndTotal = 0
            currentArrowNumber = 1
            isEndComplete = false
 
            if let next = nextEndNumber {
                currentEndNumber = Int(next)
            } else {
                // Round complete
                isRoundComplete = true
                try await roundRepository.completeRound(roundId: Int32(round.id))
            }
 
        } catch {
            errorMessage = "Failed to save end: \(error.localizedDescription)"
        }
 
        isLoading = false
    }
 
    private func loadCurrentProgress() {
        Task {
            do {
                let ends = try await roundRepository.getEndsForParticipant(
                    roundId: Int64(round.id),
                    participantId: participantId
                )
 
                let completedEnds = ends.filter { $0.isCompleted }
                runningTotal = completedEnds.reduce(0) { $0 + Int($1.totalScore) }
                currentEndNumber = completedEnds.count + 1
 
            } catch {
                errorMessage = "Failed to load progress: \(error.localizedDescription)"
            }
        }
    }
}

ActiveScoringView

struct ActiveScoringView: View {
    @StateObject private var viewModel: ActiveScoringViewModel
    @Environment(\.dismiss) private var dismiss
 
    init(round: Round, roundRepository: RoundRepository) {
        _viewModel = StateObject(wrappedValue: ActiveScoringViewModel(
            round: round,
            roundRepository: roundRepository
        ))
    }
 
    var body: some View {
        VStack(spacing: 0) {
            // Progress header
            EndProgressHeader(
                currentEnd: viewModel.currentEndNumber,
                totalEnds: viewModel.round.numEnds,
                currentArrow: viewModel.currentArrowNumber,
                totalArrows: viewModel.round.numArrows
            )
 
            Divider()
 
            // Running totals
            RunningTotalCard(
                runningTotal: viewModel.runningTotal,
                currentEndTotal: viewModel.currentEndTotal,
                maxPossibleScore: viewModel.round.maxPossibleScore
            )
 
            Divider()
 
            // Score button grid
            ScoreButtonGrid(
                scoringSystem: viewModel.round.scoringSystem,
                onScoreTapped: { value, isX in
                    viewModel.scoreArrow(value, isX: isX)
                }
            )
            .disabled(viewModel.isEndComplete)
 
            // Actions
            HStack(spacing: 16) {
                Button("Undo") {
                    viewModel.undoLastArrow()
                }
                .disabled(viewModel.currentEndScores.isEmpty)
                .buttonStyle(.bordered)
 
                Spacer()
 
                if viewModel.isEndComplete {
                    Button(viewModel.isRoundComplete ? "Finish Round" : "Next End") {
                        Task {
                            await viewModel.completeEnd()
 
                            if viewModel.isRoundComplete {
                                dismiss()
                            }
                        }
                    }
                    .buttonStyle(.borderedProminent)
                    .disabled(viewModel.isLoading)
                }
            }
            .padding()
        }
        .navigationTitle("Score Round")
        .navigationBarTitleDisplayMode(.inline)
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            Button("OK") {
                viewModel.errorMessage = nil
            }
        } message: {
            Text(viewModel.errorMessage ?? "")
        }
    }
}
 
struct ScoreButtonGrid: View {
    let scoringSystem: ScoringSystem
    let onScoreTapped: (Int, Bool) -> Void
 
    var body: some View {
        LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: 12) {
            ForEach(scoringSystem.validValues.reversed(), id: \.self) { value in
                ScoreButton(value: value, isX: value == scoringSystem.maxValue) {
                    onScoreTapped(Int(value), value == scoringSystem.maxValue)
                }
            }
        }
        .padding()
    }
}
 
struct ScoreButton: View {
    let value: Int32
    let isX: Bool
    let action: () -> Void
 
    var body: some View {
        Button(action: action) {
            Text(isX ? "X" : "\(value)")
                .font(.title)
                .fontWeight(.bold)
                .frame(maxWidth: .infinity)
                .frame(height: 60)
                .background(scoreColor)
                .foregroundColor(.white)
                .cornerRadius(12)
        }
    }
 
    private var scoreColor: Color {
        if value >= 9 { return .green }
        if value >= 7 { return .blue }
        if value >= 5 { return .orange }
        return .red
    }
}
 
struct EndProgressHeader: View {
    let currentEnd: Int
    let totalEnds: Int
    let currentArrow: Int
    let totalArrows: Int
 
    var body: some View {
        VStack(spacing: 4) {
            Text("End \(currentEnd) of \(totalEnds)")
                .font(.headline)
            Text("Arrow \(currentArrow) of \(totalArrows)")
                .font(.subheadline)
                .foregroundColor(.secondary)
        }
        .padding()
    }
}
 
struct RunningTotalCard: View {
    let runningTotal: Int
    let currentEndTotal: Int
    let maxPossibleScore: Int
 
    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                Text("Running Total")
                    .font(.caption)
                    .foregroundColor(.secondary)
                Text("\(runningTotal)")
                    .font(.title)
                    .fontWeight(.bold)
            }
 
            Spacer()
 
            VStack(alignment: .trailing) {
                Text("This End")
                    .font(.caption)
                    .foregroundColor(.secondary)
                Text("\(currentEndTotal)")
                    .font(.title2)
                    .foregroundColor(.blue)
            }
        }
        .padding()
    }
}

Success Criteria

  • ✅ Score entry fast and intuitive
  • ✅ Running totals accurate
  • ✅ Undo works correctly
  • ✅ End completion advances properly
  • ✅ Round completion detected
  • ✅ State persists across app sessions

Last Updated: 2025-11-21 Status: Phase 2d planned