Initial commit

This commit is contained in:
15lu.akari
2025-08-24 14:44:50 +03:00
parent dca1ae410b
commit 5a583d9415
50 changed files with 2019 additions and 17 deletions

View File

@ -0,0 +1,297 @@
import SwiftUI
import SDWebImageSwiftUI
private extension String {
var trimmedNonEmpty: String? {
let t = trimmingCharacters(in: .whitespacesAndNewlines)
return t.isEmpty ? nil : t
}
}
// MARK: - ViewModel
@MainActor
final class StopsViewModel: ObservableObject {
@Published var stops: [StopDetail] = []
@Published var isLoading: Bool = false
@Published var selectedStopId: Int?
func fetchStops(for routeId: Int) {
guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeId)/station") else { return }
isLoading = true
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let stops = try decoder.decode([StopDetail].self, from: data)
self.stops = stops
} catch {
print("Ошибка загрузки остановок:", error)
self.stops = []
}
self.isLoading = false
}
}
func toggleStop(id: Int) {
withAnimation(.easeInOut(duration: 0.25)) {
if selectedStopId == id {
selectedStopId = nil
} else {
selectedStopId = id
}
}
}
}
// MARK: - BottomMenu
struct BottomMenu: View {
@Binding var isPresented: Bool
@State private var dragOffset: CGFloat = 0
@State private var selectedTab: Tab = .sights
@EnvironmentObject var appState: AppState
@StateObject private var stopsVM = StopsViewModel()
enum Tab { case sights, stops }
private let columns: [GridItem] = Array(
repeating: GridItem(.flexible(), spacing: 16, alignment: .top),
count: 3
)
var body: some View {
GeometryReader { geo in
ZStack {
if isPresented {
Color.black.opacity(0.4)
.ignoresSafeArea()
.onTapGesture { isPresented = false }
.transition(.opacity)
}
VStack {
Spacer()
VStack(spacing: 20) {
Capsule()
.fill(Color.white.opacity(0.8))
.frame(width: 120, height: 3)
.padding(.top, 8)
VStack(spacing: 12) {
menuButton(title: "Достопримечательности", tab: .sights)
menuButton(title: "Остановки", tab: .stops)
if selectedTab == .sights {
// --- Достопримечательности ---
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
ForEach(appState.sights) { sight in
VStack(spacing: 8) {
SightThumbnail(
url: URL(string: "https://white-nights.krbl.ru/services/content/media/\(sight.thumbnail)/download"),
size: 80
)
.onTapGesture {
appState.sightId = sight.id
isPresented = false
}
Text(sight.name)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundColor(.white)
.lineLimit(2)
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
}
} else {
// --- Остановки + пересадки ---
ScrollView {
LazyVStack(spacing: 0) {
if stopsVM.isLoading {
ProgressView("Загрузка остановок...")
.padding()
} else {
ForEach(stopsVM.stops) { stop in
VStack(alignment: .leading, spacing: 0) {
Button {
stopsVM.toggleStop(id: stop.id)
} label: {
Text(stop.name)
.font(.subheadline) // меньше размер
.frame(maxWidth: .infinity, alignment: .leading) // левое выравнивание
.padding(.vertical, 10)
.padding(.horizontal, 20)
.foregroundColor(.white)
.transaction { $0.animation = nil }
}
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(.white.opacity(0.3)),
alignment: .bottom
)
if stopsVM.selectedStopId == stop.id {
transfersView(for: stop)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
}
}
}
.onAppear {
if stopsVM.stops.isEmpty,
let routeId = appState.selectedRoute?.id {
stopsVM.fetchStops(for: routeId)
}
}
}
}
.padding(.horizontal, 20)
.padding(.top, 2)
HStack(spacing: 16) {
Image("GAT_Icon")
.resizable()
.scaledToFit()
.frame(height: 30)
Spacer()
HStack(spacing: 2) {
Image("ru_lang_icon").resizable().scaledToFit().frame(width: 24, height: 24)
Image("zh_lang_icon").resizable().scaledToFit().frame(width: 24, height: 24)
Image("en_lang_icon").resizable().scaledToFit().frame(width: 24, height: 24)
}
}
.padding(.horizontal, 26)
.padding(.top, 16)
.padding(.bottom, 32)
}
.frame(height: geo.size.height * 0.8)
.frame(maxWidth: .infinity)
.background(
Color(hex: 0x806C59)
.cornerRadius(24, corners: [.topLeft, .topRight])
.shadow(radius: 10)
)
.offset(y: isPresented ? dragOffset : geo.size.height)
.animation(.spring(response: 0.35, dampingFraction: 0.9), value: isPresented)
.animation(.spring(response: 0.35, dampingFraction: 0.9), value: dragOffset)
.gesture(
DragGesture()
.onChanged { value in
if value.translation.height > 0 {
dragOffset = value.translation.height
}
}
.onEnded { value in
if value.translation.height > 100 { isPresented = false }
dragOffset = 0
}
)
}
.ignoresSafeArea(edges: .bottom)
}
}
}
// MARK: - Menu Button
@ViewBuilder
private func menuButton(title: String, tab: Tab) -> some View {
Button { selectedTab = tab } label: {
Text(title)
.frame(maxWidth: .infinity)
.frame(height: 47)
.foregroundColor(.white)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedTab == tab ? Color(hex: 0x6D5743) : Color(hex: 0xB3A598))
)
}
.buttonStyle(.plain)
}
// MARK: - Transfers View
@ViewBuilder
private func transfersView(for stop: StopDetail) -> some View {
let t = stop.transfers
let items: [(String, String)] = [
("tram_icon", t.tram?.trimmedNonEmpty),
("trolley_icon", t.trolleybus?.trimmedNonEmpty),
("bus_icon", t.bus?.trimmedNonEmpty),
("train_icon", t.train?.trimmedNonEmpty),
("metro_red_icon", t.metroRed?.trimmedNonEmpty),
("metro_green_icon", t.metroGreen?.trimmedNonEmpty),
("metro_blue_icon", t.metroBlue?.trimmedNonEmpty),
("metro_purple_icon", t.metroPurple?.trimmedNonEmpty),
("metro_orange_icon", t.metroOrange?.trimmedNonEmpty),
].compactMap { icon, text in
guard let text = text else { return nil }
return (icon, text)
}
if items.isEmpty {
Text("Нет пересадок")
.font(.caption) // меньше размер
.foregroundColor(.white.opacity(0.7))
.padding(.leading, 20)
.padding(.vertical, 8)
} else {
VStack(alignment: .leading, spacing: 6) { // меньше расстояние
ForEach(items, id: \.0) { icon, text in
HStack(spacing: 8) {
Image(icon)
.resizable()
.scaledToFit()
.frame(width: 18, height: 18)
Text(text)
.font(.caption) // меньше размер
.foregroundColor(.white)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading) // левое выравнивание
}
.padding(.leading, 20)
.padding(.vertical, 4)
}
}
.padding(.bottom, 8)
.padding(.top, 6)
}
}
}
// MARK: - Sight Thumbnail
struct SightThumbnail: View {
let url: URL?
let size: CGFloat
var body: some View {
ZStack {
Circle()
.fill(Color.white)
.frame(width: size, height: size)
WebImage(url: url)
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.clipShape(Circle())
.overlay(
Group {
if url == nil {
ProgressView().frame(width: size, height: size)
}
}
)
}
}
}

