Improve UI consistency and add heart save button
- Add themed background to all tab views (Schedule, Settings, My Trips) - Remove navigation titles from all screens (tab bar provides context) - Add empty state to My Trips view - Remove max driving hours slider from trip planner (available in Settings) - Replace save menu with heart button overlay on trip detail map - Add ability to unsave trips by tapping heart again 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,6 @@ struct HomeView: View {
|
|||||||
.padding(Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
.navigationTitle("SportsTime")
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
@@ -470,16 +469,28 @@ struct SavedTripsListView: View {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
|
||||||
if trips.isEmpty {
|
|
||||||
EmptyStateView(
|
|
||||||
icon: "suitcase",
|
|
||||||
title: "No Saved Trips",
|
|
||||||
message: "Your planned adventures will appear here. Start planning your first sports road trip!"
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
if trips.isEmpty {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Spacer()
|
||||||
|
.frame(height: 100)
|
||||||
|
|
||||||
|
Image(systemName: "suitcase")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("No Saved Trips")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Browse featured trips on the Home tab or create your own to get started.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
} else {
|
||||||
LazyVStack(spacing: Theme.Spacing.md) {
|
LazyVStack(spacing: Theme.Spacing.md) {
|
||||||
ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in
|
ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||||
if let trip = savedTrip.trip {
|
if let trip = savedTrip.trip {
|
||||||
@@ -496,9 +507,7 @@ struct SavedTripsListView: View {
|
|||||||
.padding(Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
.navigationTitle("My Trips")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ struct ScheduleListView: View {
|
|||||||
gamesList
|
gamesList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Schedule")
|
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
|
.searchable(text: $viewModel.searchText, prompt: "Search teams or venues")
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.themedBackground()
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Menu {
|
Menu {
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ struct SettingsView: View {
|
|||||||
// Reset
|
// Reset
|
||||||
resetSection
|
resetSection
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.scrollContentBackground(.hidden)
|
||||||
|
.themedBackground()
|
||||||
.alert("Reset Settings", isPresented: $showResetConfirmation) {
|
.alert("Reset Settings", isPresented: $showResetConfirmation) {
|
||||||
Button("Cancel", role: .cancel) { }
|
Button("Cancel", role: .cancel) { }
|
||||||
Button("Reset", role: .destructive) {
|
Button("Reset", role: .destructive) {
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ struct TripCreationView: View {
|
|||||||
.padding(Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
.navigationTitle("Plan Your Trip")
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
@@ -551,21 +550,6 @@ struct TripCreationView: View {
|
|||||||
onIncrement: { viewModel.numberOfDrivers += 1 },
|
onIncrement: { viewModel.numberOfDrivers += 1 },
|
||||||
onDecrement: { viewModel.numberOfDrivers -= 1 }
|
onDecrement: { viewModel.numberOfDrivers -= 1 }
|
||||||
)
|
)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
|
||||||
HStack {
|
|
||||||
Text("Max Hours/Driver/Day")
|
|
||||||
.font(.system(size: Theme.FontSize.caption))
|
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
|
|
||||||
.font(.system(size: Theme.FontSize.caption, weight: .bold))
|
|
||||||
.foregroundStyle(Theme.warmOrange)
|
|
||||||
}
|
|
||||||
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
|
|
||||||
.tint(Theme.warmOrange)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1132,8 +1116,6 @@ struct TripOptionsView: View {
|
|||||||
.padding(.bottom, Theme.Spacing.xxl)
|
.padding(.bottom, Theme.Spacing.xxl)
|
||||||
}
|
}
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
.navigationTitle("Choose Your Trip")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationDestination(isPresented: $showTripDetail) {
|
.navigationDestination(isPresented: $showTripDetail) {
|
||||||
if let trip = selectedTrip {
|
if let trip = selectedTrip {
|
||||||
TripDetailView(trip: trip, games: games)
|
TripDetailView(trip: trip, games: games)
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ struct TripDetailView: View {
|
|||||||
@State private var shareURL: URL?
|
@State private var shareURL: URL?
|
||||||
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
||||||
@State private var isSaved = false
|
@State private var isSaved = false
|
||||||
@State private var showSaveConfirmation = false
|
|
||||||
@State private var routePolylines: [MKPolyline] = []
|
@State private var routePolylines: [MKPolyline] = []
|
||||||
@State private var isLoadingRoutes = false
|
@State private var isLoadingRoutes = false
|
||||||
|
|
||||||
@@ -57,8 +56,6 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Theme.backgroundGradient(colorScheme))
|
.background(Theme.backgroundGradient(colorScheme))
|
||||||
.navigationTitle(trip.name)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
@@ -71,23 +68,12 @@ struct TripDetailView: View {
|
|||||||
.foregroundStyle(Theme.warmOrange)
|
.foregroundStyle(Theme.warmOrange)
|
||||||
}
|
}
|
||||||
|
|
||||||
Menu {
|
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await exportPDF()
|
await exportPDF()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Export PDF", systemImage: "doc.fill")
|
Image(systemName: "doc.fill")
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
saveTrip()
|
|
||||||
} label: {
|
|
||||||
Label(isSaved ? "Saved" : "Save Trip", systemImage: isSaved ? "bookmark.fill" : "bookmark")
|
|
||||||
}
|
|
||||||
.disabled(isSaved)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle")
|
|
||||||
.foregroundStyle(Theme.warmOrange)
|
.foregroundStyle(Theme.warmOrange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,11 +90,6 @@ struct TripDetailView: View {
|
|||||||
ShareSheet(items: [trip.name, trip.formattedDateRange])
|
ShareSheet(items: [trip.name, trip.formattedDateRange])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Trip Saved", isPresented: $showSaveConfirmation) {
|
|
||||||
Button("OK", role: .cancel) { }
|
|
||||||
} message: {
|
|
||||||
Text("Your trip has been saved and can be accessed from My Trips.")
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
checkIfSaved()
|
checkIfSaved()
|
||||||
}
|
}
|
||||||
@@ -132,6 +113,22 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
|
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
// Save/Unsave heart button
|
||||||
|
Button {
|
||||||
|
toggleSaved()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isSaved ? "heart.fill" : "heart")
|
||||||
|
.font(.system(size: 22, weight: .medium))
|
||||||
|
.foregroundStyle(isSaved ? .red : .white)
|
||||||
|
.padding(12)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: .black.opacity(0.2), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
}
|
||||||
|
|
||||||
// Gradient overlay at bottom
|
// Gradient overlay at bottom
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
@@ -449,6 +446,14 @@ struct TripDetailView: View {
|
|||||||
showShareSheet = true
|
showShareSheet = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func toggleSaved() {
|
||||||
|
if isSaved {
|
||||||
|
unsaveTrip()
|
||||||
|
} else {
|
||||||
|
saveTrip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveTrip() {
|
private func saveTrip() {
|
||||||
guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else {
|
guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else {
|
||||||
print("Failed to create SavedTrip")
|
print("Failed to create SavedTrip")
|
||||||
@@ -459,13 +464,34 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||||
isSaved = true
|
isSaved = true
|
||||||
showSaveConfirmation = true
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to save trip: \(error)")
|
print("Failed to save trip: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func unsaveTrip() {
|
||||||
|
let tripId = trip.id
|
||||||
|
let descriptor = FetchDescriptor<SavedTrip>(
|
||||||
|
predicate: #Predicate { $0.id == tripId }
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Failed to unsave trip: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func checkIfSaved() {
|
private func checkIfSaved() {
|
||||||
let tripId = trip.id
|
let tripId = trip.id
|
||||||
let descriptor = FetchDescriptor<SavedTrip>(
|
let descriptor = FetchDescriptor<SavedTrip>(
|
||||||
|
|||||||
Reference in New Issue
Block a user