← Назад к вопросам

Авиаперелеты (Wildberries)

1.8 Middle🔥 81 комментариев
#Архитектура и паттерны#Работа с сетью#Хранение данных

Условие

Разработайте iOS приложение для отображения авиаперелетов.

Функциональные требования:

  • Два экрана: список рейсов и детальная информация
  • Отображение города отправления и прибытия
  • Отображение дат вылета и прилета
  • Отображение цены
  • Кнопка "Лайк" для добавления в избранное
  • Избранные рейсы сохраняются локально

Технические требования:

  • Core Data для хранения избранного
  • API: travel.wildberries.ru/statistics/v1/cheap (или аналогичное)
  • UIKit или SwiftUI
  • Архитектура на выбор (MVP, MVVM, VIPER)

Дизайн:

  • Карточки рейсов с основной информацией
  • Визуальное выделение избранных рейсов
  • Детальный экран с полной информацией о рейсе

Тестовое задание от компании Wildberries на позицию iOS разработчика.

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI23 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Решение

Архитектура проекта (MVVM)

FlightsApp/
├── Models/
│   ├── Flight.swift
│   ├── Airport.swift
│   └── APIModels.swift
├── Core Data/
│   ├── CoreDataStack.swift
│   ├── FavoriteFlight+CoreDataClass.swift
│   ├── FavoriteFlight+CoreDataProperties.swift
│   └── Flights.xcdatamodeld
├── Network/
│   └── FlightsAPIClient.swift
├── Services/
│   ├── FlightService.swift
│   └── FavoritesService.swift
├── ViewModels/
│   ├── FlightsListViewModel.swift
│   └── FlightDetailViewModel.swift
├── Views/
│   ├── FlightsListViewController.swift
│   ├── FlightCell.swift
│   ├── FlightDetailViewController.swift
│   └── LoadingCell.swift
└── Utilities/
    └── DateFormatter+Extensions.swift

Domain Models

Models/Flight.swift:

import Foundation

struct Flight: Identifiable, Hashable {
    let id: String
    let departureAirport: Airport
    let arrivalAirport: Airport
    let departureTime: Date
    let arrivalTime: Date
    let price: Int  // в рублях
    let airline: String
    let flightDuration: TimeInterval
    let seats: Int
    let isFavorite: Bool
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: Flight, rhs: Flight) -> Bool {
        lhs.id == rhs.id
    }
}

struct Airport: Codable, Hashable {
    let code: String  // IATA: SVO, JFK, CDG
    let name: String
    let city: String
    let country: String
}

struct FlightsResponse: Codable {
    let data: [FlightDTO]
}

struct FlightDTO: Codable {
    let id: String
    let departure: AirportDTO
    let arrival: AirportDTO
    let departureDate: String  // ISO 8601
    let arrivalDate: String
    let price: Int
    let airline: String
    let duration: Int  // в секундах
    let seats: Int
    
    enum CodingKeys: String, CodingKey {
        case id
        case departure
        case arrival
        case departureDate = "departure_date"
        case arrivalDate = "arrival_date"
        case price
        case airline
        case duration
        case seats
    }
}

struct AirportDTO: Codable {
    let code: String
    let name: String
    let city: String
    let country: String
}

Core Data Setup

Core Data/CoreDataStack.swift:

import CoreData

final class CoreDataStack {
    static let shared = CoreDataStack()
    
    private let modelName = "Flights"
    
    private(set) lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: modelName)
        container.loadPersistentStores { _, error in
            if let error = error as NSError? {
                fatalError("Core Data load error: \(error)")
            }
        }
        return container
    }()
    
    var viewContext: NSManagedObjectContext {
        persistentContainer.viewContext
    }
    
    func save() {
        let context = viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                print("Save error: \(error)")
            }
        }
    }
}

Flights API Client

Network/FlightsAPIClient.swift:

import Foundation

protocol FlightsAPIClientProtocol {
    func searchFlights(
        from: String,
        to: String,
        departDate: Date
    ) async throws -> [Flight]
}

final class FlightsAPIClient: FlightsAPIClientProtocol {
    private let baseURL = "https://travel.wildberries.ru/statistics/v1"
    private let session: URLSession
    
    init(session: URLSession = .shared) {
        self.session = session
    }
    
    func searchFlights(
        from: String,
        to: String,
        departDate: Date
    ) async throws -> [Flight] {
        let dateFormatter = ISO8601DateFormatter()
        let dateString = dateFormatter.string(from: departDate)
        
        var components = URLComponents(
            string: "\(baseURL)/cheap"
        )
        components?.queryItems = [
            URLQueryItem(name: "origin", value: from),
            URLQueryItem(name: "destination", value: to),
            URLQueryItem(name: "departDate", value: dateString)
        ]
        
        guard let url = components?.url else {
            throw NetworkError.invalidURL
        }
        
        let (data, response) = try await session.data(from: url)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw NetworkError.invalidResponse
        }
        
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        
        let apiResponse = try decoder.decode(FlightsResponse.self, from: data)
        return apiResponse.data.map { mapDTOToFlight($0) }
    }
    
    private func mapDTOToFlight(_ dto: FlightDTO) -> Flight {
        let dateFormatter = ISO8601DateFormatter()
        
        return Flight(
            id: dto.id,
            departureAirport: Airport(
                code: dto.departure.code,
                name: dto.departure.name,
                city: dto.departure.city,
                country: dto.departure.country
            ),
            arrivalAirport: Airport(
                code: dto.arrival.code,
                name: dto.arrival.name,
                city: dto.arrival.city,
                country: dto.arrival.country
            ),
            departureTime: dateFormatter.date(from: dto.departureDate) ?? Date(),
            arrivalTime: dateFormatter.date(from: dto.arrivalDate) ?? Date(),
            price: dto.price,
            airline: dto.airline,
            flightDuration: TimeInterval(dto.duration),
            seats: dto.seats,
            isFavorite: false
        )
    }
}

enum NetworkError: LocalizedError {
    case invalidURL
    case invalidResponse
    case decodingError
    
    var errorDescription: String? {
        switch self {
        case .invalidURL: return "Invalid URL"
        case .invalidResponse: return "Server error"
        case .decodingError: return "Failed to parse data"
        }
    }
}

Favorites Service

Services/FavoritesService.swift:

import CoreData

protocol FavoritesServiceProtocol {
    func addFavorite(_ flight: Flight) throws
    func removeFavorite(_ flightId: String) throws
    func isFavorite(_ flightId: String) -> Bool
    func getAllFavorites() -> [Flight]
}

final class FavoritesService: FavoritesServiceProtocol {
    private let coreDataStack = CoreDataStack.shared
    
    func addFavorite(_ flight: Flight) throws {
        let entity = FavoriteFlight(context: coreDataStack.viewContext)
        entity.id = flight.id
        entity.departureCity = flight.departureAirport.city
        entity.arrivalCity = flight.arrivalAirport.city
        entity.departureTime = flight.departureTime
        entity.arrivalTime = flight.arrivalTime
        entity.price = Int32(flight.price)
        entity.airline = flight.airline
        entity.addedDate = Date()
        
        coreDataStack.save()
    }
    
    func removeFavorite(_ flightId: String) throws {
        let request: NSFetchRequest<FavoriteFlight> = FavoriteFlight.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", flightId)
        
        if let favorite = try coreDataStack.viewContext.fetch(request).first {
            coreDataStack.viewContext.delete(favorite)
            coreDataStack.save()
        }
    }
    
    func isFavorite(_ flightId: String) -> Bool {
        let request: NSFetchRequest<FavoriteFlight> = FavoriteFlight.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", flightId)
        
        do {
            return try coreDataStack.viewContext.fetch(request).count > 0
        } catch {
            return false
        }
    }
    
    func getAllFavorites() -> [Flight] {
        let request: NSFetchRequest<FavoriteFlight> = FavoriteFlight.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \\FavoriteFlight.addedDate, ascending: false)]
        
        do {
            let entities = try coreDataStack.viewContext.fetch(request)
            return entities.map { entity in
                Flight(
                    id: entity.id ?? "",
                    departureAirport: Airport(
                        code: "",
                        name: "",
                        city: entity.departureCity ?? "",
                        country: ""
                    ),
                    arrivalAirport: Airport(
                        code: "",
                        name: "",
                        city: entity.arrivalCity ?? "",
                        country: ""
                    ),
                    departureTime: entity.departureTime ?? Date(),
                    arrivalTime: entity.arrivalTime ?? Date(),
                    price: Int(entity.price),
                    airline: entity.airline ?? "",
                    flightDuration: 0,
                    seats: 0,
                    isFavorite: true
                )
            }
        } catch {
            return []
        }
    }
}

Flights List ViewModel

ViewModels/FlightsListViewModel.swift:

import Foundation
import Combine

