refactor: remove legacy trip creation flow, extract shared components
- Delete TripCreationView.swift and TripCreationViewModel.swift (unused) - Extract TripOptionsView to standalone file - Extract DateRangePicker and DayCell to standalone file - Extract LocationSearchSheet and CityInputType to standalone file - Fix TripWizardView to pass games dictionary to TripOptionsView - Remove debug print statements from TripDetailView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
600
SportsTime/Features/Trip/Views/TripOptionsView.swift
Normal file
600
SportsTime/Features/Trip/Views/TripOptionsView.swift
Normal file
@@ -0,0 +1,600 @@
|
||||
//
|
||||
// TripOptionsView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Displays trip options for user selection after planning completes.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Sort Options
|
||||
|
||||
enum TripSortOption: String, CaseIterable, Identifiable {
|
||||
case recommended = "Recommended"
|
||||
case mostCities = "Most Cities"
|
||||
case mostGames = "Most Games"
|
||||
case leastGames = "Least Games"
|
||||
case mostMiles = "Most Miles"
|
||||
case leastMiles = "Least Miles"
|
||||
case bestEfficiency = "Best Efficiency"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .recommended: return "star.fill"
|
||||
case .mostCities: return "mappin.and.ellipse"
|
||||
case .mostGames, .leastGames: return "sportscourt"
|
||||
case .mostMiles, .leastMiles: return "road.lanes"
|
||||
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pace Filter
|
||||
|
||||
enum TripPaceFilter: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case packed = "Packed"
|
||||
case moderate = "Moderate"
|
||||
case relaxed = "Relaxed"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .all: return "rectangle.stack"
|
||||
case .packed: return "flame"
|
||||
case .moderate: return "equal.circle"
|
||||
case .relaxed: return "leaf"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cities Filter
|
||||
|
||||
enum CitiesFilter: Int, CaseIterable, Identifiable {
|
||||
case noLimit = 100
|
||||
case fifteen = 15
|
||||
case ten = 10
|
||||
case five = 5
|
||||
case four = 4
|
||||
case three = 3
|
||||
case two = 2
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .noLimit: return "No Limit"
|
||||
case .fifteen: return "15"
|
||||
case .ten: return "10"
|
||||
case .five: return "5"
|
||||
case .four: return "4"
|
||||
case .three: return "3"
|
||||
case .two: return "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Options View
|
||||
|
||||
struct TripOptionsView: View {
|
||||
let options: [ItineraryOption]
|
||||
let games: [String: RichGame]
|
||||
let preferences: TripPreferences?
|
||||
let convertToTrip: (ItineraryOption) -> Trip
|
||||
|
||||
@State private var selectedTrip: Trip?
|
||||
@State private var showTripDetail = false
|
||||
@State private var sortOption: TripSortOption = .recommended
|
||||
@State private var citiesFilter: CitiesFilter = .noLimit
|
||||
@State private var paceFilter: TripPaceFilter = .all
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private func uniqueCityCount(for option: ItineraryOption) -> Int {
|
||||
Set(option.stops.map { $0.city }).count
|
||||
}
|
||||
|
||||
private var filteredAndSortedOptions: [ItineraryOption] {
|
||||
// Apply filters first
|
||||
let filtered = options.filter { option in
|
||||
let cityCount = uniqueCityCount(for: option)
|
||||
|
||||
// City filter
|
||||
guard cityCount <= citiesFilter.rawValue else { return false }
|
||||
|
||||
// Pace filter based on games per day ratio
|
||||
switch paceFilter {
|
||||
case .all:
|
||||
return true
|
||||
case .packed:
|
||||
// High game density: > 0.8 games per day
|
||||
return gamesPerDay(for: option) >= 0.8
|
||||
case .moderate:
|
||||
// Medium density: 0.4-0.8 games per day
|
||||
let gpd = gamesPerDay(for: option)
|
||||
return gpd >= 0.4 && gpd < 0.8
|
||||
case .relaxed:
|
||||
// Low density: < 0.4 games per day
|
||||
return gamesPerDay(for: option) < 0.4
|
||||
}
|
||||
}
|
||||
|
||||
// Then apply sorting
|
||||
switch sortOption {
|
||||
case .recommended:
|
||||
return filtered
|
||||
case .mostCities:
|
||||
return filtered.sorted { $0.stops.count > $1.stops.count }
|
||||
case .mostGames:
|
||||
return filtered.sorted { $0.totalGames > $1.totalGames }
|
||||
case .leastGames:
|
||||
return filtered.sorted { $0.totalGames < $1.totalGames }
|
||||
case .mostMiles:
|
||||
return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles }
|
||||
case .leastMiles:
|
||||
return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles }
|
||||
case .bestEfficiency:
|
||||
return filtered.sorted {
|
||||
let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0
|
||||
let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0
|
||||
return effA > effB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func gamesPerDay(for option: ItineraryOption) -> Double {
|
||||
guard let first = option.stops.first,
|
||||
let last = option.stops.last else { return 0 }
|
||||
let days = max(1, Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 1)
|
||||
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) {
|
||||
// Hero header
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text("\(filteredAndSortedOptions.count) of \(options.count) Routes")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
// Filters section
|
||||
filtersSection
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
|
||||
// Options list (grouped when applicable)
|
||||
if filteredAndSortedOptions.isEmpty {
|
||||
emptyFilterState
|
||||
.padding(.top, Theme.Spacing.xl)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationDestination(isPresented: $showTripDetail) {
|
||||
if let trip = selectedTrip {
|
||||
TripDetailView(trip: trip, games: games)
|
||||
}
|
||||
}
|
||||
.onChange(of: showTripDetail) { _, isShowing in
|
||||
if !isShowing {
|
||||
selectedTrip = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sortPicker: some View {
|
||||
Menu {
|
||||
ForEach(TripSortOption.allCases) { option in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
sortOption = option
|
||||
}
|
||||
} label: {
|
||||
Label(option.rawValue, systemImage: option.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: sortOption.icon)
|
||||
.font(.subheadline)
|
||||
Text(sortOption.rawValue)
|
||||
.font(.subheadline)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Filters Section
|
||||
|
||||
private var filtersSection: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Sort and Pace row
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
sortPicker
|
||||
Spacer()
|
||||
pacePicker
|
||||
}
|
||||
|
||||
// Cities picker
|
||||
citiesPicker
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
|
||||
private var pacePicker: some View {
|
||||
Menu {
|
||||
ForEach(TripPaceFilter.allCases) { pace in
|
||||
Button {
|
||||
paceFilter = pace
|
||||
} label: {
|
||||
Label(pace.rawValue, systemImage: pace.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: paceFilter.icon)
|
||||
.font(.caption)
|
||||
.contentTransition(.identity)
|
||||
Text(paceFilter.rawValue)
|
||||
.font(.subheadline)
|
||||
.contentTransition(.identity)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(paceFilter == .all ? Theme.cardBackground(colorScheme) : Theme.warmOrange.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var citiesPicker: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Label("Max Cities", systemImage: "mappin.circle")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(CitiesFilter.allCases) { filter in
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
citiesFilter = filter
|
||||
}
|
||||
} label: {
|
||||
Text(filter.displayName)
|
||||
.font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium))
|
||||
.foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(citiesFilter == filter ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(citiesFilter == filter ? Color.clear : Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyFilterState: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Text("No routes match your filters")
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
Button {
|
||||
withAnimation {
|
||||
citiesFilter = .noLimit
|
||||
paceFilter = .all
|
||||
}
|
||||
} label: {
|
||||
Text("Reset Filters")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.xxl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Option Card
|
||||
|
||||
struct TripOptionCard: View {
|
||||
let option: ItineraryOption
|
||||
let games: [String: RichGame]
|
||||
let onSelect: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var aiDescription: String?
|
||||
@State private var isLoadingDescription = false
|
||||
|
||||
private var uniqueCities: [String] {
|
||||
option.stops.map { $0.city }.removingDuplicates()
|
||||
}
|
||||
|
||||
private var totalGames: Int {
|
||||
option.stops.flatMap { $0.games }.count
|
||||
}
|
||||
|
||||
private var uniqueSports: [Sport] {
|
||||
let gameIds = option.stops.flatMap { $0.games }
|
||||
let sports = gameIds.compactMap { games[$0]?.game.sport }
|
||||
return Array(Set(sports)).sorted { $0.rawValue < $1.rawValue }
|
||||
}
|
||||
|
||||
private var gamesPerSport: [(sport: Sport, count: Int)] {
|
||||
let gameIds = option.stops.flatMap { $0.games }
|
||||
var countsBySport: [Sport: Int] = [:]
|
||||
for gameId in gameIds {
|
||||
if let sport = games[gameId]?.game.sport {
|
||||
countsBySport[sport, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return countsBySport.sorted { $0.key.rawValue < $1.key.rawValue }
|
||||
.map { (sport: $0.key, count: $0.value) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Route info
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Vertical route display
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(uniqueCities.first ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("|")
|
||||
.font(.caption2)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.caption2)
|
||||
}
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text(uniqueCities.last ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
// Top stats row: cities and miles
|
||||
HStack(spacing: 12) {
|
||||
Label("\(uniqueCities.count) cities", systemImage: "mappin")
|
||||
if option.totalDistanceMiles > 0 {
|
||||
Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Bottom row: sports with game counts
|
||||
HStack(spacing: 6) {
|
||||
ForEach(gamesPerSport, id: \.sport) { item in
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: item.sport.iconName)
|
||||
.font(.caption2)
|
||||
Text("\(item.sport.rawValue.uppercased()) \(item.count)")
|
||||
.font(.caption2)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(item.sport.themeColor.opacity(0.15))
|
||||
.foregroundStyle(item.sport.themeColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// AI-generated description (after stats)
|
||||
if let description = aiDescription {
|
||||
Text(description)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.transition(.opacity)
|
||||
} else if isLoadingDescription {
|
||||
HStack(spacing: 4) {
|
||||
LoadingSpinner(size: .small)
|
||||
Text("Generating...")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Right: Chevron
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.task(id: option.id) {
|
||||
// Reset state when option changes
|
||||
aiDescription = nil
|
||||
isLoadingDescription = false
|
||||
await generateDescription()
|
||||
}
|
||||
}
|
||||
|
||||
private func generateDescription() async {
|
||||
guard RouteDescriptionGenerator.shared.isAvailable else { return }
|
||||
|
||||
isLoadingDescription = true
|
||||
|
||||
// Build input from THIS specific option
|
||||
let input = RouteDescriptionInput(from: option, games: games)
|
||||
|
||||
if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
aiDescription = description
|
||||
}
|
||||
}
|
||||
isLoadingDescription = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Extension for Removing Duplicates
|
||||
|
||||
extension Array where Element: Hashable {
|
||||
func removingDuplicates() -> [Element] {
|
||||
var seen = Set<Element>()
|
||||
return filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user