Tournament List Filtering Guide

Phase: 6a Status: Complete PR: #329


Overview

Phase 6a adds Active/Completed tab filtering to the iOS TournamentListView, achieving feature parity with Android’s tournament list functionality. The implementation uses a Combine-based reactive pattern with dependency injection for testability.

Key Features:

  • Segmented picker for Active/Completed tab filtering
  • Tab count badges showing tournament counts
  • Per-tab empty states
  • Pull-to-refresh support
  • Firebase callback pattern for production stability
  • Full TDD implementation (16 tests)

Architecture

Component Structure

TournamentListView
├── TournamentListViewModel
│   ├── TournamentRepositoryProtocol (DI)
│   │   ├── TournamentRepositoryBridge (production)
│   │   │   └── TournamentParser (shared)
│   │   └── MockTournamentRepository (testing)
│   └── Combine Publishers (reactive filtering)
├── Segmented Picker (tab selection)
├── Tournament List (filtered)
└── Empty States (per tab)

Data Flow

User selects tab → selectedTabIndex changes → Combine observer triggers
                                                      ↓
                                            updateFilteredTournaments()
                                                      ↓
                                            filteredTournaments published
                                                      ↓
                                            SwiftUI view updates

Key Files

ComponentFileLinesDescription
ViewModelViewModels/TournamentListViewModel.swift217Core logic with Combine
ViewTournamentListView.swift145UI with segmented picker
BridgeDI/TournamentRepositoryBridge.swift285Firebase adapter + parser
ProtocolDI/Protocols.swift+31TournamentRepositoryProtocol
TestsTournamentListViewModelTests.swift41116 TDD tests

TournamentListViewModel

Published Properties

@MainActor
class TournamentListViewModel: ObservableObject {
    /// All tournaments from repository
    @Published var tournaments: [Tournament] = []
 
    /// Filtered tournaments based on selected tab
    @Published var filteredTournaments: [Tournament] = []
 
    /// Currently selected tab index (0 = Active, 1 = Completed)
    @Published var selectedTabIndex: Int = 0
 
    /// Loading state
    @Published var isLoading: Bool = false
 
    /// Error message (if any)
    @Published var errorMessage: String? = nil
 
    /// Empty state indicator
    @Published var isEmpty: Bool = true
}

Combine Reactive Filtering

private func setupObservers() {
    // Observe tournaments array and re-filter when it changes
    $tournaments
        .combineLatest($selectedTabIndex)
        .sink { [weak self] _, _ in
            self?.updateFilteredTournaments()
        }
        .store(in: &cancellables)
}
 
private func updateFilteredTournaments() {
    switch selectedTabIndex {
    case 0:
        // Active tab: OPEN and IN_PROGRESS
        filteredTournaments = tournaments.filter { isActiveTournament($0) }
    case 1:
        // Completed tab: COMPLETED only
        filteredTournaments = tournaments.filter { $0.status.name == TournamentStatus.completed.name }
    default:
        filteredTournaments = tournaments
    }
}
 
private func isActiveTournament(_ tournament: Tournament) -> Bool {
    let statusName = tournament.status.name
    return statusName == TournamentStatus.open.name || statusName == TournamentStatus.inProgress.name
}

Tab Count Methods

/// Get count of active tournaments (OPEN + IN_PROGRESS)
func getActiveCount() -> Int {
    return tournaments.filter { isActiveTournament($0) }.count
}
 
/// Get count of completed tournaments
func getCompletedCount() -> Int {
    return tournaments.filter { $0.status.name == TournamentStatus.completed.name }.count
}

Firebase Callback Pattern

Problem

Using Swift async/await with KMP coroutines in production can cause ObjCExportCoroutines crashes.

Solution

Use Firebase’s native callback API for production, async/await only for testing:

/// Synchronous load for production use (called from onAppear)
/// Uses Firebase callback pattern to avoid KMP coroutine issues
func loadTournamentsSync() {
    loadFromFirebase()
}
 
/// Load from Firebase using callback pattern (production)
private func loadFromFirebase() {
    isLoading = true
    errorMessage = nil
 
    guard FirebaseApp.app() != nil else {
        errorMessage = "Firebase is not configured."
        isLoading = false
        return
    }
 
    let db = Firestore.firestore()
 
    db.collection("tournaments")
        .whereField("public", isEqualTo: true)
        .limit(to: 50)
        .getDocuments { [weak self] (querySnapshot, error) in
            guard let self = self else { return }
 
            Task { @MainActor in
                if let error = error {
                    self.errorMessage = "Failed to load tournaments: \(error.localizedDescription)"
                    self.tournaments = []
                    self.isEmpty = true
                } else if let documents = querySnapshot?.documents {
                    let allTournaments = documents.compactMap { doc -> Tournament? in
                        TournamentParser.parse(document: doc)
                    }
                    self.tournaments = allTournaments
                    self.isEmpty = allTournaments.isEmpty
                    self.updateFilteredTournaments()
                }
                self.isLoading = false
            }
        }
}

Testing with Async/Await

For unit tests, inject a mock repository that supports async/await:

/// Load from test repository using async/await
private func loadFromRepository(_ repository: any TournamentRepositoryProtocol) async {
    isLoading = true
    errorMessage = nil
 
    do {
        let allTournaments = try await repository.getPublicTournaments()
        tournaments = allTournaments
        isEmpty = allTournaments.isEmpty
        updateFilteredTournaments()
        isLoading = false
    } catch {
        errorMessage = "Failed to load tournaments: \(error.localizedDescription)"
        tournaments = []
        isEmpty = true
        isLoading = false
    }
}

TournamentListView

Tab Picker

