From 8bba5a1592d67777b5f3f3028aa3a8d2793bd143 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 09:13:10 -0600 Subject: [PATCH] docs(plans): add brutalist app-wide implementation plan Detailed step-by-step plan for extending brutalist style to: - TripDetailView - SavedTripsListView - ScheduleListView - SettingsView Includes StyleProvider protocol, adaptive routers, and complete code snippets for each task. Co-Authored-By: Claude Opus 4.5 --- ...01-16-brutalist-app-wide-implementation.md | 1089 +++++++++++++++++ 1 file changed, 1089 insertions(+) create mode 100644 docs/plans/2026-01-16-brutalist-app-wide-implementation.md diff --git a/docs/plans/2026-01-16-brutalist-app-wide-implementation.md b/docs/plans/2026-01-16-brutalist-app-wide-implementation.md new file mode 100644 index 0000000..acf19aa --- /dev/null +++ b/docs/plans/2026-01-16-brutalist-app-wide-implementation.md @@ -0,0 +1,1089 @@ +# Brutalist App-Wide Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extend the Brutalist design style from home-screen-only to work app-wide (TripDetailView, ScheduleListView, SavedTripsListView, SettingsView). + +**Architecture:** Adaptive view routers that switch between Classic and Brutalist variants based on `DesignStyleManager.shared.currentStyle`. StyleProvider protocol provides shared styling constants. Brutalist uses Theme colors but with monospace fonts, sharp corners, and borders. + +**Tech Stack:** SwiftUI, @Observable pattern, Theme system, DesignStyleManager + +--- + +## Task 1: Create StyleProvider Protocol + +**Files:** +- Create: `SportsTime/Core/Design/StyleProvider.swift` + +**Step 1: Create the StyleProvider file** + +```swift +// +// StyleProvider.swift +// SportsTime +// +// Provides style-specific constants for shared components. +// + +import SwiftUI + +/// Protocol for style-specific visual properties +protocol StyleProvider { + // Shape + var cornerRadius: CGFloat { get } + var borderWidth: CGFloat { get } + + // Typography + var fontDesign: Font.Design { get } + var usesUppercase: Bool { get } + var headerTracking: CGFloat { get } + + // Effects + var usesGradientBackgrounds: Bool { get } + var usesSoftShadows: Bool { get } + + // Helpers + func flatBackground(_ colorScheme: ColorScheme) -> Color +} + +// MARK: - Classic Style + +struct ClassicStyle: StyleProvider { + let cornerRadius: CGFloat = 12 + let borderWidth: CGFloat = 0 + let fontDesign: Font.Design = .default + let usesUppercase = false + let headerTracking: CGFloat = 0 + let usesGradientBackgrounds = true + let usesSoftShadows = true + + func flatBackground(_ colorScheme: ColorScheme) -> Color { + Theme.cardBackground(colorScheme) + } +} + +// MARK: - Brutalist Style + +struct BrutalistStyle: StyleProvider { + let cornerRadius: CGFloat = 0 + let borderWidth: CGFloat = 1 + let fontDesign: Font.Design = .monospaced + let usesUppercase = true + let headerTracking: CGFloat = 2 + let usesGradientBackgrounds = false + let usesSoftShadows = false + + func flatBackground(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? .black : Color(white: 0.95) + } +} + +// MARK: - UIDesignStyle Extension + +extension UIDesignStyle { + /// Returns the appropriate StyleProvider for this design style + var styleProvider: StyleProvider { + switch self { + case .brutalist: + return BrutalistStyle() + default: + return ClassicStyle() + } + } +} +``` + +**Step 2: Verify the file compiles** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add SportsTime/Core/Design/StyleProvider.swift +git commit -m "$(cat <<'EOF' +feat(design): add StyleProvider protocol for style-specific constants + +Introduces ClassicStyle and BrutalistStyle implementations for shared +component styling across the app. + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 2: Create TripDetailView Brutalist Variant + +**Files:** +- Create: `SportsTime/Features/Trip/Views/Variants/Brutalist/TripDetailView_Brutalist.swift` +- Create: `SportsTime/Features/Trip/Views/Variants/Classic/TripDetailView_Classic.swift` +- Create: `SportsTime/Features/Trip/Views/AdaptiveTripDetailView.swift` +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` (extract to Classic) + +### Step 1: Create directory structure + +```bash +mkdir -p SportsTime/Features/Trip/Views/Variants/Classic +mkdir -p SportsTime/Features/Trip/Views/Variants/Brutalist +``` + +### Step 2: Create AdaptiveTripDetailView router + +Create `SportsTime/Features/Trip/Views/AdaptiveTripDetailView.swift`: + +```swift +// +// AdaptiveTripDetailView.swift +// SportsTime +// +// Routes to the appropriate trip detail variant based on the selected design style. +// + +import SwiftUI + +struct AdaptiveTripDetailView: View { + let trip: Trip + let games: [String: RichGame]? + + /// Initialize with trip and games dictionary + init(trip: Trip, games: [String: RichGame]) { + self.trip = trip + self.games = games + } + + /// Initialize with just trip - games will be loaded from AppDataProvider + init(trip: Trip) { + self.trip = trip + self.games = nil + } + + var body: some View { + switch DesignStyleManager.shared.currentStyle { + case .brutalist: + if let games = games { + TripDetailView_Brutalist(trip: trip, games: games) + } else { + TripDetailView_Brutalist(trip: trip) + } + default: + if let games = games { + TripDetailView_Classic(trip: trip, games: games) + } else { + TripDetailView_Classic(trip: trip) + } + } + } +} +``` + +### Step 3: Copy existing TripDetailView to Classic variant + +Copy `TripDetailView.swift` to `Variants/Classic/TripDetailView_Classic.swift` and rename the struct to `TripDetailView_Classic`. + +Key changes: +- Rename `struct TripDetailView` → `struct TripDetailView_Classic` +- Rename `struct DaySection` → `struct DaySection_Classic` +- Rename `struct GameRow` → `struct GameRow_Classic` +- Rename `struct TravelSection` → `struct TravelSection_Classic` +- Rename `struct EVChargerRow` → `struct EVChargerRow_Classic` +- Keep `struct ShareSheet` as-is (shared utility) +- Keep `enum ItinerarySection` as-is (shared model) +- Remove the #Preview at the bottom + +### Step 4: Create TripDetailView_Brutalist + +Create `SportsTime/Features/Trip/Views/Variants/Brutalist/TripDetailView_Brutalist.swift`: + +```swift +// +// TripDetailView_Brutalist.swift +// SportsTime +// +// BRUTALIST: Raw, unpolished, anti-design trip details. +// Monospace typography, harsh borders, ticket stub aesthetics. +// + +import SwiftUI +import SwiftData +import MapKit + +struct TripDetailView_Brutalist: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.colorScheme) private var colorScheme + + let trip: Trip + private let providedGames: [String: RichGame]? + + @Query private var savedTrips: [SavedTrip] + @State private var showProPaywall = false + @State private var showExportSheet = false + @State private var exportURL: URL? + @State private var isExporting = false + @State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress? + @State private var mapCameraPosition: MapCameraPosition = .automatic + @State private var isSaved = false + @State private var routePolylines: [MKPolyline] = [] + @State private var isLoadingRoutes = false + @State private var loadedGames: [String: RichGame] = [:] + @State private var isLoadingGames = false + + private let exportService = ExportService() + private let dataProvider = AppDataProvider.shared + + private var games: [String: RichGame] { + providedGames ?? loadedGames + } + + private var bgColor: Color { + colorScheme == .dark ? .black : Color(white: 0.95) + } + + private var textColor: Color { + colorScheme == .dark ? .white : .black + } + + init(trip: Trip, games: [String: RichGame]) { + self.trip = trip + self.providedGames = games + } + + init(trip: Trip) { + self.trip = trip + self.providedGames = nil + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + // HERO MAP - Sharp edges + heroMapSection + .frame(height: 280) + + // HEADER + tripHeader + .padding(.horizontal, 16) + .padding(.top, 24) + + perforatedDivider + .padding(.vertical, 24) + + // STATS ROW + statsRow + .padding(.horizontal, 16) + + perforatedDivider + .padding(.vertical, 24) + + // SCORE CARD + if let score = trip.score { + scoreCard(score) + .padding(.horizontal, 16) + + perforatedDivider + .padding(.vertical, 24) + } + + // ITINERARY + itinerarySection + .padding(.horizontal, 16) + .padding(.bottom, 32) + } + } + .background(bgColor) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + ShareButton(trip: trip, style: .icon) + .foregroundStyle(Theme.warmOrange) + + Button { + if StoreManager.shared.isPro { + Task { await exportPDF() } + } else { + showProPaywall = true + } + } label: { + HStack(spacing: 2) { + Image(systemName: "doc.fill") + if !StoreManager.shared.isPro { + ProBadge() + } + } + .foregroundStyle(Theme.warmOrange) + } + } + } + .sheet(isPresented: $showExportSheet) { + if let url = exportURL { + ShareSheet(items: [url]) + } + } + .sheet(isPresented: $showProPaywall) { + PaywallView() + } + .onAppear { checkIfSaved() } + .task { await loadGamesIfNeeded() } + .overlay { + if isExporting { + exportProgressOverlay + } + } + } + + // MARK: - Perforated Divider + + private var perforatedDivider: some View { + HStack(spacing: 8) { + ForEach(0..<25, id: \.self) { _ in + Circle() + .fill(textColor.opacity(0.3)) + .frame(width: 6, height: 6) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } + + // MARK: - Hero Map Section + + private var heroMapSection: some View { + ZStack(alignment: .topTrailing) { + Map(position: $mapCameraPosition, interactionModes: []) { + ForEach(stopCoordinates.indices, id: \.self) { index in + let stop = stopCoordinates[index] + Annotation(stop.name, coordinate: stop.coordinate) { + Rectangle() + .fill(index == 0 ? Theme.warmOrange : Theme.routeGold) + .frame(width: 12, height: 12) + } + } + + ForEach(routePolylines.indices, id: \.self) { index in + MapPolyline(routePolylines[index]) + .stroke(Theme.routeGold, lineWidth: 3) + } + } + .mapStyle(.standard(elevation: .flat)) + + // Save button - sharp rectangle + Button { toggleSaved() } label: { + Image(systemName: isSaved ? "heart.fill" : "heart") + .font(.title3) + .foregroundStyle(isSaved ? .red : textColor) + .padding(12) + .background(bgColor) + .border(textColor.opacity(0.3), width: 1) + } + .padding(12) + } + .task { + updateMapRegion() + await fetchDrivingRoutes() + } + } + + // MARK: - Header + + private var tripHeader: some View { + VStack(alignment: .leading, spacing: 8) { + // Date range + Text(trip.formattedDateRange.uppercased()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + .tracking(2) + + // Route as arrow chain + Text(trip.stops.map { $0.city.uppercased() }.joined(separator: " → ")) + .font(.system(.title3, design: .monospaced).bold()) + .foregroundStyle(textColor) + .lineLimit(2) + + // Sport badges - bordered rectangles + HStack(spacing: 8) { + ForEach(Array(trip.uniqueSports), id: \.self) { sport in + HStack(spacing: 4) { + Image(systemName: sport.iconName) + .font(.caption2) + Text(sport.rawValue.uppercased()) + .font(.system(.caption2, design: .monospaced)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .foregroundStyle(sport.themeColor) + .border(sport.themeColor.opacity(0.5), width: 1) + } + } + } + } + + // MARK: - Stats Row + + private var statsRow: some View { + HStack(spacing: 12) { + brutalistStat(value: "\(trip.tripDuration)", label: "DAYS") + brutalistStat(value: "\(trip.stops.count)", label: "CITIES") + brutalistStat(value: "\(trip.totalGames)", label: "GAMES") + } + } + + private func brutalistStat(value: String, label: String) -> some View { + VStack(spacing: 4) { + Text(value) + .font(.system(.title, design: .monospaced).bold()) + .foregroundStyle(textColor) + Text("[ \(label) ]") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .border(textColor.opacity(0.2), width: 1) + } + + // MARK: - Score Card + + private func scoreCard(_ score: TripScore) -> some View { + VStack(spacing: 16) { + HStack { + Text("[ TRIP SCORE ]") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + Spacer() + Text(score.scoreGrade) + .font(.system(size: 48, weight: .black, design: .monospaced)) + .foregroundStyle(Theme.warmOrange) + } + + HStack(spacing: 8) { + scoreItem(label: "GAMES", value: score.gameQualityScore) + scoreItem(label: "ROUTE", value: score.routeEfficiencyScore) + scoreItem(label: "BALANCE", value: score.leisureBalanceScore) + scoreItem(label: "PREFS", value: score.preferenceAlignmentScore) + } + } + .padding(16) + .border(textColor.opacity(0.2), width: 2) + } + + private func scoreItem(label: String, value: Double) -> some View { + VStack(spacing: 4) { + Text(String(format: "%.0f", value)) + .font(.system(.headline, design: .monospaced).bold()) + .foregroundStyle(textColor) + Text(label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.4)) + } + .frame(maxWidth: .infinity) + } + + // MARK: - Itinerary + + private var itinerarySection: some View { + VStack(alignment: .leading, spacing: 16) { + Text("[ ITINERARY ]") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + + if isLoadingGames { + Text("LOADING...") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + } else { + ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in + switch section { + case .day(let dayNumber, let date, let gamesOnDay): + DaySection_Brutalist( + dayNumber: dayNumber, + date: date, + games: gamesOnDay + ) + case .travel(let segment): + TravelSection_Brutalist(segment: segment) + } + } + } + } + } + + // MARK: - Export Progress Overlay + + private var exportProgressOverlay: some View { + ZStack { + Color.black.opacity(0.8) + .ignoresSafeArea() + + VStack(spacing: 16) { + Text("CREATING PDF") + .font(.system(.headline, design: .monospaced)) + .foregroundStyle(.white) + + Text(exportProgress?.currentStep.uppercased() ?? "PREPARING...") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.white.opacity(0.7)) + + if let progress = exportProgress { + Text("\(Int(progress.percentComplete * 100))%") + .font(.system(.title, design: .monospaced).bold()) + .foregroundStyle(Theme.warmOrange) + } + } + .padding(32) + .border(Color.white.opacity(0.3), width: 2) + } + } + + // MARK: - Computed Properties (same as Classic) + + private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] { + trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in + if let coord = stop.coordinate { return (stop.city, coord) } + if let stadiumId = stop.stadium, + let stadium = dataProvider.stadium(for: stadiumId) { + return (stadium.name, stadium.coordinate) + } + return nil + } + } + + private var itinerarySections: [ItinerarySection] { + // Same logic as Classic variant + var sections: [ItinerarySection] = [] + var dayCitySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = [] + let days = tripDays + + for (index, dayDate) in days.enumerated() { + let dayNum = index + 1 + let gamesOnDay = gamesOn(date: dayDate) + guard !gamesOnDay.isEmpty else { continue } + + var gamesByCity: [(city: String, games: [RichGame])] = [] + for game in gamesOnDay { + let city = game.stadium.city + if let lastIndex = gamesByCity.indices.last, gamesByCity[lastIndex].city == city { + gamesByCity[lastIndex].games.append(game) + } else { + gamesByCity.append((city, [game])) + } + } + + for cityGroup in gamesByCity { + dayCitySections.append((dayNum, dayDate, cityGroup.city, cityGroup.games)) + } + } + + for (index, section) in dayCitySections.enumerated() { + if index > 0 { + let prevCity = dayCitySections[index - 1].city + let currentCity = section.city + if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity { + if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) { + sections.append(.travel(travelSegment)) + } + } + } + sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games)) + } + + return sections + } + + private var tripDays: [Date] { + let calendar = Calendar.current + guard let startDate = trip.stops.first?.arrivalDate, + let endDate = trip.stops.last?.departureDate else { return [] } + + var days: [Date] = [] + var current = calendar.startOfDay(for: startDate) + let end = calendar.startOfDay(for: endDate) + + while current <= end { + days.append(current) + current = calendar.date(byAdding: .day, value: 1, to: current)! + } + return days + } + + private func gamesOn(date: Date) -> [RichGame] { + let calendar = Calendar.current + let dayStart = calendar.startOfDay(for: date) + let allGameIds = trip.stops.flatMap { $0.games } + let foundGames = allGameIds.compactMap { games[$0] } + + return foundGames.filter { richGame in + calendar.startOfDay(for: richGame.game.dateTime) == dayStart + }.sorted { $0.game.dateTime < $1.game.dateTime } + } + + private func findTravelSegment(from fromCity: String, to toCity: String) -> TravelSegment? { + let fromLower = fromCity.lowercased().trimmingCharacters(in: .whitespaces) + let toLower = toCity.lowercased().trimmingCharacters(in: .whitespaces) + + return trip.travelSegments.first { segment in + let segmentFrom = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces) + let segmentTo = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces) + return segmentFrom == fromLower && segmentTo == toLower + } + } + + // MARK: - Actions (same as Classic) + + private func loadGamesIfNeeded() async { + guard providedGames == nil else { return } + let gameIds = trip.stops.flatMap { $0.games } + guard !gameIds.isEmpty else { return } + + isLoadingGames = true + var loaded: [String: RichGame] = [:] + for gameId in gameIds { + if let game = try? await dataProvider.fetchGame(by: gameId), + let richGame = dataProvider.richGame(from: game) { + loaded[gameId] = richGame + } + } + loadedGames = loaded + isLoadingGames = false + } + + private func exportPDF() async { + isExporting = true + exportProgress = nil + + do { + let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in + await MainActor.run { self.exportProgress = progress } + } + exportURL = url + showExportSheet = true + } catch { } + + isExporting = false + } + + private func toggleSaved() { + if isSaved { unsaveTrip() } else { saveTrip() } + } + + private func saveTrip() { + if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit { + showProPaywall = true + return + } + + guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else { return } + modelContext.insert(savedTrip) + + if let _ = try? modelContext.save() { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + isSaved = true + } + } + } + + private func unsaveTrip() { + let tripId = trip.id + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == tripId }) + + if let savedTrips = try? modelContext.fetch(descriptor) { + for savedTrip in savedTrips { modelContext.delete(savedTrip) } + try? modelContext.save() + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + isSaved = false + } + } + } + + private func checkIfSaved() { + let tripId = trip.id + let descriptor = FetchDescriptor(predicate: #Predicate { $0.id == tripId }) + if let count = try? modelContext.fetchCount(descriptor), count > 0 { + isSaved = true + } + } + + private func updateMapRegion() { + guard !stopCoordinates.isEmpty else { return } + + let coordinates = stopCoordinates.map(\.coordinate) + let lats = coordinates.map(\.latitude) + let lons = coordinates.map(\.longitude) + + guard let minLat = lats.min(), let maxLat = lats.max(), + let minLon = lons.min(), let maxLon = lons.max() else { return } + + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + + let latSpan = (maxLat - minLat) * 1.3 + 0.5 + let lonSpan = (maxLon - minLon) * 1.3 + 0.5 + + mapCameraPosition = .region(MKCoordinateRegion( + center: center, + span: MKCoordinateSpan(latitudeDelta: max(latSpan, 1), longitudeDelta: max(lonSpan, 1)) + )) + } + + private func fetchDrivingRoutes() async { + let stops = stopCoordinates + guard stops.count >= 2 else { return } + + isLoadingRoutes = true + var polylines: [MKPolyline] = [] + + for i in 0..<(stops.count - 1) { + let source = stops[i] + let destination = stops[i + 1] + + let request = MKDirections.Request() + let sourceLocation = CLLocation(latitude: source.coordinate.latitude, longitude: source.coordinate.longitude) + let destLocation = CLLocation(latitude: destination.coordinate.latitude, longitude: destination.coordinate.longitude) + request.source = MKMapItem(location: sourceLocation, address: nil) + request.destination = MKMapItem(location: destLocation, address: nil) + request.transportType = .automobile + + let directions = MKDirections(request: request) + + do { + let response = try await directions.calculate() + if let route = response.routes.first { + polylines.append(route.polyline) + } + } catch { + let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2) + polylines.append(straightLine) + } + } + + routePolylines = polylines + isLoadingRoutes = false + } +} + +// MARK: - Day Section (Brutalist) + +struct DaySection_Brutalist: View { + let dayNumber: Int + let date: Date + let games: [RichGame] + @Environment(\.colorScheme) private var colorScheme + + private var textColor: Color { + colorScheme == .dark ? .white : .black + } + + private var formattedDate: String { + date.formatted(.dateTime.weekday(.wide).month().day()).uppercased() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Day header + HStack { + Text("DAY \(dayNumber)") + .font(.system(.title2, design: .monospaced).bold()) + .foregroundStyle(textColor) + + Spacer() + + Text("\(games.count) GAME\(games.count > 1 ? "S" : "")") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(Theme.warmOrange) + } + + Text(formattedDate) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + .tracking(1) + + // City + if let city = games.first?.stadium.city { + HStack(spacing: 4) { + Image(systemName: "mappin") + .font(.caption2) + Text(city.uppercased()) + .font(.system(.caption, design: .monospaced)) + } + .foregroundStyle(textColor.opacity(0.6)) + } + + // Games + ForEach(games, id: \.game.id) { richGame in + GameRow_Brutalist(game: richGame) + } + } + .padding(16) + .border(textColor.opacity(0.2), width: 1) + } +} + +// MARK: - Game Row (Brutalist) + +struct GameRow_Brutalist: View { + let game: RichGame + @Environment(\.colorScheme) private var colorScheme + + private var textColor: Color { + colorScheme == .dark ? .white : .black + } + + var body: some View { + HStack(spacing: 12) { + // Sport indicator - vertical bar + Rectangle() + .fill(game.game.sport.themeColor) + .frame(width: 4) + + VStack(alignment: .leading, spacing: 4) { + // Matchup + HStack(spacing: 8) { + Text(game.awayTeam.abbreviation) + .font(.system(.body, design: .monospaced).bold()) + Text("@") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + Text(game.homeTeam.abbreviation) + .font(.system(.body, design: .monospaced).bold()) + } + .foregroundStyle(textColor) + + // Stadium + Text(game.stadium.name.uppercased()) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + } + + Spacer() + + // Time + Text(game.localGameTimeShort) + .font(.system(.subheadline, design: .monospaced).bold()) + .foregroundStyle(Theme.warmOrange) + } + .padding(12) + .background(textColor.opacity(0.03)) + .border(textColor.opacity(0.1), width: 1) + } +} + +// MARK: - Travel Section (Brutalist) + +struct TravelSection_Brutalist: View { + let segment: TravelSegment + @Environment(\.colorScheme) private var colorScheme + + private var textColor: Color { + colorScheme == .dark ? .white : .black + } + + var body: some View { + VStack(spacing: 0) { + // Connector + Rectangle() + .fill(Theme.routeGold) + .frame(width: 2, height: 20) + + // Travel card + HStack(spacing: 12) { + Image(systemName: "car.fill") + .foregroundStyle(Theme.routeGold) + + VStack(alignment: .leading, spacing: 2) { + Text("[ TRAVEL ]") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.4)) + + Text("\(segment.fromLocation.name.uppercased()) → \(segment.toLocation.name.uppercased())") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(textColor) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(segment.formattedDistance.uppercased()) + .font(.system(.caption, design: .monospaced).bold()) + Text(segment.formattedDuration.uppercased()) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(textColor.opacity(0.5)) + } + .foregroundStyle(textColor) + } + .padding(12) + .border(Theme.routeGold.opacity(0.3), width: 1) + + // Connector + Rectangle() + .fill(Theme.routeGold) + .frame(width: 2, height: 20) + } + } +} +``` + +### Step 5: Update original TripDetailView to be a typealias + +Replace `TripDetailView.swift` with a simple redirect: + +```swift +// +// TripDetailView.swift +// SportsTime +// +// Redirects to AdaptiveTripDetailView for backward compatibility. +// + +import SwiftUI + +typealias TripDetailView = AdaptiveTripDetailView +``` + +### Step 6: Verify the build + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -30` +Expected: BUILD SUCCEEDED + +### Step 7: Commit + +```bash +git add SportsTime/Features/Trip/Views/ +git commit -m "$(cat <<'EOF' +feat(trip): add brutalist variant for TripDetailView + +- Create AdaptiveTripDetailView router +- Add TripDetailView_Classic (extracted from original) +- Add TripDetailView_Brutalist with monospace fonts, borders, perforated dividers +- Update TripDetailView as typealias for backward compatibility + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 3: Create SavedTripsListView Variants + +**Files:** +- Create: `SportsTime/Features/Home/Views/Variants/Classic/SavedTripsListView_Classic.swift` +- Create: `SportsTime/Features/Home/Views/Variants/Brutalist/SavedTripsListView_Brutalist.swift` +- Create: `SportsTime/Features/Home/Views/AdaptiveSavedTripsListView.swift` +- Modify: `SportsTime/Features/Home/Views/HomeView.swift` (extract SavedTripsListView, update to use Adaptive) + +### Step 1: Create directory structure + +```bash +mkdir -p SportsTime/Features/Home/Views/Variants/Classic +mkdir -p SportsTime/Features/Home/Views/Variants/Brutalist +``` + +### Step 2: Extract SavedTripsListView to Classic variant + +Move `SavedTripsListView` (lines 406-581 from HomeView.swift) to `Variants/Classic/SavedTripsListView_Classic.swift`, renaming to `SavedTripsListView_Classic`. + +### Step 3: Create SavedTripsListView_Brutalist + +Create `SportsTime/Features/Home/Views/Variants/Brutalist/SavedTripsListView_Brutalist.swift` with brutalist styling. + +### Step 4: Create AdaptiveSavedTripsListView router + +### Step 5: Update HomeView.swift + +Replace inline `SavedTripsListView` with `AdaptiveSavedTripsListView`. + +### Step 6: Verify build and commit + +--- + +## Task 4: Create ScheduleListView Variants + +**Files:** +- Create: `SportsTime/Features/Schedule/Views/Variants/Classic/ScheduleListView_Classic.swift` +- Create: `SportsTime/Features/Schedule/Views/Variants/Brutalist/ScheduleListView_Brutalist.swift` +- Create: `SportsTime/Features/Schedule/Views/AdaptiveScheduleListView.swift` +- Modify: `SportsTime/Features/Schedule/Views/ScheduleListView.swift` (typealias) +- Modify: `SportsTime/Features/Home/Views/HomeView.swift` (update Schedule tab) + +### Step 1: Create directory structure + +```bash +mkdir -p SportsTime/Features/Schedule/Views/Variants/Classic +mkdir -p SportsTime/Features/Schedule/Views/Variants/Brutalist +``` + +### Step 2-6: Same pattern as TripDetailView + +--- + +## Task 5: Create SettingsView Variants + +**Files:** +- Create: `SportsTime/Features/Settings/Views/Variants/Classic/SettingsView_Classic.swift` +- Create: `SportsTime/Features/Settings/Views/Variants/Brutalist/SettingsView_Brutalist.swift` +- Create: `SportsTime/Features/Settings/Views/AdaptiveSettingsView.swift` +- Modify: `SportsTime/Features/Settings/Views/SettingsView.swift` (typealias) +- Modify: `SportsTime/Features/Home/Views/HomeView.swift` (update Settings tab) + +### Step 1: Create directory structure + +```bash +mkdir -p SportsTime/Features/Settings/Views/Variants/Classic +mkdir -p SportsTime/Features/Settings/Views/Variants/Brutalist +``` + +### Step 2-6: Same pattern as TripDetailView + +--- + +## Task 6: Run Tests and Fix Issues + +**Step 1: Run full test suite** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test` +Expected: All tests pass + +**Step 2: Fix any failing tests** + +Update test files that reference view types directly. + +**Step 3: Commit fixes** + +--- + +## Task 7: Final Verification and Documentation Update + +**Step 1: Test all design styles in simulator** + +1. Build and run app +2. Go to Settings → Home Screen Style +3. Select "Classic" - verify all screens look correct +4. Select "Brutalist" - verify all screens have brutalist styling + +**Step 2: Update CLAUDE.md if needed** + +Add note about adaptive view pattern if helpful for future development. + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "$(cat <<'EOF' +docs: complete brutalist app-wide implementation + +All main screens now support Classic and Brutalist design variants: +- TripDetailView +- SavedTripsListView +- ScheduleListView +- SettingsView + +Other 22 design styles fall back to Classic for non-home screens. + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +```