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"