Round List Implementation Guide

Phase: 2c Estimated Effort: 6-8 hours Prerequisites: Phase 2b (Round Persistence) complete


Overview

Display list of created rounds in RoundsTabView with filtering, sorting, and navigation to details/scoring.

Current State: RoundsTabView shows empty state only Target State: List of rounds with navigation to scoring/details


Step 1: Create RoundListViewModel

Responsibilities

  • Fetch rounds from RoundRepository
  • Filter by status (planned, in-progress, completed)
  • Sort by date (most recent first)
  • Handle loading and error states
  • React to database changes

Implementation

import Shared
import Combine
 
class RoundListViewModel: ObservableObject {
    @Published var rounds: [Round] = []
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    @Published var selectedFilter: RoundFilter = .all
 
    private let roundRepository: RoundRepository
    private var cancellables = Set<AnyCancellable>()
 
    enum RoundFilter: String, CaseIterable {
        case all = "All"
        case planned = "Planned"
        case inProgress = "In Progress"
        case completed = "Completed"
    }
 
    init(roundRepository: RoundRepository) {
        self.roundRepository = roundRepository
        loadRounds()
    }
 
    func loadRounds() {
        isLoading = true
        errorMessage = nil
 
        Task {
            do {
                // Option 1: Using Flow (if KMP-NativeCoroutines configured)
                for await roundList in roundRepository.allRounds {
                    await MainActor.run {
                        self.rounds = filterAndSortRounds(roundList)
                        self.isLoading = false
                    }
                }
 
                // Option 2: Using completion handler
                // roundRepository.getAllRounds { flow, error in ... }
 
            } catch {
                await MainActor.run {
                    self.errorMessage = "Failed to load rounds: \(error.localizedDescription)"
                    self.isLoading = false
                }
            }
        }
    }
 
    private func filterAndSortRounds(_ rounds: [Round]) -> [Round] {
        let filtered: [Round]
 
        switch selectedFilter {
        case .all:
            filtered = rounds
        case .planned:
            filtered = rounds.filter { $0.status == .planned }
        case .inProgress:
            filtered = rounds.filter { $0.status == .inProgress }
        case .completed:
            filtered = rounds.filter { $0.status == .completed }
        }
 
        // Sort by creation date, most recent first
        return filtered.sorted { $0.createdAt > $1.createdAt }
    }
 
    func deleteRound(_ round: Round) async {
        do {
            try await roundRepository.deleteRound(round: round)
            // Rounds list will auto-update via Flow
        } catch {
            errorMessage = "Failed to delete round: \(error.localizedDescription)"
        }
    }
}

Step 2: Create RoundListView Component

UI Design

Round Card Layout:

┌─────────────────────────────────────┐
│ Round Name                    Date  │
│ Distance • Target Size              │
│ Score: XXX / Max   Ends: X / XX    │
└─────────────────────────────────────┘

Implementation

import SwiftUI
import Shared
 
struct RoundListView: View {
    @ObservedObject var viewModel: RoundListViewModel
 
    var body: some View {
        Group {
            if viewModel.isLoading && viewModel.rounds.isEmpty {
                ProgressView("Loading rounds...")
            } else if let errorMessage = viewModel.errorMessage {
                VStack(spacing: 16) {
                    Image(systemName: "exclamationmark.triangle")
                        .font(.system(size: 48))
                        .foregroundColor(.orange)
                    Text(errorMessage)
                        .multilineTextAlignment(.center)
                    Button("Retry") {
                        viewModel.loadRounds()
                    }
                    .buttonStyle(.bordered)
                }
                .padding()
            } else {
                ScrollView {
                    LazyVStack(spacing: 12) {
                        ForEach(viewModel.rounds, id: \.id) { round in
                            RoundCard(round: round)
                                .onTapGesture {
                                    handleRoundTap(round)
                                }
                                .contextMenu {
                                    roundContextMenu(for: round)
                                }
                        }
                    }
                    .padding()
                }
                .refreshable {
                    viewModel.loadRounds()
                }
            }
        }
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                Menu {
                    ForEach(RoundListViewModel.RoundFilter.allCases, id: \.self) { filter in
                        Button(filter.rawValue) {
                            viewModel.selectedFilter = filter
                        }
                    }
                } label: {
                    Image(systemName: "line.3.horizontal.decrease.circle")
                }
            }
        }
    }
 
    private func handleRoundTap(_ round: Round) {
        switch round.status {
        case .planned:
            // Navigate to Active Scoring (Phase 2d)
            break
        case .inProgress:
            // Resume scoring (Phase 2d)
            break
        case .completed:
            // Show round details (Phase 2e)
            break
        default:
            break
        }
    }
 
    @ViewBuilder
    private func roundContextMenu(for round: Round) -> some View {
        Button(role: .destructive) {
            Task {
                await viewModel.deleteRound(round)
            }
        } label: {
            Label("Delete", systemImage: "trash")
        }
    }
}