View File

@ -0,0 +1,52 @@
import SwiftUI
struct RouteSelectionView: View {
@EnvironmentObject var appState: AppState
@State private var routes: [Route] = []
@State private var isLoading = true
var body: some View {
VStack {
if isLoading {
ProgressView("Загрузка маршрутов...")
.padding()
} else {
List(routes, id: \.id) { route in
Button(action: {
appState.selectedRoute = route
}) {
HStack {
Text("\(route.routeNumber)")
.font(.headline)
}
.padding(.vertical, 8)
}
}
.listStyle(PlainListStyle())
}
}
.navigationTitle("Выберите маршрут")
.onAppear {
Task {
await fetchRoutes()
}
}
}
// MARK: - Fetch Routes
private func fetchRoutes() async {
isLoading = true
defer { isLoading = false }
guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route") else { return }
do {
let (data, _) = try await URLSession.shared.data(from: url)
let fetchedRoutes = try JSONDecoder().decode([Route].self, from: data)
routes = fetchedRoutes
} catch {
print("Ошибка загрузки маршрутов: \(error)")
}
}
}

View File

@ -0,0 +1,108 @@
import SwiftUI
struct RouteView: View {
@EnvironmentObject var appState: AppState
@State private var firstStationName: String = "Загрузка..."
@State private var lastStationName: String = "Загрузка..."
@State private var engStationsName: String = "Загрузка..."
private var topBackgroundColor = Color(hex: 0xFCD500)
var body: some View {
NavigationLink(destination: RouteSelectionView()) {
GeometryReader { geo in
VStack(spacing: 0) {
// Верхняя половина: номер маршрута
VStack {
if let route = appState.selectedRoute {
Text("\(route.routeNumber)")
.font(.system(size: 36, weight: .black))
.foregroundColor(.black)
.multilineTextAlignment(.center)
.frame(maxHeight: .infinity, alignment: .center)
}
}
.frame(height: geo.size.height / 2)
.frame(maxWidth: .infinity)
.background(topBackgroundColor)
.cornerRadius(16, corners: [.topLeft, .topRight])
// Нижняя половина: станции
VStack(spacing: 0) {
MarqueeText(
text: firstStationName,
font: .headline.bold(),
foregroundColor: .white
)
MarqueeText(
text: lastStationName,
font: .headline.bold(),
foregroundColor: .white
)
MarqueeText(
text: engStationsName,
font: .caption,
foregroundColor: .white.opacity(0.5)
)
}
.padding(10)
.frame(height: geo.size.height / 2)
.frame(maxWidth: .infinity)
.background(
ZStack {
Color(hex: 0x806C59)
LinearGradient(
stops: [
.init(color: .white.opacity(0.0), location: 0.0871),
.init(color: .white.opacity(0.16), location: 0.6969)
],
startPoint: .bottomLeading,
endPoint: .topTrailing
)
}
)
.cornerRadius(16, corners: [.bottomLeft, .bottomRight])
}
}
.aspectRatio(1, contentMode: .fit)
.shadow(color: Color.black.opacity(0.10), radius: 8, x: 0, y: 2)
}
.buttonStyle(.plain)
.onChange(of: appState.selectedRoute?.id) { newRouteID in
guard let routeID = newRouteID else { return }
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 }
do {
let (data, _) = try await URLSession.shared.data(from: url)
let (dataEn, _) = try await URLSession.shared.data(from: urlEng)
let stations = try JSONDecoder().decode([Station].self, from: data)
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 }
let firstStationEn = stationsEn.first?.name ?? "Loading..."
let lastStationEn = stationsEn.last?.name ?? "Loading..."
engStationsName = "\(firstStationEn) - \(lastStationEn)"
} catch {
print("Ошибка загрузки станций: \(error)")
}
}
}

