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 <noreply@anthropic.com>
1090 lines
35 KiB
Markdown
1090 lines
35 KiB
Markdown
# 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 <noreply@anthropic.com>
|
|
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<SavedTrip>(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<SavedTrip>(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 <noreply@anthropic.com>
|
|
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 <noreply@anthropic.com>
|
|
EOF
|
|
)"
|
|
```
|