diff --git a/WhiteNights/AppState.swift b/WhiteNights/AppState.swift index 06b4461..d31bfe5 100644 --- a/WhiteNights/AppState.swift +++ b/WhiteNights/AppState.swift @@ -18,11 +18,24 @@ class AppState: ObservableObject { @Published var allRoutes: [Route] = [] @Published var sightId: Int? = nil - @Published var sights: [SightModel] = [] // <- все достопримечательности маршрута + @Published var sights: [SightModel] = [] // все достопримечательности маршрута + + @Published var selectedLanguage: String = "ru" { + didSet { + // если язык поменялся и маршрут уже выбран, перезагружаем достопримечательности + if let routeId = selectedRoute?.id { + Task { + await fetchSights(for: routeId) + } + } + } + } +// язык с начальным значением "ru" // MARK: - Fetch Sights private func fetchSights(for routeId: Int) async { - let urlString = "https://white-nights.krbl.ru/services/content/route/\(routeId)/sight" + // Добавляем параметр выбранного языка + let urlString = "https://white-nights.krbl.ru/services/content/route/\(routeId)/sight?lang=\(selectedLanguage)" guard let url = URL(string: urlString) else { return } do { @@ -50,7 +63,8 @@ class AppState: ObservableObject { private func preloadThumbnails(for sights: [SightModel]) { let session = URLSession.shared for sight in sights { - guard let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.thumbnail)/download") else { continue } + // Миниатюры тоже адаптируем под выбранный язык + guard let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.thumbnail)/download?lang=\(selectedLanguage)") else { continue } let request = URLRequest(url: url) // Если уже в кэше, пропускаем diff --git a/WhiteNights/Assets.xcassets/AppIcon.appiconset/1024-logo.png b/WhiteNights/Assets.xcassets/AppIcon.appiconset/1024-logo.png new file mode 100644 index 0000000..858af84 Binary files /dev/null and b/WhiteNights/Assets.xcassets/AppIcon.appiconset/1024-logo.png differ diff --git a/WhiteNights/Assets.xcassets/AppIcon.appiconset/Contents.json b/WhiteNights/Assets.xcassets/AppIcon.appiconset/Contents.json index 532cd72..1398365 100644 --- a/WhiteNights/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/WhiteNights/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "1024-logo.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/WhiteNights/Assets.xcassets/bus_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/bus_icon.imageset/Contents.json new file mode 100644 index 0000000..206796e --- /dev/null +++ b/WhiteNights/Assets.xcassets/bus_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "bus.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/bus_icon.imageset/bus.svg b/WhiteNights/Assets.xcassets/bus_icon.imageset/bus.svg new file mode 100644 index 0000000..00f52e2 --- /dev/null +++ b/WhiteNights/Assets.xcassets/bus_icon.imageset/bus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/WhiteNights/Assets.xcassets/metro_blue_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/metro_blue_icon.imageset/Contents.json new file mode 100644 index 0000000..e10e8c0 --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_blue_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "metroBlue.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/metro_blue_icon.imageset/metroBlue.svg b/WhiteNights/Assets.xcassets/metro_blue_icon.imageset/metroBlue.svg new file mode 100644 index 0000000..884b565 --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_blue_icon.imageset/metroBlue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/metro_green_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/metro_green_icon.imageset/Contents.json new file mode 100644 index 0000000..7f204d5 --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_green_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "metroGreen.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/metro_green_icon.imageset/metroGreen.svg b/WhiteNights/Assets.xcassets/metro_green_icon.imageset/metroGreen.svg new file mode 100644 index 0000000..039f95a --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_green_icon.imageset/metroGreen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/metro_orange_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/metro_orange_icon.imageset/Contents.json new file mode 100644 index 0000000..c3ee67e --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_orange_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "metroOrange.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/metro_orange_icon.imageset/metroOrange.svg b/WhiteNights/Assets.xcassets/metro_orange_icon.imageset/metroOrange.svg new file mode 100644 index 0000000..8b27624 --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_orange_icon.imageset/metroOrange.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/metro_purple_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/metro_purple_icon.imageset/Contents.json new file mode 100644 index 0000000..164caef --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_purple_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "metroPurple.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/metro_purple_icon.imageset/metroPurple.svg b/WhiteNights/Assets.xcassets/metro_purple_icon.imageset/metroPurple.svg new file mode 100644 index 0000000..6813ae1 --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_purple_icon.imageset/metroPurple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/metro_red_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/metro_red_icon.imageset/Contents.json new file mode 100644 index 0000000..52d530e --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_red_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "metroRed.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/metro_red_icon.imageset/metroRed.svg b/WhiteNights/Assets.xcassets/metro_red_icon.imageset/metroRed.svg new file mode 100644 index 0000000..9db2a0e --- /dev/null +++ b/WhiteNights/Assets.xcassets/metro_red_icon.imageset/metroRed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/open_menu_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/open_menu_icon.imageset/Contents.json new file mode 100644 index 0000000..a64f174 --- /dev/null +++ b/WhiteNights/Assets.xcassets/open_menu_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "open_menu_icon.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/open_menu_icon.imageset/open_menu_icon.svg b/WhiteNights/Assets.xcassets/open_menu_icon.imageset/open_menu_icon.svg new file mode 100644 index 0000000..a83b0fe --- /dev/null +++ b/WhiteNights/Assets.xcassets/open_menu_icon.imageset/open_menu_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/WhiteNights/Assets.xcassets/train_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/train_icon.imageset/Contents.json new file mode 100644 index 0000000..d4310ef --- /dev/null +++ b/WhiteNights/Assets.xcassets/train_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "train.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/train_icon.imageset/train.svg b/WhiteNights/Assets.xcassets/train_icon.imageset/train.svg new file mode 100644 index 0000000..60bc427 --- /dev/null +++ b/WhiteNights/Assets.xcassets/train_icon.imageset/train.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/tram_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/tram_icon.imageset/Contents.json new file mode 100644 index 0000000..04a616c --- /dev/null +++ b/WhiteNights/Assets.xcassets/tram_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "tram.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/tram_icon.imageset/tram.svg b/WhiteNights/Assets.xcassets/tram_icon.imageset/tram.svg new file mode 100644 index 0000000..b01c9fc --- /dev/null +++ b/WhiteNights/Assets.xcassets/tram_icon.imageset/tram.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/WhiteNights/Assets.xcassets/trolley_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/trolley_icon.imageset/Contents.json new file mode 100644 index 0000000..0e60a56 --- /dev/null +++ b/WhiteNights/Assets.xcassets/trolley_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "trolley.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/trolley_icon.imageset/trolley.svg b/WhiteNights/Assets.xcassets/trolley_icon.imageset/trolley.svg new file mode 100644 index 0000000..eab02b6 --- /dev/null +++ b/WhiteNights/Assets.xcassets/trolley_icon.imageset/trolley.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/WhiteNights/Assets.xcassets/waiting_screen_logo.imageset/Contents.json b/WhiteNights/Assets.xcassets/waiting_screen_logo.imageset/Contents.json new file mode 100644 index 0000000..6777028 --- /dev/null +++ b/WhiteNights/Assets.xcassets/waiting_screen_logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "logo.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/waiting_screen_logo.imageset/logo.svg b/WhiteNights/Assets.xcassets/waiting_screen_logo.imageset/logo.svg new file mode 100644 index 0000000..dc4c21e --- /dev/null +++ b/WhiteNights/Assets.xcassets/waiting_screen_logo.imageset/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/WhiteNights/ContentView.swift b/WhiteNights/ContentView.swift index 1a983ac..58bc4e5 100644 --- a/WhiteNights/ContentView.swift +++ b/WhiteNights/ContentView.swift @@ -3,10 +3,12 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var appState: AppState @State private var showMenu = false + @State private var isLoading = true // состояние загрузки var body: some View { NavigationStack { ZStack { + // Основной контент VStack(spacing: 10) { HStack(spacing: 10) { RouteView() @@ -22,8 +24,10 @@ struct ContentView: View { } } .padding() + .blur(radius: isLoading ? 5 : 0) // слегка размываем, пока загрузка + .disabled(isLoading) // блокируем взаимодействие с контентом при загрузке - // Плавающая кнопка в правом нижнем углу + // Плавающая кнопка VStack { Spacer() HStack { @@ -33,35 +37,60 @@ struct ContentView: View { showMenu.toggle() } }) { - Image(systemName: "line.3.horizontal") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(.white) - .padding() - .background(Color.blue) + Image("open_menu_icon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 24, height: 24) + .padding(10) + .background(Color(hex: 0x806C59)) .clipShape(Circle()) .shadow(radius: 5) } .padding(.trailing, 20) - // .padding(.bottom, -20) // Убираем отрицательный паддинг - .padding(.bottom, -20) // Добавляем паддинг, чтобы кнопка не перекрывалась - + .padding(.bottom, -20) } } - // Используем кастомное BottomMenu + // BottomMenu if showMenu { - // Используем Binding для двусторонней связи BottomMenu(isPresented: $showMenu) .transition(.move(edge: .bottom)) - // Добавляем игнорирование safe area сверху, если BottomMenu - // должно полностью закрывать контент, но обычно для - // BottomSheet это не требуется, GeometryReader в BottomMenu - // уже делает нужное растяжение. + .animation(.spring(response: 0.35, dampingFraction: 0.9), value: showMenu) + } + + // Экран загрузки + if isLoading { + ZStack { + Color(hex: 0x806C59) + .edgesIgnoringSafeArea(.all) + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + VStack { + Spacer() + HStack { + Spacer() + Image("waiting_screen_logo") + .resizable() + .scaledToFit() + .frame(width: 200) + .padding(20) + } + } + } + .transition(.opacity) + .zIndex(1) } } } .task { await fetchRoutes() + // Убираем экран загрузки через 2 секунды + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation(.easeOut(duration: 0.5)) { + isLoading = false + } + } } .preferredColorScheme(.light) } diff --git a/WhiteNights/WhiteNightsApp.swift b/WhiteNights/WhiteNightsApp.swift index 8d54cff..d76b2b9 100644 --- a/WhiteNights/WhiteNightsApp.swift +++ b/WhiteNights/WhiteNightsApp.swift @@ -1,20 +1,20 @@ -// -// WhiteNightsApp.swift -// WhiteNights -// -// Created by Микаэл Оганесян on 24.08.2025. -// - import SwiftUI +import SDWebImageSVGCoder // <-- импортируем SVG кодер @main struct WhiteNightsApp: App { @StateObject private var appState = AppState() + init() { + // Регистрируем SVG кодер + let svgCoder = SDImageSVGCoder.shared + SDImageCodersManager.shared.addCoder(svgCoder) + } + var body: some Scene { WindowGroup { ContentView() - .environmentObject(appState) // <- обязательно! + .environmentObject(appState) } } } diff --git a/WhiteNights/Widgets/BottomMenu.swift b/WhiteNights/Widgets/BottomMenu.swift index ee9e507..95ccbe4 100644 --- a/WhiteNights/Widgets/BottomMenu.swift +++ b/WhiteNights/Widgets/BottomMenu.swift @@ -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) } } } diff --git a/WhiteNights/Widgets/RouteSelectionView.swift b/WhiteNights/Widgets/RouteSelectionView.swift index a868068..1270357 100644 --- a/WhiteNights/Widgets/RouteSelectionView.swift +++ b/WhiteNights/Widgets/RouteSelectionView.swift @@ -2,6 +2,7 @@ import SwiftUI struct RouteSelectionView: View { @EnvironmentObject var appState: AppState + @Environment(\.dismiss) private var dismiss @State private var routes: [Route] = [] @State private var isLoading = true @@ -9,12 +10,17 @@ struct RouteSelectionView: View { var body: some View { VStack { if isLoading { - ProgressView("Загрузка маршрутов...") - .padding() + ProgressView( + appState.selectedLanguage == "ru" ? "Загрузка маршрутов..." : + appState.selectedLanguage == "zh" ? "正在加载路线..." : + "Loading routes..." + ) + .padding() } else { List(routes, id: \.id) { route in Button(action: { appState.selectedRoute = route + dismiss() }) { HStack { Text("\(route.routeNumber)") @@ -26,15 +32,21 @@ struct RouteSelectionView: View { .listStyle(PlainListStyle()) } } - .navigationTitle("Выберите маршрут") + .navigationTitle( + appState.selectedLanguage == "ru" ? "Выберите маршрут" : + appState.selectedLanguage == "zh" ? "选择路线" : + "Select route" + ) .onAppear { Task { await fetchRoutes() } } + .onChange(of: appState.selectedLanguage) { _ in + // просто перерисовываем view, navigationTitle автоматически обновится + } } - // MARK: - Fetch Routes private func fetchRoutes() async { isLoading = true defer { isLoading = false } diff --git a/WhiteNights/Widgets/RouteView.swift b/WhiteNights/Widgets/RouteView.swift index b878194..f8f6865 100644 --- a/WhiteNights/Widgets/RouteView.swift +++ b/WhiteNights/Widgets/RouteView.swift @@ -5,7 +5,7 @@ struct RouteView: View { @State private var firstStationName: String = "Загрузка..." @State private var lastStationName: String = "Загрузка..." - @State private var engStationsName: String = "Загрузка..." + @State private var stationsRangeName: String = "Загрузка..." private var topBackgroundColor = Color(hex: 0xFCD500) @@ -42,7 +42,7 @@ struct RouteView: View { foregroundColor: .white ) MarqueeText( - text: engStationsName, + text: stationsRangeName, font: .caption, foregroundColor: .white.opacity(0.5) ) @@ -76,33 +76,72 @@ struct RouteView: View { await fetchStations(forRoute: routeID) } } + .onChange(of: appState.selectedLanguage) { _ in + if let routeID = appState.selectedRoute?.id { + Task { + await fetchStations(forRoute: routeID) + } + } + } } // MARK: - Fetch Stations private func fetchStations(forRoute routeID: Int) async { - firstStationName = "Загрузка..." - lastStationName = "Загрузка..." - engStationsName = "Loading..." - - guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station"), - let urlEng = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station?lang=en") else { return } + // текст загрузки + switch appState.selectedLanguage { + case "ru": + firstStationName = "Загрузка..." + lastStationName = "Загрузка..." + stationsRangeName = "Loading..." + case "zh": + firstStationName = "正在加载..." + lastStationName = "正在加载..." + stationsRangeName = "正在加载..." + default: + firstStationName = "Loading..." + lastStationName = "Loading..." + stationsRangeName = "Loading..." + } do { - let (data, _) = try await URLSession.shared.data(from: url) - let (dataEn, _) = try await URLSession.shared.data(from: urlEng) + // Загружаем станции на русском для диапазона + guard let urlRu = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station?lang=ru") else { return } + let (dataRu, _) = try await URLSession.shared.data(from: urlRu) + let stationsRu = try JSONDecoder().decode([Station].self, from: dataRu) - let stations = try JSONDecoder().decode([Station].self, from: data) + // Загружаем станции на английском для диапазона + guard let urlEn = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station?lang=en") else { return } + let (dataEn, _) = try await URLSession.shared.data(from: urlEn) let stationsEn = try JSONDecoder().decode([Station].self, from: dataEn) - if let firstStation = stations.first { firstStationName = firstStation.name } - if let lastStation = stations.last { lastStationName = lastStation.name } + // Загружаем станции на языке интерфейса для отображения first/last + let langCode = appState.selectedLanguage + guard let urlCurrent = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station?lang=\(langCode)") else { return } + let (dataCurrent, _) = try await URLSession.shared.data(from: urlCurrent) + let stationsCurrent = try JSONDecoder().decode([Station].self, from: dataCurrent) - let firstStationEn = stationsEn.first?.name ?? "Loading..." - let lastStationEn = stationsEn.last?.name ?? "Loading..." - engStationsName = "\(firstStationEn) - \(lastStationEn)" + // Устанавливаем first и last на текущем языке интерфейса + if let first = stationsCurrent.first { firstStationName = first.name } + if let last = stationsCurrent.last { lastStationName = last.name } + + // Диапазон станций + if appState.selectedLanguage == "ru" { + // для русского языка диапазон на английском + let firstEn = stationsEn.first?.name ?? "" + let lastEn = stationsEn.last?.name ?? "" + stationsRangeName = "\(firstEn) - \(lastEn)" + } else { + // для всех остальных языков диапазон на русском + let firstRu = stationsRu.first?.name ?? "" + let lastRu = stationsRu.last?.name ?? "" + stationsRangeName = "\(firstRu) - \(lastRu)" + } } catch { print("Ошибка загрузки станций: \(error)") + firstStationName = appState.selectedLanguage == "ru" ? "Ошибка загрузки" : appState.selectedLanguage == "zh" ? "加载失败" : "Failed to load" + lastStationName = appState.selectedLanguage == "ru" ? "Ошибка загрузки" : appState.selectedLanguage == "zh" ? "加载失败" : "Failed to load" + stationsRangeName = appState.selectedLanguage == "ru" ? "Ошибка загрузки" : appState.selectedLanguage == "zh" ? "加载失败" : "Failed to load" } } } diff --git a/WhiteNights/Widgets/SightView.swift b/WhiteNights/Widgets/SightView.swift index 5f7c103..732ee2e 100644 --- a/WhiteNights/Widgets/SightView.swift +++ b/WhiteNights/Widgets/SightView.swift @@ -1,27 +1,28 @@ import SwiftUI import AVKit import NukeUI -import UIKit - - -// MARK: - SightView struct SightView: View { let sightId: Int @StateObject private var viewModel = SightViewModel() + @EnvironmentObject private var appState: AppState var body: some View { VStack(alignment: .leading, spacing: 4) { mediaSection - + VStack(alignment: .leading, spacing: 8) { // Заголовок статьи - Text(viewModel.selectedArticle?.isReviewArticle == true ? viewModel.sightName : viewModel.articleHeading) + Text(viewModel.selectedArticle?.isReviewArticle == true + ? viewModel.sightName + : viewModel.articleHeading) .font(.title2) .fontWeight(.bold) .foregroundColor(.white) - .frame(maxWidth: .infinity, alignment: viewModel.selectedArticle?.isReviewArticle == true ? .center : .leading) - + .frame(maxWidth: .infinity, + alignment: viewModel.selectedArticle?.isReviewArticle == true ? .center : .leading) + .multilineTextAlignment(viewModel.selectedArticle?.isReviewArticle == true ? .center : .leading) + // Тело статьи ScrollView { if viewModel.selectedArticle?.isReviewArticle == true { @@ -38,16 +39,14 @@ struct SightView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - - // Список статей (кнопки навигации) - ФИНАЛЬНОЕ ИСПРАВЛЕНИЕ ЦЕНТРИРОВАНИЯ + + // Список статей GeometryReader { geometry in ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - // Spacers для центрирования + HStack(spacing: 10) { Spacer(minLength: 0) - ForEach(viewModel.allArticles) { article in - Text(article.heading) + Text(localizedHeading(article)) .font(.system(size: 12)) .lineLimit(1) .padding(.vertical, 6) @@ -64,28 +63,34 @@ struct SightView: View { viewModel.selectArticle(article) } } - Spacer(minLength: 0) } - // Принудительно задаем ширину HStack как ширину GeometryReader .frame(minWidth: geometry.size.width) } - .scrollIndicators(.hidden) // Скрываем полосу прокрутки + .scrollIndicators(.hidden) } - // Задаем высоту для GeometryReader .frame(height: 34) .frame(maxWidth: .infinity) } .padding(.horizontal, 8) - .padding(.bottom, 10) + .padding(.bottom, 4) } + // MARK: - Initial load .task(id: sightId) { + viewModel.setLanguage(appState.selectedLanguage) await viewModel.loadInitialData(sightId: sightId) } + // MARK: - Reload on language change + .onChange(of: appState.selectedLanguage) { newLang in + viewModel.setLanguage(newLang) + Task { + await viewModel.loadInitialData(sightId: sightId) + } + } .blockStyle(cornerRadius: 25) } - // Медиа-секция + // MARK: - Медиа @ViewBuilder private var mediaSection: some View { Group { @@ -98,29 +103,29 @@ struct SightView: View { .tint(.white) } .frame(maxWidth: .infinity) - .aspectRatio(16/9, contentMode: .fit) + .frame(height: 160) .cornerRadius(24, corners: [.topLeft, .topRight]) .clipped() - case .image(let url): LazyImage(url: url) { state in if let image = state.image { - image - .resizable() - .scaledToFit() + image.resizable().scaledToFit() } else { - ProgressView() + ZStack { + Color.gray.opacity(0.3) + ProgressView() + .progressViewStyle(.circular) + .tint(.white) + } } } .cornerRadius(24, corners: [.topLeft, .topRight]) .clipped() - case .video(let player): VideoPlayer(player: player) .aspectRatio(16/9, contentMode: .fit) .cornerRadius(24, corners: [.topLeft, .topRight]) .clipped() - case .error: Image(systemName: "photo") .resizable() @@ -134,4 +139,18 @@ struct SightView: View { .padding(4) .frame(maxWidth: .infinity) } + + // MARK: - Локализованные заголовки статей + private func localizedHeading(_ article: Article) -> String { + if article.isReviewArticle == true { + switch appState.selectedLanguage { + case "ru": return "Обзор" + case "en": return "Review" + case "zh": return "奥布佐尔" + default: return "Обзор" + } + } else { + return article.heading + } + } } diff --git a/WhiteNights/Widgets/SightViewModel.swift b/WhiteNights/Widgets/SightViewModel.swift index f37b5da..f28ef8e 100644 --- a/WhiteNights/Widgets/SightViewModel.swift +++ b/WhiteNights/Widgets/SightViewModel.swift @@ -1,7 +1,6 @@ import Foundation import AVKit import Combine - @MainActor class SightViewModel: ObservableObject { @Published var sightName: String = "Загрузка..." @@ -10,8 +9,9 @@ class SightViewModel: ObservableObject { @Published var articleHeading: String = "" @Published var articleBody: String = "" @Published var mediaState: MediaState = .loading - + private var sightModel: SightModel? + private var selectedLanguage: String = "ru" // по умолчанию enum MediaState { case loading @@ -20,10 +20,20 @@ class SightViewModel: ObservableObject { case error } + func setLanguage(_ language: String) { + self.selectedLanguage = language + } + func loadInitialData(sightId: Int) async { do { - async let sightModelTask = fetchJSON(from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)", type: SightModel.self) - async let articlesTask = fetchJSON(from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)/article", type: [Article].self) + async let sightModelTask = fetchJSON( + from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)?lang=\(selectedLanguage)", + type: SightModel.self + ) + async let articlesTask = fetchJSON( + from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)/article?lang=\(selectedLanguage)", + type: [Article].self + ) let (fetchedSightModel, fetchedArticles) = try await (sightModelTask, articlesTask) @@ -63,20 +73,23 @@ class SightViewModel: ObservableObject { } if let videoPreviewId = sight.video_preview, - let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(videoPreviewId)/download") { + let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(videoPreviewId)/download?lang=\(selectedLanguage)") { let player = AVPlayer(url: url) player.play() self.mediaState = .video(player) - } else if let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.preview_media)/download") { + } else if let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.preview_media)/download?lang=\(selectedLanguage)") { self.mediaState = .image(url) } else { self.mediaState = .error } } else { do { - let mediaItems = try await fetchJSON(from: "https://white-nights.krbl.ru/services/content/article/\(article.id)/media", type: [ArticleMedia].self) + let mediaItems = try await fetchJSON( + from: "https://white-nights.krbl.ru/services/content/article/\(article.id)/media?lang=\(selectedLanguage)", + type: [ArticleMedia].self + ) if let firstMedia = mediaItems.first, - let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(firstMedia.id)/download") { + let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(firstMedia.id)/download?lang=\(selectedLanguage)") { self.mediaState = .image(url) } else { self.mediaState = .error diff --git a/WhiteNights/Widgets/VisualEffectBlur.swift b/WhiteNights/Widgets/VisualEffectBlur.swift index e81a004..a8a1b62 100644 --- a/WhiteNights/Widgets/VisualEffectBlur.swift +++ b/WhiteNights/Widgets/VisualEffectBlur.swift @@ -7,5 +7,7 @@ struct VisualEffectBlur: UIViewRepresentable { UIVisualEffectView(effect: UIBlurEffect(style: blurStyle)) } - func updateUIView(_ uiView: UIVisualEffectView, context: Context) { } + func updateUIView(_ uiView: UIVisualEffectView, context: Context) { + uiView.effect = UIBlurEffect(style: blurStyle) + } } diff --git a/WhiteNights/Widgets/WeatherView.swift b/WhiteNights/Widgets/WeatherView.swift index 790317f..bae9a79 100644 --- a/WhiteNights/Widgets/WeatherView.swift +++ b/WhiteNights/Widgets/WeatherView.swift @@ -1,6 +1,6 @@ import SwiftUI -private let WEATHER_STATUS_MAP: [String: String] = [ +private let WEATHER_STATUS_MAP_RU: [String: String] = [ "Rain": "дождливо", "Clouds": "облачно", "Clear": "солнечно", @@ -10,52 +10,81 @@ private let WEATHER_STATUS_MAP: [String: String] = [ "Fog": "туман" ] +private let WEATHER_STATUS_MAP_ZH: [String: String] = [ + "Rain": "下雨", + "Clouds": "多云", + "Clear": "晴朗", + "Thunderstorm": "雷雨", + "Snow": "下雪", + "Drizzle": "毛毛雨", + "Fog": "雾" +] + +private let WEATHER_STATUS_MAP_EN: [String: String] = [ + "Rain": "Rain", + "Clouds": "Cloudy", + "Clear": "Sunny", + "Thunderstorm": "Thunderstorm", + "Snow": "Snow", + "Drizzle": "Drizzle", + "Fog": "Fog" +] + struct FormattedWeather { let temperature: Int - let status: String + let statusCode: String let precipitation: Int? let windSpeed: Double? - let dayOfWeek: String? + var dayOfWeek: String? + let originalDate: Date? // добавлено для пересчета дня недели + + func localizedStatus(language: String) -> String { + switch language { + case "ru": return WEATHER_STATUS_MAP_RU[statusCode] ?? statusCode + case "zh": return WEATHER_STATUS_MAP_ZH[statusCode] ?? statusCode + default: return WEATHER_STATUS_MAP_EN[statusCode] ?? statusCode + } + } } struct WeatherView: View { + @EnvironmentObject var appState: AppState + @State private var todayWeather: FormattedWeather? @State private var forecast: [FormattedWeather] = [] var body: some View { HStack(alignment: .center, spacing: 4) { if let today = todayWeather { - // ЛЕВЫЙ СТОЛБЕЦ: Иконка, температура и статус - VStack(alignment: .leading, spacing: 2) { - Image(getWeatherIconName(for: today.status)) + VStack(alignment: .center, spacing: 2) { + Image(getWeatherIconName(for: today.localizedStatus(language: appState.selectedLanguage))) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 36, height: 36) .padding(.bottom, 5) - + Text("\(today.temperature)°") .font(.system(size: 30, weight: .bold)) .foregroundColor(.white) - Text(today.status) + Text(today.localizedStatus(language: appState.selectedLanguage)) .font(.system(size: 12)) .foregroundColor(.white) + .multilineTextAlignment(.center) } - - // ПРАВЫЙ СТОЛБЕЦ: Прогноз с иконками - VStack(alignment: .leading, spacing: 10) { - // Прогноз на 3 дня + + VStack(alignment: .leading, spacing: 6) { ForEach(forecast.prefix(3).indices, id: \.self) { index in let day = forecast[index] - HStack(spacing: 5) { - Image(getWeatherIconName(for: day.status)) + HStack(spacing: 4) { + Image(getWeatherIconName(for: day.localizedStatus(language: appState.selectedLanguage))) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) - if let dayName = day.dayOfWeek { - Text(dayName) - .font(.system(size: 12)) + if let date = day.originalDate { + Text(getDayOfWeek(from: date)) + .font(.system(size: 10)) .foregroundColor(.white) } @@ -66,9 +95,12 @@ struct WeatherView: View { } } - // Влажность и скорость ветра + Divider() + .background(Color.white.opacity(0.8)) + .padding(.vertical, 1) + if let precipitation = today.precipitation { - HStack(spacing: 5) { + HStack(spacing: 4) { Image("det_humidity") .resizable() .aspectRatio(contentMode: .fit) @@ -78,20 +110,25 @@ struct WeatherView: View { .foregroundColor(.white) } } + if let windSpeed = today.windSpeed { HStack(spacing: 5) { Image("det_wind_speed") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) - Text("\(Int(windSpeed)) м/с") + Text(appState.selectedLanguage == "ru" ? "\(Int(windSpeed)) м/с" : + appState.selectedLanguage == "zh" ? "\(Int(windSpeed)) 米/秒" : + "\(Int(windSpeed)) m/s") .font(.system(size: 12)) .foregroundColor(.white) } } } } else { - Text("Загрузка погоды...") + Text(appState.selectedLanguage == "ru" ? "Загрузка погоды..." : + appState.selectedLanguage == "zh" ? "正在加载天气..." : + "Loading weather...") .foregroundColor(.white) .padding() } @@ -117,13 +154,16 @@ struct WeatherView: View { .task { await fetchAndFormatWeather() } + .onChange(of: appState.selectedLanguage) { _ in + // при смене языка день недели пересчитывается динамически в View + } } private func fetchAndFormatWeather() async { let lat = 59.938784 let lng = 30.314997 - guard let url = URL(string: "https://white-nights.krbl.ru/services/weather") else { return } + guard let url = URL(string: "https://white-nights.krbl.ru/services/weather?lang=\(appState.selectedLanguage)") else { return } var request = URLRequest(url: url) request.httpMethod = "POST" @@ -136,14 +176,8 @@ struct WeatherView: View { do { request.httpBody = try JSONSerialization.data(withJSONObject: parameters) let (data, _) = try await URLSession.shared.data(for: request) - - // 💡 Отладка: Печатаем полученные данные для проверки - // print("Received Data (String): \(String(data: data, encoding: .utf8) ?? "N/A")") - let weatherResponse = try JSONDecoder().decode(WeatherResponse.self, from: data) - formatWeatherData(data: weatherResponse) - } catch { print("Ошибка загрузки погоды: \(error)") } @@ -152,103 +186,85 @@ struct WeatherView: View { private func formatWeatherData(data: WeatherResponse) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // UTC - dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Добавление POSIX-локали для надежного парсинга + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.locale = Locale(identifier: "en_US_POSIX") var calendar = Calendar.current - calendar.timeZone = TimeZone(secondsFromGMT: 0)! // UTC + calendar.timeZone = TimeZone(secondsFromGMT: 0)! - // 1. Фильтруем все записи, которые соответствуют 12:00 UTC let middayForecast = data.forecast.filter { item in - guard let date = dateFormatter.date(from: item.date) else { - // 💡 Отладка: если здесь вы увидите сообщения, значит, формат даты API не совпадает с форматом dateFormatter - // print("Парсинг даты провален для: \(item.date)") - return false - } + guard let date = dateFormatter.date(from: item.date) else { return false } return calendar.component(.hour, from: date) == 12 } - - // 💡 Отладка: Проверяем, сколько записей найдено - // print("Найдено записей на 12:00 UTC: \(middayForecast.count)") - - // Сегодня + if let today = data.currentWeather { - self.todayWeather = FormattedWeather( + todayWeather = FormattedWeather( temperature: Int(today.temperatureCelsius.rounded()), - status: WEATHER_STATUS_MAP[today.description] ?? today.description, + statusCode: today.description, precipitation: today.humidity, windSpeed: today.windSpeed, - dayOfWeek: nil + dayOfWeek: nil, + originalDate: nil ) } - // 🚀 ИСПРАВЛЕНИЕ: Прогноз на 3 дня. Берем первые 3 доступные записи с 12:00. var formattedForecast: [FormattedWeather] = [] - - // Перебираем первые 3 записи с 12:00 UTC + for item in middayForecast.prefix(3) { guard let date = dateFormatter.date(from: item.date) else { continue } - let averageTemp = (item.minTemperatureCelsius + item.maxTemperatureCelsius) / 2 - let dayOfWeekString = getDayOfWeek(from: date) - - // 💡 Отладка: Печатаем найденный прогноз - // print("Прогноз найден: \(dayOfWeekString) - \(Int(averageTemp.rounded()))°") - formattedForecast.append(FormattedWeather( temperature: Int(averageTemp.rounded()), - status: WEATHER_STATUS_MAP[item.description] ?? item.description, + statusCode: item.description, precipitation: item.humidity, windSpeed: item.windSpeed, - dayOfWeek: dayOfWeekString + dayOfWeek: getDayOfWeek(from: date), + originalDate: date )) } - // Если данных меньше 3, заполняем оставшиеся места нулями (N/A) let daysToFill = 3 - formattedForecast.count if daysToFill > 0 { let baseDate = Date() - for i in 1...daysToFill { - guard let nextDayForNA = Calendar.current.date(byAdding: .day, value: formattedForecast.count + i, to: baseDate) else { continue } - - let dayOfWeekString = getDayOfWeek(from: nextDayForNA) - + guard let nextDay = Calendar.current.date(byAdding: .day, value: formattedForecast.count + i, to: baseDate) else { continue } formattedForecast.append(FormattedWeather( temperature: 0, - status: "N/A", + statusCode: "N/A", precipitation: nil, windSpeed: nil, - dayOfWeek: dayOfWeekString + dayOfWeek: getDayOfWeek(from: nextDay), + originalDate: nextDay )) } } - + self.forecast = formattedForecast } private func getDayOfWeek(from date: Date) -> String { let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ru_RU") + formatter.locale = appState.selectedLanguage == "ru" ? Locale(identifier: "ru_RU") : + appState.selectedLanguage == "zh" ? Locale(identifier: "zh_CN") : + Locale(identifier: "en_US") formatter.dateFormat = "E" return formatter.string(from: date).capitalized } - + private func getWeatherIconName(for status: String) -> String { let normalizedStatus = status.lowercased() - switch normalizedStatus { - case "солнечно": + case "солнечно", "晴朗", "sunny": return "cond_sunny" - case "облачно": + case "облачно", "多云", "cloudy": return "cond_cloudy" - case "дождливо", "мелкий дождь": + case "дождливо", "мелкий дождь", "下雨", "毛毛雨", "rainy", "drizzle": return "cond_rainy" - case "снег": + case "снег", "下雪", "snowy": return "cond_snowy" - case "гроза": + case "гроза", "雷雨", "thunderstorm": return "cond_thunder" - case "туман": + case "туман", "雾", "fog": return "det_humidity" default: return "cond_sunny"