View File

@ -0,0 +1,137 @@
import SwiftUI
import AVKit
import NukeUI
import UIKit
// MARK: - SightView
struct SightView: View {
let sightId: Int
@StateObject private var viewModel = SightViewModel()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
mediaSection
VStack(alignment: .leading, spacing: 8) {
// Заголовок статьи
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)
// Тело статьи
ScrollView {
if viewModel.selectedArticle?.isReviewArticle == true {
VStack {
Text(viewModel.articleBody)
.font(.system(size: 13))
.foregroundColor(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
Text(viewModel.articleBody)
.font(.system(size: 13))
.foregroundColor(.white)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// Список статей (кнопки навигации) - ФИНАЛЬНОЕ ИСПРАВЛЕНИЕ ЦЕНТРИРОВАНИЯ
GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
// Spacers для центрирования
Spacer(minLength: 0)
ForEach(viewModel.allArticles) { article in
Text(article.heading)
.font(.system(size: 12))
.lineLimit(1)
.padding(.vertical, 6)
.padding(.horizontal, 6)
.frame(minWidth: 70)
.foregroundColor(.white)
.overlay(
Rectangle()
.frame(height: 2)
.foregroundColor(viewModel.selectedArticle == article ? Color.white : Color.clear),
alignment: .bottom
)
.onTapGesture {
viewModel.selectArticle(article)
}
}
Spacer(minLength: 0)
}
// Принудительно задаем ширину HStack как ширину GeometryReader
.frame(minWidth: geometry.size.width)
}
.scrollIndicators(.hidden) // Скрываем полосу прокрутки
}
// Задаем высоту для GeometryReader
.frame(height: 34)
.frame(maxWidth: .infinity)
}
.padding(.horizontal, 8)
.padding(.bottom, 10)
}
.task(id: sightId) {
await viewModel.loadInitialData(sightId: sightId)
}
.blockStyle(cornerRadius: 25)
}
// Медиа-секция
@ViewBuilder
private var mediaSection: some View {
Group {
switch viewModel.mediaState {
case .loading:
ZStack {
Color.gray.opacity(0.3)
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
}
.frame(maxWidth: .infinity)
.aspectRatio(16/9, contentMode: .fit)
.cornerRadius(24, corners: [.topLeft, .topRight])
.clipped()
case .image(let url):
LazyImage(url: url) { state in
if let image = state.image {
image
.resizable()
.scaledToFit()
} else {
ProgressView()
}
}
.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()
.aspectRatio(contentMode: .fit)
.foregroundColor(.gray)
.frame(maxWidth: .infinity, minHeight: 200)
.cornerRadius(24, corners: [.topLeft, .topRight])
.clipped()
}
}
.padding(4)
.frame(maxWidth: .infinity)
}
}

