import SwiftUI import SDWebImageSwiftUI // MARK: - String Extension private extension String { var trimmedNonEmpty: String? { let t = trimmingCharacters(in: .whitespacesAndNewlines) return t.isEmpty ? nil : t } } // MARK: - ViewModel final class StopsViewModel: ObservableObject { @Published var stops: [Stop] = [] @Published var isLoading: Bool = false @Published var selectedStopDetail: StopDetail? private var detailCache: [Int: StopDetail] = [:] // MARK: - Fetch Stops With Transfers func fetchStops(routeId: Int, language: String = "ru") { guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeId)/station?lang=\(language)") else { return } isLoading = true URLSession.shared.dataTask(with: url) { data, _, error in DispatchQueue.main.async { self.isLoading = false } guard let data = data, error == nil else { return } do { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase let stopDetails = try decoder.decode([StopDetail].self, from: data) // Раздаем детали по остановкам var stops: [Stop] = [] var cache: [Int: StopDetail] = [:] for detail in stopDetails { cache[detail.id] = detail stops.append(Stop(id: detail.id, name: detail.name)) } DispatchQueue.main.async { self.stops = stops self.detailCache = cache } } catch { print("Parse stops error:", error) } }.resume() } func toggleStop(id: Int) { withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { if selectedStopDetail?.id == id { selectedStopDetail = nil } else { selectedStopDetail = detailCache[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 { VisualEffectBlur(blurStyle: .systemUltraThinMaterialDark) .ignoresSafeArea() .mask( LinearGradient( gradient: Gradient(stops: [ .init(color: Color.black.opacity(0), location: 0), .init(color: Color.black.opacity(1), location: 0.25), .init(color: Color.black.opacity(1), location: 1) ]), startPoint: .top, endPoint: .bottom ) ).onTapGesture { isPresented = false } 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: appState.selectedLanguage == "ru" ? "Достопримечательности" : appState.selectedLanguage == "zh" ? "景点" : "Sights", tab: .sights) menuButton(title: appState.selectedLanguage == "ru" ? "Остановки" : appState.selectedLanguage == "zh" ? "车站" : "Stops", tab: .stops) if selectedTab == .sights { sightsView } else { stopsView } } .padding(.horizontal, 20) .padding(.top, 2) menuFooter } .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.4, dampingFraction: 0.85), value: isPresented) .animation(.spring(response: 0.4, dampingFraction: 0.85), 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) .font(.system(size: 14)) .frame(maxWidth: .infinity) .frame(height: 40) .foregroundColor(.white) .background( RoundedRectangle(cornerRadius: 16) .fill(selectedTab == tab ? Color(hex: 0x6D5743) : Color(hex: 0xB3A598)) ) } .buttonStyle(.plain) } // MARK: - Sights View @ViewBuilder private var sightsView: some View { 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?lang=\(appState.selectedLanguage)"), size: 80 ) .onTapGesture { appState.sightId = sight.id isPresented = false } Text(sight.name) .font(.footnote) .multilineTextAlignment(.center) .foregroundColor(.white) .lineLimit(2) } } } .padding(.horizontal, 20) .padding(.vertical, 10) }.mask( LinearGradient( gradient: Gradient(stops: [ .init(color: Color.black.opacity(0), location: 0), .init(color: Color.black, location: 0.05), .init(color: Color.black, location: 0.95), .init(color: Color.black.opacity(0), location: 1) ]), startPoint: .top, endPoint: .bottom ) ) } // MARK: - Stops View @ViewBuilder private var stopsView: some View { ScrollView { LazyVStack(spacing: 10) { if stopsVM.isLoading { ProgressView(appState.selectedLanguage == "ru" ? "Загрузка остановок..." : appState.selectedLanguage == "zh" ? "加载车站..." : "Loading stops...") .padding() } else { ForEach(stopsVM.stops) { stop in VStack(alignment: .leading, spacing: 0) { Button { stopsVM.toggleStop(id: stop.id) } label: { Text(stop.name) .font(.subheadline) .foregroundColor(.white) .frame(maxWidth: .infinity, alignment: .leading) .multilineTextAlignment(.leading) .padding(.vertical, 8) .padding(.horizontal, 20) } .overlay( Rectangle() .frame(height: 1) .foregroundColor(.white.opacity(0.3)), alignment: .bottom ) if stopsVM.selectedStopDetail?.id == stop.id, let detail = stopsVM.selectedStopDetail { transfersView(for: detail) .transition(.opacity.combined(with: .scale(scale: 0.95, anchor: .top))) } } } } } } .mask( LinearGradient( gradient: Gradient(stops: [ .init(color: Color.black.opacity(0), location: 0), .init(color: Color.black, location: 0.05), .init(color: Color.black, location: 0.95), .init(color: Color.black.opacity(0), location: 1) ]), startPoint: .top, endPoint: .bottom ) ) .onAppear { if stopsVM.stops.isEmpty, let routeId = appState.selectedRoute?.id { stopsVM.fetchStops(routeId: routeId, language: appState.selectedLanguage) } } .animation(.easeInOut(duration: 0.25), value: stopsVM.selectedStopDetail?.id) } // MARK: - Footer @ViewBuilder private var menuFooter: some View { HStack(spacing: 16) { Image("GAT_Icon") .resizable() .scaledToFit() .frame(height: 30) Spacer() HStack(spacing: 4) { languageButton(imageName: "ru_lang_icon", code: "ru") languageButton(imageName: "zh_lang_icon", code: "zh") languageButton(imageName: "en_lang_icon", code: "en") } } .padding(.horizontal, 26) .padding(.top, 16) .padding(.bottom, 32) } @ViewBuilder private func languageButton(imageName: String, code: String) -> some View { Button { appState.selectedLanguage = code // Перезагрузка остановок при смене языка if let routeId = appState.selectedRoute?.id { stopsVM.fetchStops(routeId: routeId, language: code) } } label: { Image(imageName) .resizable() .scaledToFit() .frame(width: 24, height: 24) .brightness(appState.selectedLanguage == code ? 0.5 : 0) // осветление выбранного языка } .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(appState.selectedLanguage == "ru" ? "Нет пересадок" : appState.selectedLanguage == "zh" ? "没有换乘" : "No transfers") .font(.caption2) .foregroundColor(.white.opacity(0.7)) .padding(.leading, 20) .padding(.vertical, 4) } else { VStack(alignment: .leading, spacing: 6) { ForEach(items, id: \.0) { icon, text in HStack(spacing: 6) { Image(icon) .resizable() .scaledToFit() .frame(width: 18, height: 18) Text(text) .font(.caption2) .foregroundColor(.white) .fixedSize(horizontal: false, vertical: true) } .padding(.leading, 20) .padding(.vertical, 2) } } .padding(.bottom, 8) .padding(.top, 4) } } } // 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) } } ) } } }