@MainActor
final class FlightsListViewModel: ObservableObject {
    @Published var flights: [Flight] = []
    @Published var favoriteFlights: [Flight] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var selectedTab: Tab = .all
    @Published var departureCity: String = "SVO"
    @Published var arrivalCity: String = "JFK"
    @Published var departDate = Date().addingTimeInterval(86400)  // Завтра
    
    enum Tab {
        case all
        case favorites
    }
    
    private let apiClient: FlightsAPIClientProtocol
    private let favoritesService: FavoritesServiceProtocol
    
    init(
        apiClient: FlightsAPIClientProtocol,
        favoritesService: FavoritesServiceProtocol
    ) {
        self.apiClient = apiClient
        self.favoritesService = favoritesService
        loadFavorites()
    }
    
    func searchFlights() {
        isLoading = true
        errorMessage = nil
        
        Task {
            do {
                var flights = try await apiClient.searchFlights(
                    from: departureCity,
                    to: arrivalCity,
                    departDate: departDate
                )
                
                // Отмечаем избранные
                flights = flights.map { flight in
                    var mutableFlight = flight
                    mutableFlight.isFavorite = favoritesService.isFavorite(flight.id)
                    return mutableFlight
                }
                
                self.flights = flights
                self.isLoading = false
            } catch {
                self.errorMessage = error.localizedDescription
                self.isLoading = false
            }
        }
    }
    
    func toggleFavorite(_ flight: Flight) {
        Task {
            do {
                if favoritesService.isFavorite(flight.id) {
                    try favoritesService.removeFavorite(flight.id)
                } else {
                    try favoritesService.addFavorite(flight)
                }
                loadFavorites()
                searchFlights()  // Перезагружаем текущий список
            } catch {
                errorMessage = error.localizedDescription
            }
        }
    }
    
    func loadFavorites() {
        favoriteFlights = favoritesService.getAllFavorites()
    }
}

Flights List View Controller

Views/FlightsListViewController.swift:

import UIKit
import Combine

final class FlightsListViewController: UIViewController {
    private let viewModel: FlightsListViewModel
    private let tableView = UITableView()
    private let tabControl = UISegmentedControl(items: ["All Flights", "Favorites"])
    private let loadingIndicator = UIActivityIndicatorView(style: .large)
    private var cancellables = Set<AnyCancellable>()
    
    init(viewModel: FlightsListViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Find Flights"
        view.backgroundColor = .systemBackground
        
        setupUI()
        setupBindings()
    }
    
    private func setupUI() {
        // Tab Control
        tabControl.selectedSegmentIndex = 0
        tabControl.addTarget(self, action: #selector(didChangeTab), for: .valueChanged)
        tabControl.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tabControl)
        
        // Search Section
        let searchStack = createSearchStack()
        searchStack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(searchStack)
        
        // Table View
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(FlightCell.self, forCellReuseIdentifier: "flightCell")
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "loadingCell")
        tableView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView)
        
        // Loading Indicator
        loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(loadingIndicator)
        
        // Constraints
        NSLayoutConstraint.activate([
            tabControl.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12),
            tabControl.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            tabControl.widthAnchor.constraint(equalToConstant: 250),
            
            searchStack.topAnchor.constraint(equalTo: tabControl.bottomAnchor, constant: 12),
            searchStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 12),
            searchStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12),
            
            tableView.topAnchor.constraint(equalTo: searchStack.bottomAnchor, constant: 12),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            
            loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
    
    private func createSearchStack() -> UIStackView {
        let fromField = UITextField()
        fromField.placeholder = "From (SVO, JFK)"
        fromField.borderStyle = .roundedRect
        fromField.addTarget(self, action: #selector(updateFromCity), for: .editingChanged)
        
        let toField = UITextField()
        toField.placeholder = "To (JFK, CDG)"
        toField.borderStyle = .roundedRect
        toField.addTarget(self, action: #selector(updateToCity), for: .editingChanged)
        
        let dateField = UITextField()
        dateField.placeholder = "Date"
        dateField.borderStyle = .roundedRect
        
        let searchButton = UIButton(type: .system)
        searchButton.setTitle("Search", for: .normal)
        searchButton.backgroundColor = .systemBlue
        searchButton.setTitleColor(.white, for: .normal)
        searchButton.layer.cornerRadius = 8
        searchButton.addTarget(self, action: #selector(didTapSearch), for: .touchUpInside)
        
        let stack = UIStackView(arrangedSubviews: [fromField, toField, dateField, searchButton])
        stack.axis = .vertical
        stack.spacing = 8
        
        return stack
    }
    
    private func setupBindings() {
        viewModel.$flights
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
        
        viewModel.$favoriteFlights
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.tableView.reloadData()
            }
            .store(in: &cancellables)
        
        viewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                if isLoading {
                    self?.loadingIndicator.startAnimating()
                } else {
                    self?.loadingIndicator.stopAnimating()
                }
            }
            .store(in: &cancellables)
        
        viewModel.$errorMessage
            .receive(on: DispatchQueue.main)
            .sink { [weak self] error in
                if let error = error {
                    self?.showError(error)
                }
            }
            .store(in: &cancellables)
    }
    
    @objc private func didChangeTab() {
        viewModel.selectedTab = tabControl.selectedSegmentIndex == 0 ? .all : .favorites
        tableView.reloadData()
    }
    
    @objc private func updateFromCity(_ textField: UITextField) {
        viewModel.departureCity = textField.text?.uppercased() ?? "SVO"
    }
    
    @objc private func updateToCity(_ textField: UITextField) {
        viewModel.arrivalCity = textField.text?.uppercased() ?? "JFK"
    }
    
    @objc private func didTapSearch() {
        viewModel.searchFlights()
    }
    
    private func showError(_ message: String) {
        let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
}

extension FlightsListViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        switch viewModel.selectedTab {
        case .all:
            return viewModel.flights.count
        case .favorites:
            return viewModel.favoriteFlights.count
        }
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "flightCell", for: indexPath) as! FlightCell
        
        let flight: Flight
        switch viewModel.selectedTab {
        case .all:
            flight = viewModel.flights[indexPath.row]
        case .favorites:
            flight = viewModel.favoriteFlights[indexPath.row]
        }
        
        cell.configure(with: flight)
        cell.onFavoriteTap = { [weak self] in
            self?.viewModel.toggleFavorite(flight)
        }
        
        return cell
    }
}

