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
- ActiveScoringView - Main scoring screen container
- ScoreButtonGrid - Tappable score buttons (0-10 + X)
- EndProgressHeader - Current end/arrow display
- 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