View File

@ -0,0 +1,98 @@
import Foundation
import AVKit
import Combine
@MainActor
class SightViewModel: ObservableObject {
@Published var sightName: String = "Загрузка..."
@Published var allArticles: [Article] = []
@Published var selectedArticle: Article?
@Published var articleHeading: String = ""
@Published var articleBody: String = ""
@Published var mediaState: MediaState = .loading
private var sightModel: SightModel?
enum MediaState {
case loading
case image(URL)
case video(AVPlayer)
case error
}
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)
let (fetchedSightModel, fetchedArticles) = try await (sightModelTask, articlesTask)
self.sightModel = fetchedSightModel
self.sightName = fetchedSightModel.name
let reviewArticle = Article(id: -1, body: "", heading: "Обзор", isReviewArticle: true)
self.allArticles = [reviewArticle] + fetchedArticles
selectArticle(reviewArticle)
} catch {
print("Ошибка начальной загрузки данных: \(error)")
self.mediaState = .error
}
}
func selectArticle(_ article: Article) {
guard selectedArticle != article else { return }
self.selectedArticle = article
self.articleHeading = article.heading
self.articleBody = article.body
Task {
await updateMedia(for: article)
}
}
private func updateMedia(for article: Article) async {
self.mediaState = .loading
if article.isReviewArticle == true {
guard let sight = sightModel else {
self.mediaState = .error
return
}
if let videoPreviewId = sight.video_preview,
let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(videoPreviewId)/download") {
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") {
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)
if let firstMedia = mediaItems.first,
let url = URL(string: "https://white-nights.krbl.ru/services/content/media/\(firstMedia.id)/download") {
self.mediaState = .image(url)
} else {
self.mediaState = .error
}
} catch {
print("Ошибка загрузки медиа для статьи '\(article.heading)': \(error)")
self.mediaState = .error
}
}
}
private func fetchJSON<T: Decodable>(from urlString: String, type: T.Type) async throws -> T {
guard let url = URL(string: urlString) else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode(T.self, from: data)
}
}

View File

@ -0,0 +1,11 @@
import SwiftUI
struct VisualEffectBlur: UIViewRepresentable {
var blurStyle: UIBlurEffect.Style
func makeUIView(context: Context) -> UIVisualEffectView {
UIVisualEffectView(effect: UIBlurEffect(style: blurStyle))
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) { }
}

