Files
WhiteNights_iOS/WhiteNights/Widgets/BottomMenu.swift
15lu.akari a87a3d12ab big update
2025-08-26 23:37:39 +03:00

377 lines
14 KiB
Swift
Raw Permalink 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
// 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)
}
}
)
}
}
}