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
| Component | File | Lines | Description |
|---|---|---|---|
| ViewModel | ViewModels/TournamentListViewModel.swift | 217 | Core logic with Combine |
| View | TournamentListView.swift | 145 | UI with segmented picker |
| Bridge | DI/TournamentRepositoryBridge.swift | 285 | Firebase adapter + parser |
| Protocol | DI/Protocols.swift | +31 | TournamentRepositoryProtocol |
| Tests | TournamentListViewModelTests.swift | 411 | 16 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)
}NavigationStack Compatibility
@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)
| Category | Tests | Description |
|---|---|---|
| Initial State | 3 | Default values, empty state |
| Load Success | 3 | Loading, filtering, counts |
| Load Error | 2 | Error handling |
| Tab Filtering | 4 | Active/Completed filtering |
| Tab Counts | 2 | getActiveCount, getCompletedCount |
| Refresh | 2 | Pull-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
| Tab | Status Values | Description |
|---|---|---|
| Active (0) | OPEN, IN_PROGRESS | Tournaments accepting participants or in progress |
| Completed (1) | COMPLETED | Finished tournaments |
KMP Enum Comparison Pattern:
// Use .name property for value comparison
tournament.status.name == TournamentStatus.completed.nameFeature Parity
| Feature | Android | iOS | Notes |
|---|---|---|---|
| Tab Filtering | Yes | Yes | Segmented picker |
| Tab Counts | Yes | Yes | Badge in tab label |
| Empty States | Yes | Yes | Per-tab messaging |
| Pull-to-Refresh | Yes | Yes | .refreshable modifier |
| Navigation | Yes | Yes | NavigationStack/View |
Related Documentation
- iOS Troubleshooting - Common issues
- KMP iOS Patterns - Enum comparison
- Phase 5 Analytics - Similar patterns
Last Updated: 2025-12-01 Status: Phase 6a complete