Guest Archers Implementation Guide

Phase: 6f Status: Complete PR: #352


Overview

Phase 6f implements guest archer functionality for iOS tournament scoring. Users can add guest archers they will score for during tournament sessions, with per-user and per-tournament limits. The implementation matches Android’s ParticipantListInput functionality.

Key Features:

  • GuestError enum with LocalizedError conformance
  • Guest limits: maxGuestsPerParticipant = 2, maxTotalGuests = 10
  • AddGuestSheet SwiftUI form with validation
  • KMP shared code usage for guest ID generation
  • Firebase Firestore integration for guest persistence
  • Accessibility support with .accessibilityElement(children: .combine)

Architecture

Component Structure

TournamentScoringView
├── Toolbar "Add Guest" button (person.badge.plus)
└── AddGuestSheet (modal)
    ├── Name TextField with validation
    ├── Remaining slots display
    ├── Info sections (What/How/Leaderboard)
    └── Error display
        │
        ▼
TournamentScoringViewModel.addGuestParticipant(name:)
    ├── Validation (auth, limits, tournament state)
    ├── SessionParticipant.companion.generateGuestId()
    ├── Firebase Firestore write
    └── Local state update

Data Flow

User taps "Add Guest"
    │
    ▼
AddGuestSheet presented
    │
    ├─► Name input (max 50 chars)
    ├─► Remaining slots shown
    │
User taps "Add"
    │
    ▼
Validation in AddGuestSheet
    ├─► Trim whitespace
    ├─► Check name length
    └─► Check remaining slots
    │
    ▼
viewModel.addGuestParticipant(name:) async throws
    ├─► Auth check (GuestError.notAuthenticated)
    ├─► Per-user limit (GuestError.maxGuestsPerUserReached)
    ├─► Total limit (GuestError.maxTotalGuestsReached)
    ├─► Tournament state (GuestError.tournamentNotInProgress)
    ├─► Generate guest ID (KMP shared)
    ├─► Firebase write
    ├─► Update participants array
    ├─► Initialize ParticipantScoreState
    └─► Update leaderboard
    │
    ▼
AddGuestSheet dismisses on success
OR shows error message on failure

Key Files

ComponentFileDescription
Add Guest UIViews/TournamentScoring/AddGuestSheet.swiftForm sheet (216 lines)
ViewModelViewModels/TournamentScoringViewModel.swift+213 lines for guest logic
ViewViews/TournamentScoring/TournamentScoringView.swift+25 lines toolbar/sheet
TestsTournamentScoringViewModelTests.swift+151 lines (15 new tests)

GuestError Enum

Comprehensive error handling with localized messages:

enum GuestError: Error, LocalizedError, Equatable {
    case notAuthenticated
    case maxGuestsPerUserReached
    case maxTotalGuestsReached
    case tournamentNotInProgress
    case firebaseNotInitialized
    case firebaseWriteFailed(Error)
 
    var errorDescription: String? {
        switch self {
        case .notAuthenticated:
            return "You must be signed in to add a guest."
        case .maxGuestsPerUserReached:
            return "You can only add \(TournamentScoringViewModel.maxGuestsPerParticipant) guest(s) per participant."
        case .maxTotalGuestsReached:
            return "This tournament has reached the maximum of \(TournamentScoringViewModel.maxTotalGuests) total guests."
        case .tournamentNotInProgress:
            return "Guests can only be added to tournaments in progress."
        case .firebaseNotInitialized:
            return "Firebase is not initialized."
        case .firebaseWriteFailed(let error):
            return "Failed to save guest: \(error.localizedDescription)"
        }
    }
}

Custom Equatable: The firebaseWriteFailed case uses simplified equality (doesn’t compare underlying errors) to support testing.


Guest Limits

Static limits matching Android’s AdminSettings:

// In TournamentScoringViewModel
// TODO: Load these from TournamentSettings.adminSettings when settings loading is implemented
 
/// Maximum number of guests a single user can add
/// Matches AdminSettings.maxGuestsPerParticipant default
static let maxGuestsPerParticipant: Int = 2
 
/// Maximum total guests allowed in tournament
/// Matches AdminSettings.maxTotalGuests default
static let maxTotalGuests: Int = 10

Computed Properties

/// Number of guests the current user has added
var guestCount: Int {
    guard let currentUserId = Auth.auth().currentUser?.uid else { return 0 }
    return participants.filter { $0.guestParticipant && $0.addedBy == currentUserId }.count
}
 
/// Total number of guests in the tournament (across all users)
var totalGuestCount: Int {
    return participants.filter { $0.guestParticipant }.count
}
 
/// Whether the current user can add more guests
var canAddGuest: Bool {
    guard let tournament = tournament else { return false }
    let isInProgress = tournament.status.name == TournamentStatus.inProgress.name
    return isInProgress &&
           guestCount < Self.maxGuestsPerParticipant &&
           totalGuestCount < Self.maxTotalGuests
}

