feat: add planning tips and grouped trip options sorting
- Add PlanningTips data model with 105 tips across 7 categories - Wire random tips into HomeView (3 tips per session) - Add TripOptionsGrouper for grouping by city/game count and mileage - Update TripOptionsView with sectioned display when sorting - Recommended and Best Efficiency remain flat lists Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ struct HomeView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||
@State private var displayedTips: [PlanningTip] = []
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -317,9 +318,9 @@ struct HomeView: View {
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
TipRow(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often")
|
||||
TipRow(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving")
|
||||
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
|
||||
ForEach(displayedTips) { tip in
|
||||
TipRow(icon: tip.icon, title: tip.title, subtitle: tip.subtitle)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
@@ -329,6 +330,11 @@ struct HomeView: View {
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if displayedTips.isEmpty {
|
||||
displayedTips = PlanningTips.random(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1567,6 +1567,58 @@ enum CitiesFilter: Int, CaseIterable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Options Grouper
|
||||
|
||||
enum TripOptionsGrouper {
|
||||
typealias GroupedOptions = (header: String, options: [ItineraryOption])
|
||||
|
||||
static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||
let grouped = Dictionary(grouping: options) { option in
|
||||
Set(option.stops.map { $0.city }).count
|
||||
}
|
||||
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
|
||||
return sorted.map { count, opts in
|
||||
("\(count) \(count == 1 ? "city" : "cities")", opts)
|
||||
}
|
||||
}
|
||||
|
||||
static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||
let grouped = Dictionary(grouping: options) { $0.totalGames }
|
||||
let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key }
|
||||
return sorted.map { count, opts in
|
||||
("\(count) \(count == 1 ? "game" : "games")", opts)
|
||||
}
|
||||
}
|
||||
|
||||
static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] {
|
||||
let ranges: [(min: Int, max: Int, label: String)] = [
|
||||
(0, 500, "0-500 mi"),
|
||||
(500, 1000, "500-1000 mi"),
|
||||
(1000, 1500, "1000-1500 mi"),
|
||||
(1500, 2000, "1500-2000 mi"),
|
||||
(2000, Int.max, "2000+ mi")
|
||||
]
|
||||
|
||||
var groupedDict: [String: [ItineraryOption]] = [:]
|
||||
for option in options {
|
||||
let miles = Int(option.totalDistanceMiles)
|
||||
for range in ranges {
|
||||
if miles >= range.min && miles < range.max {
|
||||
groupedDict[range.label, default: []].append(option)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by range order
|
||||
let rangeOrder = ascending ? ranges : ranges.reversed()
|
||||
return rangeOrder.compactMap { range in
|
||||
guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil }
|
||||
return (range.label, opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TripOptionsView: View {
|
||||
let options: [ItineraryOption]
|
||||
let games: [String: RichGame]
|
||||
@@ -1641,6 +1693,29 @@ struct TripOptionsView: View {
|
||||
return Double(option.totalGames) / Double(days)
|
||||
}
|
||||
|
||||
private var groupedOptions: [TripOptionsGrouper.GroupedOptions] {
|
||||
switch sortOption {
|
||||
case .recommended, .bestEfficiency:
|
||||
// Flat list, no grouping
|
||||
return [("", filteredAndSortedOptions)]
|
||||
|
||||
case .mostCities:
|
||||
return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false)
|
||||
|
||||
case .mostGames:
|
||||
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false)
|
||||
|
||||
case .leastGames:
|
||||
return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true)
|
||||
|
||||
case .mostMiles:
|
||||
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false)
|
||||
|
||||
case .leastMiles:
|
||||
return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
@@ -1660,21 +1735,43 @@ struct TripOptionsView: View {
|
||||
filtersSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Options list
|
||||
// Options list (grouped when applicable)
|
||||
if filteredAndSortedOptions.isEmpty {
|
||||
emptyFilterState
|
||||
.padding(.top, Theme.Spacing.xl)
|
||||
} else {
|
||||
ForEach(filteredAndSortedOptions) { option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
games: games,
|
||||
onSelect: {
|
||||
selectedTrip = convertToTrip(option)
|
||||
showTripDetail = true
|
||||
ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Section header (only if non-empty)
|
||||
if !group.header.isEmpty {
|
||||
HStack {
|
||||
Text(group.header)
|
||||
.font(.headline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(group.options.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Options in this group
|
||||
ForEach(group.options) { option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
games: games,
|
||||
onSelect: {
|
||||
selectedTrip = convertToTrip(option)
|
||||
showTripDetail = true
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user