View File

@ -0,0 +1,257 @@
import SwiftUI
private let WEATHER_STATUS_MAP: [String: String] = [
"Rain": "дождливо",
"Clouds": "облачно",
"Clear": "солнечно",
"Thunderstorm": "гроза",
"Snow": "снег",
"Drizzle": "мелкий дождь",
"Fog": "туман"
]
struct FormattedWeather {
let temperature: Int
let status: String
let precipitation: Int?
let windSpeed: Double?
let dayOfWeek: String?
}
struct WeatherView: View {
@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))
.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)
.font(.system(size: 12))
.foregroundColor(.white)
}
// ПРАВЫЙ СТОЛБЕЦ: Прогноз с иконками
VStack(alignment: .leading, spacing: 10) {
// Прогноз на 3 дня
ForEach(forecast.prefix(3).indices, id: \.self) { index in
let day = forecast[index]
HStack(spacing: 5) {
Image(getWeatherIconName(for: day.status))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
if let dayName = day.dayOfWeek {
Text(dayName)
.font(.system(size: 12))
.foregroundColor(.white)
}
Text("\(day.temperature)°")
.font(.system(size: 12))
.foregroundColor(.white)
.fontWeight(.bold)
}
}
// Влажность и скорость ветра
if let precipitation = today.precipitation {
HStack(spacing: 5) {
Image("det_humidity")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
Text("\(precipitation)%")
.font(.system(size: 12))
.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)) м/с")
.font(.system(size: 12))
.foregroundColor(.white)
}
}
}
} else {
Text("Загрузка погоды...")
.foregroundColor(.white)
.padding()
}
}
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(1, contentMode: .fit)
.background(
ZStack {
Color(hex: 0x806C59)
LinearGradient(
stops: [
.init(color: .white.opacity(0.0), location: 0.0871),
.init(color: .white.opacity(0.16), location: 0.6969)
],
startPoint: .bottomLeading,
endPoint: .topTrailing
)
}
)
.cornerRadius(25)
.shadow(color: Color.black.opacity(0.10), radius: 8, x: 0, y: 2)
.task {
await fetchAndFormatWeather()
}
}
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 }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let parameters: [String: Any] = [
"coordinates": ["latitude": lat, "longitude": lng]
]
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)")
}
}
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-локали для надежного парсинга
var calendar = Calendar.current
calendar.timeZone = TimeZone(secondsFromGMT: 0)! // UTC
// 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
}
return calendar.component(.hour, from: date) == 12
}
// 💡 Отладка: Проверяем, сколько записей найдено
// print("Найдено записей на 12:00 UTC: \(middayForecast.count)")
// Сегодня
if let today = data.currentWeather {
self.todayWeather = FormattedWeather(
temperature: Int(today.temperatureCelsius.rounded()),
status: WEATHER_STATUS_MAP[today.description] ?? today.description,
precipitation: today.humidity,
windSpeed: today.windSpeed,
dayOfWeek: 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,
precipitation: item.humidity,
windSpeed: item.windSpeed,
dayOfWeek: dayOfWeekString
))
}
// Если данных меньше 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)
formattedForecast.append(FormattedWeather(
temperature: 0,
status: "N/A",
precipitation: nil,
windSpeed: nil,
dayOfWeek: dayOfWeekString
))
}
}
self.forecast = formattedForecast
}
private func getDayOfWeek(from date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "ru_RU")
formatter.dateFormat = "E"
return formatter.string(from: date).capitalized
}
private func getWeatherIconName(for status: String) -> String {
let normalizedStatus = status.lowercased()
switch normalizedStatus {
case "солнечно":
return "cond_sunny"
case "облачно":
return "cond_cloudy"
case "дождливо", "мелкий дождь":
return "cond_rainy"
case "снег":
return "cond_snowy"
case "гроза":
return "cond_thunder"
case "туман":
return "det_humidity"
default:
return "cond_sunny"
}
}
}