298 lines
13 KiB
Swift
298 lines
13 KiB
Swift
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)
|
||
}
|
||
}
|
||
)
|
||
}
|
||
}
|
||
}
|