AddGuestSheet

Usage

.sheet(isPresented: $showingAddGuest) {
    AddGuestSheet(
        currentGuestCount: viewModel.guestCount,
        maxGuests: TournamentScoringViewModel.maxGuestsPerParticipant
    ) { name in
        try await viewModel.addGuestParticipant(name: name)
    }
}

Error Handling Pattern

Uses throwing callback with local error display:

private func addGuest() {
    let trimmedName = guestName.trimmingCharacters(in: .whitespacesAndNewlines)
 
    // Client-side validation
    guard trimmedName.count <= 50 else {
        errorMessage = "Name must be 50 characters or fewer"
        return
    }
 
    isAdding = true
    errorMessage = nil
 
    Task {
        do {
            try await onAdd(trimmedName)
            await MainActor.run {
                dismiss()
            }
        } catch {
            await MainActor.run {
                isAdding = false
                errorMessage = error.localizedDescription
            }
        }
    }
}

Accessibility

Info rows use combined accessibility:

private func infoRow(icon: String, title: String, description: String) -> some View {
    HStack(alignment: .top, spacing: 12) {
        Image(systemName: icon)
        VStack(alignment: .leading) {
            Text(title)
            Text(description)
        }
    }
    .accessibilityElement(children: .combine)
    .accessibilityLabel("\(title). \(description)")
}

addGuestParticipant Implementation

Guest ID Generation

Uses KMP shared code for consistent ID format:

// Generate unique guest ID using KMP shared implementation
let guestId = SessionParticipant.companion.generateGuestId()
// Format: "guest_" + 16 alphanumeric characters

Firebase Document Structure

Guest participants are written with ownership fields:

let guestData: [String: Any] = [
    "participantId": guestId,
    "tournamentId": tournamentId,
    "displayName": name,
    "status": "JOINED",
    // ... standard participant fields ...
 
    // Guest-specific fields
    "addedBy": currentUserId,        // Owner who added this guest
    "guestParticipant": true,         // Flag for guest status
    "parentParticipantId": currentUserId  // Parent for scoring permissions
]

Field Conventions:

  • Required fields are always included
  • Optional fields with nil values are omitted (not set to null)
  • Numeric fields use appropriate types (Int vs Double)

State Updates

After Firebase write, local state is updated:

// Update local state
participants.append(guestParticipant)
 
// Initialize scoring state for the new guest
participantStates[guestId] = ParticipantScoreState(participantId: guestId)
 
// Update leaderboard to include the new guest
updateLeaderboard()

UI Integration

Toolbar Button

Conditionally shown when guests can be added:

.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
        HStack(spacing: 16) {
            // Add Guest button (only shown when guests can be added)
            if viewModel.canAddGuest {
                Button(action: { showingAddGuest = true }) {
                    Image(systemName: "person.badge.plus")
                }
            }
 
            // Leaderboard toggle
            Button(action: { showingLeaderboard.toggle() }) {
                Image(systemName: showingLeaderboard ? "xmark" : "trophy")
            }
        }
    }
}

Platform Parity

iOS ↔ Android Alignment

FeatureiOSAndroid
Max per-usermaxGuestsPerParticipant = 2AdminSettings.maxGuestsPerParticipant = 2
Max totalmaxTotalGuests = 10AdminSettings.maxTotalGuests = 10
ID generationSessionParticipant.companion.generateGuestId()Same KMP shared code
ID formatguest_ + 16 alphanumericSame format
Add UIAddGuestSheetParticipantListInput
OwnershipaddedBy, parentParticipantIdSame fields

KMP Shared Code

Guest ID generation uses the shared SessionParticipant.companion.generateGuestId() function, ensuring consistent format across platforms.


Test Coverage

GuestErrorTests (6 tests)

TestDescription
testNotAuthenticated_hasCorrectMessageAuth error message
testMaxGuestsPerUserReached_hasCorrectMessagePer-user limit message
testMaxTotalGuestsReached_hasCorrectMessageTotal limit message
testTournamentNotInProgress_hasCorrectMessageTournament state message
testFirebaseNotInitialized_hasCorrectMessageFirebase error message
testFirebaseWriteFailed_includesUnderlyingErrorWrapped error message

GuestParticipantTests (9 tests)

TestDescription
Guest count calculationFilters by addedBy
Total guest countFilters by guestParticipant flag
canAddGuest true conditionsTournament in progress, under limits
canAddGuest false - no tournamentNil tournament check
canAddGuest false - per-user limit2 guests already added
canAddGuest false - total limit10 total guests
canAddGuest false - not in progressTournament completed
Guest ID format validationStarts with “guest_“
State initialization after addParticipantScoreState created


Last Updated: 2025-12-04 Status: Phase 6f complete