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:
GuestErrorenum with LocalizedError conformance- Guest limits:
maxGuestsPerParticipant = 2,maxTotalGuests = 10 AddGuestSheetSwiftUI 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
| Component | File | Description |
|---|---|---|
| Add Guest UI | Views/TournamentScoring/AddGuestSheet.swift | Form sheet (216 lines) |
| ViewModel | ViewModels/TournamentScoringViewModel.swift | +213 lines for guest logic |
| View | Views/TournamentScoring/TournamentScoringView.swift | +25 lines toolbar/sheet |
| Tests | TournamentScoringViewModelTests.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 = 10Computed 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 charactersFirebase 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
| Feature | iOS | Android |
|---|---|---|
| Max per-user | maxGuestsPerParticipant = 2 | AdminSettings.maxGuestsPerParticipant = 2 |
| Max total | maxTotalGuests = 10 | AdminSettings.maxTotalGuests = 10 |
| ID generation | SessionParticipant.companion.generateGuestId() | Same KMP shared code |
| ID format | guest_ + 16 alphanumeric | Same format |
| Add UI | AddGuestSheet | ParticipantListInput |
| Ownership | addedBy, parentParticipantId | Same fields |
KMP Shared Code
Guest ID generation uses the shared SessionParticipant.companion.generateGuestId() function, ensuring consistent format across platforms.
Test Coverage
GuestErrorTests (6 tests)
| Test | Description |
|---|---|
testNotAuthenticated_hasCorrectMessage | Auth error message |
testMaxGuestsPerUserReached_hasCorrectMessage | Per-user limit message |
testMaxTotalGuestsReached_hasCorrectMessage | Total limit message |
testTournamentNotInProgress_hasCorrectMessage | Tournament state message |
testFirebaseNotInitialized_hasCorrectMessage | Firebase error message |
testFirebaseWriteFailed_includesUnderlyingError | Wrapped error message |
GuestParticipantTests (9 tests)
| Test | Description |
|---|---|
| Guest count calculation | Filters by addedBy |
| Total guest count | Filters by guestParticipant flag |
| canAddGuest true conditions | Tournament in progress, under limits |
| canAddGuest false - no tournament | Nil tournament check |
| canAddGuest false - per-user limit | 2 guests already added |
| canAddGuest false - total limit | 10 total guests |
| canAddGuest false - not in progress | Tournament completed |
| Guest ID format validation | Starts with “guest_“ |
| State initialization after add | ParticipantScoreState created |
Related Documentation
- Multi-Archer Scoring Guide - Phase 6d
- Tournament List Filtering - Phase 6a
- iOS Troubleshooting - Common issues
- KMP iOS Patterns - Integration patterns
Last Updated: 2025-12-04 Status: Phase 6f complete