big update
This commit is contained in:
@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import SDWebImageSwiftUI
|
||||
|
||||
// MARK: - String Extension
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let t = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -9,37 +10,52 @@ private extension String {
|
||||
}
|
||||
|
||||
// MARK: - ViewModel
|
||||
@MainActor
|
||||
final class StopsViewModel: ObservableObject {
|
||||
@Published var stops: [StopDetail] = []
|
||||
@Published var stops: [Stop] = []
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var selectedStopId: Int?
|
||||
@Published var selectedStopDetail: StopDetail?
|
||||
|
||||
func fetchStops(for routeId: Int) {
|
||||
guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeId)/station") else { return }
|
||||
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
|
||||
|
||||
Task {
|
||||
URLSession.shared.dataTask(with: url) { data, _, error in
|
||||
DispatchQueue.main.async { self.isLoading = false }
|
||||
guard let data = data, error == nil else { return }
|
||||
|
||||
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
|
||||
|
||||
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("Ошибка загрузки остановок:", error)
|
||||
self.stops = []
|
||||
print("Parse stops error:", error)
|
||||
}
|
||||
self.isLoading = false
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
func toggleStop(id: Int) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
if selectedStopId == id {
|
||||
selectedStopId = nil
|
||||
withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) {
|
||||
if selectedStopDetail?.id == id {
|
||||
selectedStopDetail = nil
|
||||
} else {
|
||||
selectedStopId = id
|
||||
selectedStopDetail = detailCache[id]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,12 +79,19 @@ struct BottomMenu: View {
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
if isPresented {
|
||||
Color.black.opacity(0.4)
|
||||
.ignoresSafeArea()
|
||||
.onTapGesture { isPresented = false }
|
||||
.transition(.opacity)
|
||||
}
|
||||
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()
|
||||
@ -80,101 +103,19 @@ struct BottomMenu: View {
|
||||
.padding(.top, 8)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
menuButton(title: "Достопримечательности", tab: .sights)
|
||||
menuButton(title: "Остановки", tab: .stops)
|
||||
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 {
|
||||
// --- Достопримечательности ---
|
||||
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)
|
||||
}
|
||||
sightsView
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
stopsView
|
||||
}
|
||||
}
|
||||
.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)
|
||||
menuFooter
|
||||
}
|
||||
.frame(height: geo.size.height * 0.8)
|
||||
.frame(maxWidth: .infinity)
|
||||
@ -184,14 +125,12 @@ struct BottomMenu: View {
|
||||
.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)
|
||||
.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
|
||||
}
|
||||
if value.translation.height > 0 { dragOffset = value.translation.height }
|
||||
}
|
||||
.onEnded { value in
|
||||
if value.translation.height > 100 { isPresented = false }
|
||||
@ -209,8 +148,9 @@ struct BottomMenu: View {
|
||||
private func menuButton(title: String, tab: Tab) -> some View {
|
||||
Button { selectedTab = tab } label: {
|
||||
Text(title)
|
||||
.font(.system(size: 14))
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 47)
|
||||
.frame(height: 40)
|
||||
.foregroundColor(.white)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
@ -220,6 +160,146 @@ struct BottomMenu: View {
|
||||
.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 {
|
||||
@ -240,31 +320,30 @@ struct BottomMenu: View {
|
||||
}
|
||||
|
||||
if items.isEmpty {
|
||||
Text("Нет пересадок")
|
||||
.font(.caption) // меньше размер
|
||||
Text(appState.selectedLanguage == "ru" ? "Нет пересадок" : appState.selectedLanguage == "zh" ? "没有换乘" : "No transfers")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white.opacity(0.7))
|
||||
.padding(.leading, 20)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) { // меньше расстояние
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(items, id: \.0) { icon, text in
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(icon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
Text(text)
|
||||
.font(.caption) // меньше размер
|
||||
.font(.caption2)
|
||||
.foregroundColor(.white)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading) // левое выравнивание
|
||||
}
|
||||
.padding(.leading, 20)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.padding(.top, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user