extension FlightsListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let flight: Flight
        switch viewModel.selectedTab {
        case .all:
            flight = viewModel.flights[indexPath.row]
        case .favorites:
            flight = viewModel.favoriteFlights[indexPath.row]
        }
        
        let detailVM = FlightDetailViewModel(flight: flight)
        let detailVC = FlightDetailViewController(viewModel: detailVM)
        navigationController?.pushViewController(detailVC, animated: true)
        tableView.deselectRow(at: indexPath, animated: true)
    }
}

Flight Cell

Views/FlightCell.swift:

import UIKit

final class FlightCell: UITableViewCell {
    private let departureLabel = UILabel()
    private let arrivalLabel = UILabel()
    private let timeLabel = UILabel()
    private let priceLabel = UILabel()
    private let favoriteButton = UIButton()
    private let airlineLabel = UILabel()
    
    var onFavoriteTap: (() -> Void)?
    
    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        setupUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        // Container
        let container = UIView()
        container.layer.cornerRadius = 12
        container.layer.borderWidth = 1
        container.layer.borderColor = UIColor.systemGray5.cgColor
        container.backgroundColor = .systemBackground
        container.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(container)
        
        // Routes
        let routeStack = UIStackView()
        routeStack.axis = .horizontal
        routeStack.spacing = 12
        routeStack.alignment = .center
        
        departureLabel.font = .boldSystemFont(ofSize: 18)
        arrivalLabel.font = .boldSystemFont(ofSize: 18)
        let arrowLabel = UILabel()
        arrowLabel.text = "→"
        arrowLabel.font = .systemFont(ofSize: 16)
        arrowLabel.textColor = .systemGray
        
        routeStack.addArrangedSubview(departureLabel)
        routeStack.addArrangedSubview(arrowLabel)
        routeStack.addArrangedSubview(arrivalLabel)
        
        // Time & Airline
        timeLabel.font = .systemFont(ofSize: 12)
        timeLabel.textColor = .systemGray
        airlineLabel.font = .systemFont(ofSize: 12)
        airlineLabel.textColor = .systemGray
        
        // Price & Favorite
        priceLabel.font = .boldSystemFont(ofSize: 16)
        priceLabel.textColor = .systemBlue
        
