Files
WhiteNights_iOS/WhiteNights/Widgets/BottomMenu.swift
2025-08-24 14:44:50 +03:00

298 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
)
}
}
}