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