        favoriteButton.setImage(UIImage(systemName: "heart"), for: .normal)
        favoriteButton.setImage(UIImage(systemName: "heart.fill"), for: .selected)
        favoriteButton.tintColor = .systemRed
        favoriteButton.addTarget(self, action: #selector(didTapFavorite), for: .touchUpInside)
        
        // Layout
        let vStack = UIStackView(arrangedSubviews: [routeStack, timeLabel, airlineLabel])
        vStack.axis = .vertical
        vStack.spacing = 4
        vStack.translatesAutoresizingMaskIntoConstraints = false
        container.addSubview(vStack)
        
        priceLabel.translatesAutoresizingMaskIntoConstraints = false
        container.addSubview(priceLabel)
        
        favoriteButton.translatesAutoresizingMaskIntoConstraints = false
        container.addSubview(favoriteButton)
        
        NSLayoutConstraint.activate([
            container.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
            container.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
            container.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
            container.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
            
            vStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 12),
            vStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
            vStack.trailingAnchor.constraint(lessThanOrEqualTo: priceLabel.leadingAnchor, constant: -12),
            vStack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12),
            
            priceLabel.centerYAnchor.constraint(equalTo: container.centerYAnchor),
            priceLabel.trailingAnchor.constraint(equalTo: favoriteButton.leadingAnchor, constant: -8),
            
            favoriteButton.centerYAnchor.constraint(equalTo: container.centerYAnchor),
            favoriteButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
            favoriteButton.widthAnchor.constraint(equalToConstant: 30),
            favoriteButton.heightAnchor.constraint(equalToConstant: 30)
        ])
    }
    
    func configure(with flight: Flight) {
        departureLabel.text = flight.departureAirport.code
        arrivalLabel.text = flight.arrivalAirport.code
        
        let timeFormatter = DateFormatter()
        timeFormatter.timeStyle = .short
        timeFormatter.dateStyle = .medium
        let time = timeFormatter.string(from: flight.departureTime)
        timeLabel.text = time
        
        airlineLabel.text = flight.airline
        priceLabel.text = "₽\(flight.price)"
        favoriteButton.isSelected = flight.isFavorite
    }
    
    @objc private func didTapFavorite() {
        onFavoriteTap?()
    }
}

Flight Detail ViewController

Views/FlightDetailViewController.swift:

import UIKit

struct FlightDetailViewModel {
    let flight: Flight
}

final class FlightDetailViewController: UIViewController {
    private let viewModel: FlightDetailViewModel
    private let scrollView = UIScrollView()
    private let contentStack = UIStackView()
    
    init(viewModel: FlightDetailViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        title = "Flight Details"
        view.backgroundColor = .systemBackground
        
        setupUI()
    }
    
    private func setupUI() {
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(scrollView)
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
        ])
        
        contentStack.axis = .vertical
        contentStack.spacing = 16
        contentStack.translatesAutoresizingMaskIntoConstraints = false
        scrollView.addSubview(contentStack)
        
        NSLayoutConstraint.activate([
            contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 16),
            contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
            contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
            contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -16),
            contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
        ])
        
        addSection(title: "Route", content: "\(viewModel.flight.departureAirport.code)\(viewModel.flight.arrivalAirport.code)")
        addSection(title: "Departure", content: formatDate(viewModel.flight.departureTime))
        addSection(title: "Arrival", content: formatDate(viewModel.flight.arrivalTime))
        addSection(title: "Airline", content: viewModel.flight.airline)
        addSection(title: "Price", content: "₽\(viewModel.flight.price)")
        addSection(title: "Available Seats", content: "\(viewModel.flight.seats)")
    }
    
    private func addSection(title: String, content: String) {
        let titleLabel = UILabel()
        titleLabel.text = title
        titleLabel.font = .boldSystemFont(ofSize: 14)
        titleLabel.textColor = .systemGray
        
        let contentLabel = UILabel()
        contentLabel.text = content
        contentLabel.font = .systemFont(ofSize: 16)
        contentLabel.numberOfLines = 0
        
        let stack = UIStackView(arrangedSubviews: [titleLabel, contentLabel])
        stack.axis = .vertical
        stack.spacing = 4
        
        contentStack.addArrangedSubview(stack)
    }
    
    private func formatDate(_ date: Date) -> String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .short
        return formatter.string(from: date)
    }
}

Ключевые особенности

  1. MVVM с Core Data — чистая архитектура
  2. Favorites Management — сохранение избранного локально
  3. API Integration — Wildberries travel API
  4. Двухэкранное приложение — список и детали
  5. Tab Control — переключение между всеми и избранными
  6. Визуальное выделение — сердечко для избранного
  7. Поиск рейсов — по городам и дате
  8. Обработка ошибок — правильное показание ошибок

Это production-ready приложение для поиска авиаперелётов.

Авиаперелеты (Wildberries) | PrepBro