private var tabPicker: some View {
    Picker("Filter", selection: $viewModel.selectedTabIndex) {
        Text("Active (\(viewModel.getActiveCount()))").tag(0)
        Text("Completed (\(viewModel.getCompletedCount()))").tag(1)
    }
    .pickerStyle(.segmented)
    .padding(.horizontal)
    .padding(.vertical, 8)
}

Per-Tab Empty States

private var emptyView: some View {
    VStack(spacing: 16) {
        Image(systemName: viewModel.selectedTabIndex == 0 ? "target" : "checkmark.circle")
            .font(.system(size: 48))
            .foregroundColor(.gray)
        Text(viewModel.selectedTabIndex == 0 ? "No Active Tournaments" : "No Completed Tournaments")
            .font(.headline)
        Text(viewModel.selectedTabIndex == 0
             ? "Check back later for upcoming tournaments"
             : "Completed tournaments will appear here")
            .font(.subheadline)
            .foregroundColor(.secondary)
            .multilineTextAlignment(.center)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var navigationContainer: some View {
    if #available(iOS 16.0, *) {
        NavigationStack {
            contentView
        }
    } else {
        NavigationView {
            contentView
        }
    }
}

TournamentRepositoryBridge

Shared TournamentParser

Extracted from TournamentDetailViewModel for reuse:

/// Shared parser for Firestore → Tournament conversion
class TournamentParser {
    static func parse(document: DocumentSnapshot) -> Tournament? {
        guard let data = document.data() else { return nil }
 
        let id = document.documentID
        let name = data["name"] as? String ?? ""
        let location = data["location"] as? String ?? ""
        let statusString = data["status"] as? String ?? "OPEN"
        let status = parseStatus(statusString)
        let isPublic = data["public"] as? Bool ?? false
        let currentParticipants = data["currentParticipants"] as? Int32 ?? 0
        let maxParticipants = data["maxParticipants"] as? Int32 ?? 0
        // ... additional fields
 
        return Tournament(
            id: id,
            name: name,
            location: location,
            status: status,
            isPublic: isPublic,
            currentParticipants: currentParticipants,
            maxParticipants: maxParticipants,
            // ...
        )
    }
 
    private static func parseStatus(_ statusString: String) -> TournamentStatus {
        switch statusString.uppercased() {
        case "OPEN": return TournamentStatus.open
        case "IN_PROGRESS": return TournamentStatus.inProgress
        case "COMPLETED": return TournamentStatus.completed
        case "CANCELLED": return TournamentStatus.cancelled
        default: return TournamentStatus.open
        }
    }
}

FirebaseTournamentRepository

class FirebaseTournamentRepository: TournamentRepositoryProtocol {
    private let firestore = Firestore.firestore()
 
    func getPublicTournaments() async throws -> [Tournament] {
        let snapshot = try await firestore.collection("tournaments")
            .whereField("public", isEqualTo: true)
            .limit(to: 50)
            .getDocuments()
 
        return snapshot.documents.compactMap { TournamentParser.parse(document: $0) }
    }
 
    func getTournamentById(id: String) async throws -> Tournament? {
        let document = try await firestore.collection("tournaments").document(id).getDocument()
        return TournamentParser.parse(document: document)
    }
}

Protocol Design

/// Protocol for tournament data access
/// Supports both Firebase (production) and mock (testing) implementations
protocol TournamentRepositoryProtocol {
    /// Get all public tournaments
    func getPublicTournaments() async throws -> [Tournament]
 
    /// Get tournament by ID
    func getTournamentById(id: String) async throws -> Tournament?
}

Test Coverage

TournamentListViewModelTests (16 tests)

CategoryTestsDescription
Initial State3Default values, empty state
Load Success3Loading, filtering, counts
Load Error2Error handling
Tab Filtering4Active/Completed filtering
Tab Counts2getActiveCount, getCompletedCount
Refresh2Pull-to-refresh

MockTournamentRepository

class MockTournamentRepository: TournamentRepositoryProtocol {
    var tournamentsToReturn: [Tournament] = []
    var shouldThrowError: Bool = false
    var errorToThrow: Error = NSError(domain: "test", code: 1)
    var getPublicTournamentsCallCount = 0
 
    func getPublicTournaments() async throws -> [Tournament] {
        getPublicTournamentsCallCount += 1
        if shouldThrowError { throw errorToThrow }
        return tournamentsToReturn
    }
 
    func getTournamentById(id: String) async throws -> Tournament? {
        if shouldThrowError { throw errorToThrow }
        return tournamentsToReturn.first { $0.id == id }
    }
}

Test Factory

extension Tournament {
    static func mock(
        id: String = "test-1",
        name: String = "Test Tournament",
        status: TournamentStatus = .open
    ) -> Tournament {
        Tournament(
            id: id,
            name: name,
            location: "Test Location",
            status: status,
            isPublic: true,
            currentParticipants: 5,
            maxParticipants: 10,
            // ...
        )
    }
}

Tab Filtering Logic

TabStatus ValuesDescription
Active (0)OPEN, IN_PROGRESSTournaments accepting participants or in progress
Completed (1)COMPLETEDFinished tournaments

KMP Enum Comparison Pattern:

// Use .name property for value comparison
tournament.status.name == TournamentStatus.completed.name

Feature Parity

FeatureAndroidiOSNotes
Tab FilteringYesYesSegmented picker
Tab CountsYesYesBadge in tab label
Empty StatesYesYesPer-tab messaging
Pull-to-RefreshYesYes.refreshable modifier
NavigationYesYesNavigationStack/View


Last Updated: 2025-12-01 Status: Phase 6a complete