import SwiftUI import SDWebImageSwiftUI private extension String { var trimmedNonEmpty: String? { let t = trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? nil : t } } // MARK: - ViewModel @MainActor final class StopsViewModel: ObservableObject { @Published var stops: [StopDetail] = [] @Published var isLoading: Bool = false @Published var selectedStopId: Int? func fetchStops(for routeId: Int) { guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeId)/station") else { return } isLoading = true Task { do { let (data, _) = try await URLSession.shared.data(from: url) let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let stops = try decoder.decode([StopDetail].self, from: data) self.stops = stops } catch { print("Ошибка загрузки остановок:", error) self.stops = [] } self.isLoading = false } } func toggleStop(id: Int) { withAnimation(.easeInOut(duration: 0.25)) { if selectedStopId == id { selectedStopId = nil } else { selectedStopId = id } } } } // MARK: - BottomMenu struct BottomMenu: View { @Binding var isPresented: Bool @State private var dragOffset: CGFloat = 0 @State private var selectedTab: Tab = .sights @EnvironmentObject var appState: AppState @StateObject private var stopsVM = StopsViewModel() enum Tab { case sights, stops } private let columns: [GridItem] = Array( repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: 3 ) var body: some View { GeometryReader { geo in ZStack { if isPresented { Color.black.opacity(0.4) .ignoresSafeArea() .onTapGesture { isPresented = false } .transition(.opacity) } VStack { Spacer() VStack(spacing: 20) { Capsule() .fill(Color.white.opacity(0.8)) .frame(width: 120, height: 3) .padding(.top, 8) VStack(spacing: 12) { menuButton(title: "Достопримечательности", tab: .sights) menuButton(title: "Остановки", tab: .stops) if selectedTab == .sights { // --- Достопримечательности --- ScrollView { LazyVGrid(columns: columns, spacing: 20) { ForEach(appState.sights) { sight in VStack(spacing: 8) { SightThumbnail( url: URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.thumbnail)/download"), size: 80 ) .onTapGesture { appState.sightId = sight.id isPresented = false } Text(sight.name) .font(.footnote) .multilineTextAlignment(.center) .foregroundColor(.white) .lineLimit(2) .frame(maxHeight: .infinity, alignment: .top) } } } .padding(.horizontal, 20) .padding(.vertical, 10) } } else { // --- Остановки + пересадки --- ScrollView { LazyVStack(spacing: 0) { if stopsVM.isLoading { ProgressView("Загрузка остановок...") .padding() } else { ForEach(stopsVM.stops) { stop in VStack(alignment: .leading, spacing: 0) { Button { stopsVM.toggleStop(id: stop.id) } label: { Text(stop.name) .font(.subheadline) // меньше размер .frame(maxWidth: .infinity, alignment: .leading) // левое выравнивание .padding(.vertical, 10) .padding(.horizontal, 20) .foregroundColor(.white) .transaction { $0.animation = nil } } .overlay( Rectangle() .frame(height: 1) .foregroundColor(.white.opacity(0.3)), alignment: .bottom ) if stopsVM.selectedStopId == stop.id { transfersView(for: stop) .transition(.opacity.combined(with: .move(edge: .top))) } } } } } } .onAppear { if stopsVM.stops.isEmpty, let routeId = appState.selectedRoute?.id { stopsVM.fetchStops(for: routeId) } } } } .padding(.horizontal, 20) .padding(.top, 2) HStack(spacing: 16) { Image("GAT_Icon") .resizable() .scaledToFit() .frame(height: 30) Spacer() HStack(spacing: 2) { Image("ru_lang_icon").resizable().scaledToFit().frame(width: 24, height: 24) Image("zh_lang_icon").resizable().scaledToFit().frame(width: 24, height: 24) Image("en_lang_icon").resizable().scaledToFit().frame(width: 24, height: 24) } } .padding(.horizontal, 26) .padding(.top, 16) .padding(.bottom, 32) } .frame(height: geo.size.height * 0.8) .frame(maxWidth: .infinity) .background( Color(hex: 0x806C59) .cornerRadius(24, corners: [.topLeft, .topRight]) .shadow(radius: 10) ) .offset(y: isPresented ? dragOffset : geo.size.height) .animation(.spring(response: 0.35, dampingFraction: 0.9), value: isPresented) .animation(.spring(response: 0.35, dampingFraction: 0.9), value: dragOffset) .gesture( DragGesture() .onChanged { value in if value.translation.height > 0 { dragOffset = value.translation.height } } .onEnded { value in if value.translation.height > 100 { isPresented = false } dragOffset = 0 } ) } .ignoresSafeArea(edges: .bottom) } } } // MARK: - Menu Button @ViewBuilder private func menuButton(title: String, tab: Tab) -> some View { Button { selectedTab = tab } label: { Text(title) .frame(maxWidth: .infinity) .frame(height: 47) .foregroundColor(.white) .background( RoundedRectangle(cornerRadius: 16) .fill(selectedTab == tab ? Color(hex: 0x6D5743) : Color(hex: 0xB3A598)) ) } .buttonStyle(.plain) } // MARK: - Transfers View @ViewBuilder private func transfersView(for stop: StopDetail) -> some View { let t = stop.transfers let items: [(String, String)] = [ ("tram_icon", t.tram?.trimmedNonEmpty), ("trolley_icon", t.trolleybus?.trimmedNonEmpty), ("bus_icon", t.bus?.trimmedNonEmpty), ("train_icon", t.train?.trimmedNonEmpty), ("metro_red_icon", t.metroRed?.trimmedNonEmpty), ("metro_green_icon", t.metroGreen?.trimmedNonEmpty), ("metro_blue_icon", t.metroBlue?.trimmedNonEmpty), ("metro_purple_icon", t.metroPurple?.trimmedNonEmpty), ("metro_orange_icon", t.metroOrange?.trimmedNonEmpty), ].compactMap { icon, text in guard let text = text else { return nil } return (icon, text) } if items.isEmpty { Text("Нет пересадок") .font(.caption) // меньше размер .foregroundColor(.white.opacity(0.7)) .padding(.leading, 20) .padding(.vertical, 8) } else { VStack(alignment: .leading, spacing: 6) { // меньше расстояние ForEach(items, id: \.0) { icon, text in HStack(spacing: 8) { Image(icon) .resizable() .scaledToFit() .frame(width: 18, height: 18) Text(text) .font(.caption) // меньше размер .foregroundColor(.white) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) // левое выравнивание } .padding(.leading, 20) .padding(.vertical, 4) } } .padding(.bottom, 8) .padding(.top, 6) } } } // MARK: - Sight Thumbnail struct SightThumbnail: View { let url: URL? let size: CGFloat var body: some View { ZStack { Circle() .fill(Color.white) .frame(width: size, height: size) WebImage(url: url) .resizable() .scaledToFit() .frame(width: size, height: size) .clipShape(Circle()) .overlay( Group { if url == nil { ProgressView().frame(width: size, height: size) } } ) } } }