Round Card Component

struct RoundCard: View {
    let round: Round
 
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack {
                Text(round.roundName)
                    .font(.headline)
                Spacer()
                Text(formattedDate(round.createdAt))
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
 
            HStack {
                Label("\(round.distance) \(round.distanceUnit)", systemImage: "arrow.right")
                Text("•")
                Label("\(round.targetSize)cm", systemImage: "target")
            }
            .font(.subheadline)
            .foregroundColor(.secondary)
 
            HStack {
                Text("Score: \(round.totalScore) / \(round.maxPossibleScore)")
                Spacer()
                Text("Ends: \(round.completedEnds) / \(round.numEnds)")
            }
            .font(.caption)
            .foregroundColor(.blue)
 
            // Status badge
            statusBadge(for: round.status)
        }
        .padding()
        .background(Color(.systemBackground))
        .cornerRadius(12)
        .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2)
    }
 
    @ViewBuilder
    private func statusBadge(for status: RoundStatus) -> some View {
        HStack {
            Circle()
                .fill(statusColor(for: status))
                .frame(width: 8, height: 8)
            Text(statusText(for: status))
                .font(.caption2)
                .foregroundColor(.secondary)
        }
    }
 
    private func statusColor(for status: RoundStatus) -> Color {
        switch status {
        case .planned: return .orange
        case .inProgress: return .green
        case .completed: return .blue
        case .paused: return .yellow
        case .cancelled: return .red
        default: return .gray
        }
    }
 
    private func statusText(for status: RoundStatus) -> String {
        switch status {
        case .planned: return "Planned"
        case .inProgress: return "In Progress"
        case .completed: return "Completed"
        case .paused: return "Paused"
        case .cancelled: return "Cancelled"
        default: return "Unknown"
        }
    }
 
    private func formattedDate(_ timestamp: Int64) -> String {
        let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
        let formatter = DateFormatter()
        formatter.dateStyle = .short
        formatter.timeStyle = .none
        return formatter.string(from: date)
    }
}

Step 3: Update RoundsTabView

Integration

struct RoundsTabView: View {
    @StateObject private var roundListViewModel = RoundListViewModel(
        roundRepository: IosDependencies.shared.roundRepository
    )
    @State private var showingCreateRound = false
 
    var body: some View {
        NavigationView {
            Group {
                if roundListViewModel.rounds.isEmpty && !roundListViewModel.isLoading {
                    // Empty state
                    VStack(spacing: 24) {
                        Image(systemName: "target")
                            .font(.system(size: 72))
                            .foregroundColor(.gray)
                        Text("No Rounds Yet")
                            .font(.title2)
                            .foregroundColor(.secondary)
                        Text("Create your first round to start tracking your practice sessions")
                            .multilineTextAlignment(.center)
                            .foregroundColor(.secondary)
                            .padding(.horizontal)
                        Button("Create Round") {
                            showingCreateRound = true
                        }
                        .buttonStyle(.borderedProminent)
                    }
                } else {
                    // Round list
                    RoundListView(viewModel: roundListViewModel)
                }
            }
            .navigationTitle("Rounds")
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        showingCreateRound = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $showingCreateRound) {
                RoundCreationView()
            }
        }
    }
}

Step 4: Testing Checklist

Functional Tests

  • ✅ List displays all created rounds
  • ✅ Rounds sorted by date (most recent first)
  • ✅ Filter by status works correctly
  • ✅ Empty state shown when no rounds exist
  • ✅ Pull-to-refresh updates list
  • ✅ Tapping round navigates appropriately (Phase 2d/2e)
  • ✅ Delete round removes from list

UI Tests (Phase 2f)

func testRoundListDisplaysRounds() {
    // Given: Database has 3 rounds
    // When: Navigate to Rounds tab
    // Then: List shows 3 round cards
}
 
func testFilterByStatus() {
    // Given: Database has mixed status rounds
    // When: Select "Completed" filter
    // Then: Only completed rounds shown
}
 
func testDeleteRound() {
    // Given: Round list visible
    // When: Swipe to delete round
    // Then: Round removed from list and database
}

Success Criteria

Phase 2c complete when:

  • ✅ Round list displays all rounds from database
  • ✅ List updates reactively when rounds added/removed
  • ✅ Filter and sort work correctly
  • ✅ Empty state handled gracefully
  • ✅ Navigation prepared for Phases 2d/2e
  • ✅ Pull-to-refresh functional

Next Steps

  • Proceed to Phase 2d: Active Scoring
  • Implement navigation from round list to scoring screen
  • Handle round status transitions

Last Updated: 2025-11-21 Status: Phase 2c planned