big update

This commit is contained in:
15lu.akari
2025-08-26 23:37:39 +03:00
parent 6b88875bee
commit a87a3d12ab
34 changed files with 803 additions and 282 deletions

View File

@ -18,11 +18,24 @@ class AppState: ObservableObject {
@Published var allRoutes: [Route] = [] @Published var allRoutes: [Route] = []
@Published var sightId: Int? = nil @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 // MARK: - Fetch Sights
private func fetchSights(for routeId: Int) async { 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 } guard let url = URL(string: urlString) else { return }
do { do {
@ -50,7 +63,8 @@ class AppState: ObservableObject {
private func preloadThumbnails(for sights: [SightModel]) { private func preloadThumbnails(for sights: [SightModel]) {
let session = URLSession.shared let session = URLSession.shared
for sight in sights { 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) let request = URLRequest(url: url)
// Если уже в кэше, пропускаем // Если уже в кэше, пропускаем

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -1,6 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "1024-logo.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

View File

@ -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
}
}

View File

@ -0,0 +1,11 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_27605)">
<path d="M63.0304 32.1746C62.9334 49.3188 48.9697 63.1273 31.8255 63.0304C14.6813 62.9334 0.872776 48.9697 0.969746 31.8061C1.08611 14.6813 15.0497 0.872776 32.1746 0.969746C49.3188 1.06672 63.1467 15.0497 63.0304 32.1746Z" fill="white"/>
<path d="M41.6582 12.2764L22.5939 12.16C19.5879 12.1406 17.1248 14.5649 17.1054 17.5903L16.9503 44.2182C16.9503 46.7976 18.7151 48.9503 21.12 49.5322V49.7261C21.12 50.8315 22.0121 51.7237 23.1176 51.7431C24.223 51.7431 25.1151 50.8509 25.1345 49.7649V49.7455L38.5551 49.8231V49.8425C38.5551 50.9285 39.4473 51.84 40.5333 51.8594C41.6388 51.8594 42.5503 50.9673 42.5503 49.8812V49.7455C45.0521 49.2606 46.9333 47.0691 46.9527 44.4121L47.1079 17.8037C47.1079 14.7588 44.6642 12.2958 41.6582 12.2764ZM20.9067 22.4582C20.9067 21.0231 22.3612 19.8788 24.0873 19.8788L39.8739 19.9758C41.6194 19.9758 43.0545 21.1588 43.0351 22.594L42.9963 31.8061C42.977 33.2412 41.5418 34.3855 39.7963 34.3855L24.0097 34.2885C22.2642 34.2885 20.8485 33.1055 20.8485 31.6703L20.9067 22.4582ZM24.8436 41.9103C24.3782 42.4146 23.7963 42.6473 23.0594 42.6473C22.3418 42.6473 21.7406 42.3952 21.2557 41.8909C20.7709 41.3867 20.5382 40.7855 20.5382 40.0679C20.5382 39.3503 20.7903 38.7685 21.3139 38.3225C21.8376 37.8764 22.4388 37.6631 23.1176 37.6631C23.8351 37.6631 24.3976 37.9152 24.863 38.3806C25.3091 38.8655 25.5418 39.4473 25.5418 40.0873C25.5612 40.8049 25.3091 41.4255 24.8436 41.9103ZM42.4533 42.0073C41.9685 42.5115 41.3866 42.7443 40.6691 42.7443C39.9515 42.7443 39.3503 42.4921 38.8848 41.9879C38.4 41.4837 38.1479 40.8825 38.1673 40.1649C38.1673 39.4473 38.4194 38.8655 38.943 38.4194C39.4667 37.9734 40.0679 37.76 40.7467 37.76C41.4642 37.76 42.0266 37.9928 42.4921 38.4776C42.9382 38.9818 43.1709 39.5249 43.1515 40.1843C43.1515 40.9406 42.9188 41.5418 42.4533 42.0073Z" fill="#816C5A"/>
</g>
<defs>
<clipPath id="clip0_1_27605">
<rect width="64" height="64" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#2D3B8E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#056939"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#EB5C2C"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#64328A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M62.9333 31.9612C62.9333 14.8364 49.0085 0.969727 31.9418 0.969727C14.8752 0.969727 0.872742 14.817 0.872742 31.9612C0.872742 49.0861 14.7006 63.0303 31.9418 63.0303C49.183 63.0303 62.9333 49.1249 62.9333 31.9612Z" fill="#E52629"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M55.7963 31.0497C55.0982 21.1588 48.1163 13.9249 40.223 11.2097C37.4497 20.2667 34.7151 29.4788 31.903 38.4194C29.0715 29.4788 26.3563 20.2667 23.583 11.2097C15.6897 13.9249 8.70786 21.1588 8.00968 31.0497C7.91271 32.1358 7.91271 33.1443 8.00968 34.1334C8.35877 40.6109 11.1903 46.4097 14.8169 49.9782H24.3782C24.5139 49.5321 23.583 49.0861 23.0982 48.7952C19.4133 46.1188 15.7091 42.7055 13.9248 37.9927C11.4618 31.903 12.8 24.2812 16.1551 19.9758C17.0473 18.9479 20.0145 16.1746 21.8957 17.6097C22.5357 18.114 22.9818 20.3443 23.3891 21.4691C26.2594 30.72 31.8254 48.9309 31.8254 48.9309L31.9224 49.2606L32.0194 48.9309C32.0194 48.9309 37.5854 30.72 40.4557 21.4691C40.8436 20.3443 41.2897 18.0946 41.9491 17.6097C43.8303 16.1746 46.7976 18.9479 47.6897 19.9758C51.0642 24.2812 52.4024 31.903 49.92 37.9927C48.1357 42.7055 44.4121 46.1188 40.7466 48.7952C40.2618 49.0861 39.3309 49.5321 39.4666 49.9782H49.0279C52.6545 46.4097 55.4666 40.6303 55.8351 34.1334C55.9127 33.1443 55.9127 32.1358 55.7963 31.0497Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M48 24C48 10.75 37.25 -1.51306e-05 24 -1.49726e-05C10.75 -1.48146e-05 1.28192e-07 10.75 2.86197e-07 24C4.44202e-07 37.25 10.75 48 24 48C37.25 48 48 37.25 48 24ZM22.16 37.54C21.6 37.54 21.15 37.09 21.15 36.53L21.15 20.59C21.15 20.14 20.61 19.92 20.29 20.23L12.84 27.68C12.44 28.08 11.8 28.08 11.41 27.68L8.81 25.08C8.41 24.68 8.41 24.04 8.81 23.65L19.97 12.49L23.29 9.16999C23.69 8.76999 24.33 8.76999 24.72 9.16999L28.04 12.49L39.2 23.65C39.6 24.05 39.6 24.69 39.2 25.08L36.6 27.68C36.2 28.08 35.56 28.08 35.17 27.68L27.72 20.23C27.4 19.91 26.86 20.14 26.86 20.59L26.86 36.53C26.86 37.09 26.41 37.54 25.85 37.54L22.18 37.54L22.16 37.54Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 765 B

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M63.0303 31.5927C63.0303 14.4485 49.0279 0.581787 31.9224 0.581787C14.817 0.581787 0.969696 14.4291 0.969696 31.5927C0.969696 48.7369 14.7588 62.6424 31.9224 62.6424C49.0861 62.6424 63.0303 48.7563 63.0303 31.5927Z" fill="white"/>
<path d="M45.2655 39.5054V18.2497C45.2655 15.2824 42.7443 12.4703 39.2534 12.4703H33.2994L34.6764 8.16481V8.04845C38.4 8.14542 39.0788 8.45572 39.137 8.68845C39.137 8.8436 39.3891 8.88239 39.3891 8.88239C39.3891 8.88239 39.8352 8.8436 39.8352 8.63027C39.8352 8.55269 39.8352 8.28117 39.6994 8.14542C39.0206 7.60239 36.7322 7.2533 31.8255 7.2533C29.537 7.2533 27.6558 7.40845 26.3176 7.54421C24.4752 7.73814 23.5831 8.04845 23.5831 8.63027C23.5831 8.8242 23.777 8.88239 23.777 8.88239C23.777 8.88239 24.3782 8.8436 24.3782 8.68845C24.3782 8.61087 25.0182 8.14542 28.8388 8.04845V8.14542L30.6231 12.4509H24.5722C21.1394 12.4509 18.5794 15.263 18.5794 18.2303V39.486C18.5794 42.4533 20.8679 44.8387 23.1952 45.1878H25.2703L19.6073 54.9236H22.2255L22.3806 54.7297C22.3806 54.6521 22.5746 54.5939 22.5746 54.5939H41.1346C41.1346 54.5939 41.3867 54.6327 41.5225 54.7297V54.9236H44.2958L38.6328 45.1878H40.6691C42.977 44.8581 45.2655 42.4727 45.2655 39.5054ZM29.5952 8.00966H34.0558L32.5043 12.4703H31.3213L29.5952 8.00966ZM28.0437 14.6424C28.0437 14.1381 28.4897 13.6921 29.0522 13.6921H34.7928C35.394 13.6921 35.7819 14.1381 35.7819 14.6424V16.3684C35.7819 16.9115 35.3746 17.3188 34.7928 17.3188H29.0522C28.4122 17.3188 28.0437 16.9115 28.0437 16.3684V14.6424ZM21.7406 21.4691C21.7406 19.84 22.8849 18.5018 24.7273 18.5018H39.0982C41.0376 18.5018 42.0267 19.84 42.0267 21.4691V25.2703C42.1819 27.1709 40.7467 28.2569 39.0982 28.2569H24.7273C23.04 28.2569 21.7406 27.1709 21.7406 25.2703V21.4691ZM36.0922 45.343C36.0922 45.44 37.0813 47.1272 37.2364 47.3988C37.3528 47.5345 37.2364 47.5927 37.2364 47.5927H26.9188C26.9188 47.5927 26.6085 47.5539 26.6667 47.3988C26.7831 47.1466 27.7722 45.4594 27.811 45.343C27.8497 45.2654 27.9661 45.2072 27.9661 45.2072H35.8013C35.7819 45.2072 35.9758 45.2654 36.0922 45.343ZM24.4752 42.1236C23.0013 42.1236 21.7406 40.9988 21.7406 39.4666C21.7406 38.0121 23.0013 36.8872 24.4752 36.8872C25.8134 36.8872 27.0546 38.0315 27.0546 39.4666C27.0546 40.9988 25.8134 42.1236 24.4752 42.1236ZM38.7685 50.0169C38.8655 50.0557 39.7188 51.84 39.8546 52.0533C40.0097 52.1503 39.8546 52.2472 39.8546 52.2472H24.1455C24.1455 52.2472 23.8934 52.1697 23.9903 52.0533C24.0873 51.8594 25.1346 50.0751 25.1346 50.0169C25.1734 49.92 25.2897 49.8618 25.2897 49.8618H38.5358C38.5164 49.8618 38.6522 49.9006 38.7685 50.0169ZM36.7322 39.4666C36.7322 38.0121 37.8764 36.8872 39.4085 36.8872C40.7467 36.8872 41.9297 38.0315 41.9297 39.4666C41.9297 40.9988 40.7467 42.1236 39.4085 42.1236C37.8764 42.1236 36.7322 40.9988 36.7322 39.4666Z" fill="#816C5A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,12 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_27600)">
<path d="M64 31.7866C63.903 48.9309 49.92 62.7587 32.7952 62.6618C15.6509 62.5648 1.84244 48.5818 1.93941 31.4181C2.03638 14.2739 16 0.446018 33.1443 0.542987C50.2885 0.639957 64.097 14.623 64 31.7866Z" fill="white"/>
<path d="M42.5891 17.9588L23.5249 17.8424C20.4994 17.823 18.0364 20.2666 18.017 23.2727L17.8619 49.92C17.8425 52.9454 20.2667 55.3891 23.2922 55.4279L42.3564 55.5442C45.3819 55.5636 47.8255 53.12 47.8449 50.1139L48.0001 23.4666C48.0194 20.4412 45.5952 17.9782 42.5891 17.9588ZM21.8376 28.1406C21.8376 26.7054 23.2728 25.5418 25.0182 25.5612L40.8049 25.6582C42.5697 25.6776 43.9855 26.8412 43.9661 28.2763L43.9079 37.5273C43.9079 38.943 42.4728 40.1066 40.7079 40.1066L24.9213 40.0097C23.1758 39.9903 21.7407 38.8266 21.7601 37.3915L21.8376 28.1406ZM25.7746 47.6121C25.3091 48.0969 24.7273 48.3491 23.9904 48.3491C23.2728 48.3491 22.6716 48.0969 22.1867 47.5927C21.7019 47.0885 21.4691 46.4872 21.4691 45.7697C21.4691 45.0521 21.7213 44.4703 22.2449 44.0242C22.7491 43.5782 23.3504 43.3454 24.0485 43.3454C24.7661 43.3454 25.3479 43.5976 25.794 44.0824C26.2594 44.5672 26.4728 45.1297 26.4534 45.7891C26.4728 46.5066 26.2401 47.1079 25.7746 47.6121ZM43.3843 47.7091C42.8994 48.2133 42.3176 48.4654 41.6001 48.446C40.8825 48.4266 40.2813 48.1745 39.8158 47.6897C39.331 47.1854 39.0982 46.5842 39.0982 45.886C39.0982 45.1491 39.3697 44.5866 39.874 44.1212C40.3976 43.6751 40.9794 43.4424 41.6776 43.4424C42.3952 43.4424 42.977 43.6945 43.4231 44.1794C43.8691 44.6642 44.1019 45.2266 44.0825 45.886C44.1019 46.6036 43.8497 47.2048 43.3843 47.7091Z" fill="#816C5A"/>
<path d="M25.9297 9.46426L28.2764 16.4461C28.5091 17.1443 29.2654 17.5127 29.9636 17.2994C30.6618 17.0667 31.0303 16.2909 30.7976 15.5927L29.0521 10.3952L36.8291 10.434L35.006 15.6121C34.7733 16.3103 35.1418 17.0667 35.84 17.3188C36.5382 17.5515 37.2945 17.2024 37.5467 16.5043L39.9709 9.54184C39.9903 9.50305 39.9903 9.44487 40.0097 9.40608C40.0097 9.40608 40.0097 9.38668 40.0097 9.36729C40.0097 9.30911 40.0291 9.25093 40.0291 9.17335C40.0291 9.15396 40.0291 9.13456 40.0291 9.11517C40.0291 9.09577 40.0291 9.07638 40.0291 9.07638C40.0291 8.97941 40.0291 8.92123 40.0097 8.84365C39.9321 8.39759 39.6024 7.99032 39.1564 7.83517C38.9624 7.77699 38.7879 7.75759 38.5939 7.77699C38.5357 7.77699 38.497 7.75759 38.4582 7.75759L27.4424 7.69941C27.4036 7.69941 27.3648 7.69941 27.3261 7.69941C27.1515 7.68002 26.9576 7.69941 26.7636 7.75759C26.3176 7.91274 26.0073 8.26184 25.8909 8.7079C25.8521 8.80487 25.8521 8.90184 25.8521 9.03759C25.8521 9.17335 25.8715 9.28971 25.9103 9.40608C25.9103 9.42547 25.9297 9.44487 25.9297 9.46426Z" fill="#816C5A"/>
</g>
<defs>
<clipPath id="clip0_1_27600">
<rect width="64" height="64" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -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
}
}

View File

@ -0,0 +1,13 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_27609)">
<path d="M63.0303 32.1746C62.9333 49.3382 48.9503 63.1467 31.8254 63.0691C14.6812 62.9527 0.872715 48.9697 0.969685 31.8061C1.06665 14.6618 15.0497 0.833957 32.1745 0.930927C49.3188 1.04729 63.1273 15.0109 63.0303 32.1746Z" fill="white"/>
<path d="M41.6194 16.7176L22.5552 16.6206C19.5491 16.6012 17.0667 19.0448 17.0667 22.0509L16.8922 48.6982C16.8728 51.2776 18.657 53.4303 21.0619 54.0315V54.2061C21.0619 55.3115 21.954 56.2036 23.04 56.223C24.1455 56.223 25.057 55.3309 25.057 54.2448V54.2254L38.4776 54.303C38.4776 55.4085 39.3697 56.32 40.4558 56.32C41.5806 56.32 42.4728 55.4279 42.4728 54.3224V54.1867C44.9746 53.7018 46.8558 51.5103 46.8752 48.8727L47.0303 22.2254C47.0691 19.2 44.6255 16.737 41.6194 16.7176ZM20.8485 26.8994C20.8485 25.4836 22.2837 24.32 24.0485 24.3394L39.8158 24.417C41.5612 24.417 42.977 25.5806 42.977 27.0158L42.9188 36.2667C42.9188 37.7018 41.4837 38.8654 39.7188 38.8461L23.9322 38.7491C22.1673 38.7491 20.7515 37.5661 20.7709 36.1309L20.8485 26.8994ZM24.7855 46.3903C24.32 46.8751 23.7382 47.1273 23.0012 47.1273C22.2837 47.1273 21.6825 46.8751 21.1976 46.3709C20.7128 45.8667 20.4994 45.2654 20.4994 44.5479C20.4994 43.8303 20.7515 43.2485 21.2752 42.8024C21.7988 42.3564 22.3806 42.143 23.0788 42.143C23.777 42.143 24.3782 42.3951 24.8243 42.88C25.2703 43.3648 25.5031 43.9467 25.5031 44.5867C25.5031 45.2848 25.2509 45.9054 24.7855 46.3903ZM42.3952 46.5067C41.9297 46.9915 41.3285 47.2436 40.6109 47.2242C39.8934 47.2242 39.3116 46.9721 38.8267 46.4679C38.3419 45.9636 38.1091 45.343 38.1091 44.6448C38.1091 43.9273 38.3806 43.3454 38.8849 42.8994C39.4085 42.4533 40.0097 42.2206 40.6885 42.24C41.4061 42.24 41.9879 42.4921 42.434 42.977C42.88 43.4618 43.0934 44.0242 43.0934 44.6836C43.1128 45.3818 42.8606 45.983 42.3952 46.5067Z" fill="#816C5A"/>
<path d="M28.5091 16.0581C29.2849 16.349 30.1576 15.9223 30.4097 15.1272L33.1443 7.31144C33.4352 6.51629 33.0279 5.66296 32.2328 5.39144C31.457 5.11993 30.6037 5.5272 30.3128 6.32235L27.5782 14.1381C27.3067 14.9139 27.714 15.7672 28.5091 16.0581Z" fill="#816C5A"/>
<path d="M33.6097 16.0775C34.3854 16.3684 35.2581 15.9418 35.5296 15.166L38.2642 7.33085C38.5357 6.5357 38.1284 5.68237 37.3333 5.41085C36.5575 5.13933 35.7042 5.54661 35.4327 6.34176L32.7175 14.1575C32.4266 14.9527 32.8533 15.806 33.6097 16.0775Z" fill="#816C5A"/>
</g>
<defs>
<clipPath id="clip0_1_27609">
<rect width="64" height="64" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -3,10 +3,12 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@State private var showMenu = false @State private var showMenu = false
@State private var isLoading = true // состояние загрузки
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
// Основной контент
VStack(spacing: 10) { VStack(spacing: 10) {
HStack(spacing: 10) { HStack(spacing: 10) {
RouteView() RouteView()
@ -22,8 +24,10 @@ struct ContentView: View {
} }
} }
.padding() .padding()
.blur(radius: isLoading ? 5 : 0) // слегка размываем, пока загрузка
.disabled(isLoading) // блокируем взаимодействие с контентом при загрузке
// Плавающая кнопка в правом нижнем углу // Плавающая кнопка
VStack { VStack {
Spacer() Spacer()
HStack { HStack {
@ -33,35 +37,60 @@ struct ContentView: View {
showMenu.toggle() showMenu.toggle()
} }
}) { }) {
Image(systemName: "line.3.horizontal") Image("open_menu_icon")
.font(.system(size: 24, weight: .bold)) .resizable()
.foregroundColor(.white) .aspectRatio(contentMode: .fit)
.padding() .frame(width: 24, height: 24)
.background(Color.blue) .padding(10)
.background(Color(hex: 0x806C59))
.clipShape(Circle()) .clipShape(Circle())
.shadow(radius: 5) .shadow(radius: 5)
} }
.padding(.trailing, 20) .padding(.trailing, 20)
// .padding(.bottom, -20) // Убираем отрицательный паддинг .padding(.bottom, -20)
.padding(.bottom, -20) // Добавляем паддинг, чтобы кнопка не перекрывалась
} }
} }
// Используем кастомное BottomMenu // BottomMenu
if showMenu { if showMenu {
// Используем Binding для двусторонней связи
BottomMenu(isPresented: $showMenu) BottomMenu(isPresented: $showMenu)
.transition(.move(edge: .bottom)) .transition(.move(edge: .bottom))
// Добавляем игнорирование safe area сверху, если BottomMenu .animation(.spring(response: 0.35, dampingFraction: 0.9), value: showMenu)
// должно полностью закрывать контент, но обычно для }
// BottomSheet это не требуется, GeometryReader в BottomMenu
// уже делает нужное растяжение. // Экран загрузки
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 { .task {
await fetchRoutes() await fetchRoutes()
// Убираем экран загрузки через 2 секунды
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeOut(duration: 0.5)) {
isLoading = false
}
}
} }
.preferredColorScheme(.light) .preferredColorScheme(.light)
} }

View File

@ -1,20 +1,20 @@
//
// WhiteNightsApp.swift
// WhiteNights
//
// Created by Микаэл Оганесян on 24.08.2025.
//
import SwiftUI import SwiftUI
import SDWebImageSVGCoder // <-- импортируем SVG кодер
@main @main
struct WhiteNightsApp: App { struct WhiteNightsApp: App {
@StateObject private var appState = AppState() @StateObject private var appState = AppState()
init() {
// Регистрируем SVG кодер
let svgCoder = SDImageSVGCoder.shared
SDImageCodersManager.shared.addCoder(svgCoder)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environmentObject(appState) // <- обязательно! .environmentObject(appState)
} }
} }
} }

View File

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

View File

@ -2,6 +2,7 @@ import SwiftUI
struct RouteSelectionView: View { struct RouteSelectionView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@State private var routes: [Route] = [] @State private var routes: [Route] = []
@State private var isLoading = true @State private var isLoading = true
@ -9,12 +10,17 @@ struct RouteSelectionView: View {
var body: some View { var body: some View {
VStack { VStack {
if isLoading { if isLoading {
ProgressView("Загрузка маршрутов...") ProgressView(
.padding() appState.selectedLanguage == "ru" ? "Загрузка маршрутов..." :
appState.selectedLanguage == "zh" ? "正在加载路线..." :
"Loading routes..."
)
.padding()
} else { } else {
List(routes, id: \.id) { route in List(routes, id: \.id) { route in
Button(action: { Button(action: {
appState.selectedRoute = route appState.selectedRoute = route
dismiss()
}) { }) {
HStack { HStack {
Text("\(route.routeNumber)") Text("\(route.routeNumber)")
@ -26,15 +32,21 @@ struct RouteSelectionView: View {
.listStyle(PlainListStyle()) .listStyle(PlainListStyle())
} }
} }
.navigationTitle("Выберите маршрут") .navigationTitle(
appState.selectedLanguage == "ru" ? "Выберите маршрут" :
appState.selectedLanguage == "zh" ? "选择路线" :
"Select route"
)
.onAppear { .onAppear {
Task { Task {
await fetchRoutes() await fetchRoutes()
} }
} }
.onChange(of: appState.selectedLanguage) { _ in
// просто перерисовываем view, navigationTitle автоматически обновится
}
} }
// MARK: - Fetch Routes
private func fetchRoutes() async { private func fetchRoutes() async {
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }

View File

@ -5,7 +5,7 @@ struct RouteView: View {
@State private var firstStationName: String = "Загрузка..." @State private var firstStationName: String = "Загрузка..."
@State private var lastStationName: String = "Загрузка..." @State private var lastStationName: String = "Загрузка..."
@State private var engStationsName: String = "Загрузка..." @State private var stationsRangeName: String = "Загрузка..."
private var topBackgroundColor = Color(hex: 0xFCD500) private var topBackgroundColor = Color(hex: 0xFCD500)
@ -42,7 +42,7 @@ struct RouteView: View {
foregroundColor: .white foregroundColor: .white
) )
MarqueeText( MarqueeText(
text: engStationsName, text: stationsRangeName,
font: .caption, font: .caption,
foregroundColor: .white.opacity(0.5) foregroundColor: .white.opacity(0.5)
) )
@ -76,33 +76,72 @@ struct RouteView: View {
await fetchStations(forRoute: routeID) await fetchStations(forRoute: routeID)
} }
} }
.onChange(of: appState.selectedLanguage) { _ in
if let routeID = appState.selectedRoute?.id {
Task {
await fetchStations(forRoute: routeID)
}
}
}
} }
// MARK: - Fetch Stations // MARK: - Fetch Stations
private func fetchStations(forRoute routeID: Int) async { private func fetchStations(forRoute routeID: Int) async {
firstStationName = "Загрузка..." // текст загрузки
lastStationName = "Загрузка..." switch appState.selectedLanguage {
engStationsName = "Loading..." case "ru":
firstStationName = "Загрузка..."
guard let url = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station"), lastStationName = "Загрузка..."
let urlEng = URL(string: "https://white-nights.krbl.ru/services/content/route/\(routeID)/station?lang=en") else { return } stationsRangeName = "Loading..."
case "zh":
firstStationName = "正在加载..."
lastStationName = "正在加载..."
stationsRangeName = "正在加载..."
default:
firstStationName = "Loading..."
lastStationName = "Loading..."
stationsRangeName = "Loading..."
}
do { 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) let stationsEn = try JSONDecoder().decode([Station].self, from: dataEn)
if let firstStation = stations.first { firstStationName = firstStation.name } // Загружаем станции на языке интерфейса для отображения first/last
if let lastStation = stations.last { lastStationName = lastStation.name } 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..." // Устанавливаем first и last на текущем языке интерфейса
let lastStationEn = stationsEn.last?.name ?? "Loading..." if let first = stationsCurrent.first { firstStationName = first.name }
engStationsName = "\(firstStationEn) - \(lastStationEn)" 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 { } catch {
print("Ошибка загрузки станций: \(error)") 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"
} }
} }
} }

View File

@ -1,27 +1,28 @@
import SwiftUI import SwiftUI
import AVKit import AVKit
import NukeUI import NukeUI
import UIKit
// MARK: - SightView
struct SightView: View { struct SightView: View {
let sightId: Int let sightId: Int
@StateObject private var viewModel = SightViewModel() @StateObject private var viewModel = SightViewModel()
@EnvironmentObject private var appState: AppState
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
mediaSection mediaSection
VStack(alignment: .leading, spacing: 8) { 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) .font(.title2)
.fontWeight(.bold) .fontWeight(.bold)
.foregroundColor(.white) .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 { ScrollView {
if viewModel.selectedArticle?.isReviewArticle == true { if viewModel.selectedArticle?.isReviewArticle == true {
@ -38,16 +39,14 @@ struct SightView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
} }
// Список статей (кнопки навигации) - ФИНАЛЬНОЕ ИСПРАВЛЕНИЕ ЦЕНТРИРОВАНИЯ // Список статей
GeometryReader { geometry in GeometryReader { geometry in
ScrollView(.horizontal, showsIndicators: false) { ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) { HStack(spacing: 10) {
// Spacers для центрирования
Spacer(minLength: 0) Spacer(minLength: 0)
ForEach(viewModel.allArticles) { article in ForEach(viewModel.allArticles) { article in
Text(article.heading) Text(localizedHeading(article))
.font(.system(size: 12)) .font(.system(size: 12))
.lineLimit(1) .lineLimit(1)
.padding(.vertical, 6) .padding(.vertical, 6)
@ -64,28 +63,34 @@ struct SightView: View {
viewModel.selectArticle(article) viewModel.selectArticle(article)
} }
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
// Принудительно задаем ширину HStack как ширину GeometryReader
.frame(minWidth: geometry.size.width) .frame(minWidth: geometry.size.width)
} }
.scrollIndicators(.hidden) // Скрываем полосу прокрутки .scrollIndicators(.hidden)
} }
// Задаем высоту для GeometryReader
.frame(height: 34) .frame(height: 34)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.bottom, 10) .padding(.bottom, 4)
} }
// MARK: - Initial load
.task(id: sightId) { .task(id: sightId) {
viewModel.setLanguage(appState.selectedLanguage)
await viewModel.loadInitialData(sightId: sightId) 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) .blockStyle(cornerRadius: 25)
} }
// Медиа-секция // MARK: - Медиа
@ViewBuilder @ViewBuilder
private var mediaSection: some View { private var mediaSection: some View {
Group { Group {
@ -98,29 +103,29 @@ struct SightView: View {
.tint(.white) .tint(.white)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.aspectRatio(16/9, contentMode: .fit) .frame(height: 160)
.cornerRadius(24, corners: [.topLeft, .topRight]) .cornerRadius(24, corners: [.topLeft, .topRight])
.clipped() .clipped()
case .image(let url): case .image(let url):
LazyImage(url: url) { state in LazyImage(url: url) { state in
if let image = state.image { if let image = state.image {
image image.resizable().scaledToFit()
.resizable()
.scaledToFit()
} else { } else {
ProgressView() ZStack {
Color.gray.opacity(0.3)
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
}
} }
} }
.cornerRadius(24, corners: [.topLeft, .topRight]) .cornerRadius(24, corners: [.topLeft, .topRight])
.clipped() .clipped()
case .video(let player): case .video(let player):
VideoPlayer(player: player) VideoPlayer(player: player)
.aspectRatio(16/9, contentMode: .fit) .aspectRatio(16/9, contentMode: .fit)
.cornerRadius(24, corners: [.topLeft, .topRight]) .cornerRadius(24, corners: [.topLeft, .topRight])
.clipped() .clipped()
case .error: case .error:
Image(systemName: "photo") Image(systemName: "photo")
.resizable() .resizable()
@ -134,4 +139,18 @@ struct SightView: View {
.padding(4) .padding(4)
.frame(maxWidth: .infinity) .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
}
}
} }

View File

@ -1,7 +1,6 @@
import Foundation import Foundation
import AVKit import AVKit
import Combine import Combine
@MainActor @MainActor
class SightViewModel: ObservableObject { class SightViewModel: ObservableObject {
@Published var sightName: String = "Загрузка..." @Published var sightName: String = "Загрузка..."
@ -10,8 +9,9 @@ class SightViewModel: ObservableObject {
@Published var articleHeading: String = "" @Published var articleHeading: String = ""
@Published var articleBody: String = "" @Published var articleBody: String = ""
@Published var mediaState: MediaState = .loading @Published var mediaState: MediaState = .loading
private var sightModel: SightModel? private var sightModel: SightModel?
private var selectedLanguage: String = "ru" // по умолчанию
enum MediaState { enum MediaState {
case loading case loading
@ -20,10 +20,20 @@ class SightViewModel: ObservableObject {
case error case error
} }
func setLanguage(_ language: String) {
self.selectedLanguage = language
}
func loadInitialData(sightId: Int) async { func loadInitialData(sightId: Int) async {
do { do {
async let sightModelTask = fetchJSON(from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)", type: SightModel.self) async let sightModelTask = fetchJSON(
async let articlesTask = fetchJSON(from: "https://white-nights.krbl.ru/services/content/sight/\(sightId)/article", type: [Article].self) 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) let (fetchedSightModel, fetchedArticles) = try await (sightModelTask, articlesTask)
@ -63,20 +73,23 @@ class SightViewModel: ObservableObject {
} }
if let videoPreviewId = sight.video_preview, 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) let player = AVPlayer(url: url)
player.play() player.play()
self.mediaState = .video(player) 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) self.mediaState = .image(url)
} else { } else {
self.mediaState = .error self.mediaState = .error
} }
} else { } else {
do { 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, 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) self.mediaState = .image(url)
} else { } else {
self.mediaState = .error self.mediaState = .error

View File

@ -7,5 +7,7 @@ struct VisualEffectBlur: UIViewRepresentable {
UIVisualEffectView(effect: UIBlurEffect(style: blurStyle)) UIVisualEffectView(effect: UIBlurEffect(style: blurStyle))
} }
func updateUIView(_ uiView: UIVisualEffectView, context: Context) { } func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
uiView.effect = UIBlurEffect(style: blurStyle)
}
} }

View File

@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
private let WEATHER_STATUS_MAP: [String: String] = [ private let WEATHER_STATUS_MAP_RU: [String: String] = [
"Rain": "дождливо", "Rain": "дождливо",
"Clouds": "облачно", "Clouds": "облачно",
"Clear": "солнечно", "Clear": "солнечно",
@ -10,52 +10,81 @@ private let WEATHER_STATUS_MAP: [String: String] = [
"Fog": "туман" "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 { struct FormattedWeather {
let temperature: Int let temperature: Int
let status: String let statusCode: String
let precipitation: Int? let precipitation: Int?
let windSpeed: Double? 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 { struct WeatherView: View {
@EnvironmentObject var appState: AppState
@State private var todayWeather: FormattedWeather? @State private var todayWeather: FormattedWeather?
@State private var forecast: [FormattedWeather] = [] @State private var forecast: [FormattedWeather] = []
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 4) { HStack(alignment: .center, spacing: 4) {
if let today = todayWeather { if let today = todayWeather {
// ЛЕВЫЙ СТОЛБЕЦ: Иконка, температура и статус VStack(alignment: .center, spacing: 2) {
VStack(alignment: .leading, spacing: 2) { Image(getWeatherIconName(for: today.localizedStatus(language: appState.selectedLanguage)))
Image(getWeatherIconName(for: today.status))
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
.padding(.bottom, 5) .padding(.bottom, 5)
Text("\(today.temperature)°") Text("\(today.temperature)°")
.font(.system(size: 30, weight: .bold)) .font(.system(size: 30, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
Text(today.status) Text(today.localizedStatus(language: appState.selectedLanguage))
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundColor(.white) .foregroundColor(.white)
.multilineTextAlignment(.center)
} }
// ПРАВЫЙ СТОЛБЕЦ: Прогноз с иконками VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 10) {
// Прогноз на 3 дня
ForEach(forecast.prefix(3).indices, id: \.self) { index in ForEach(forecast.prefix(3).indices, id: \.self) { index in
let day = forecast[index] let day = forecast[index]
HStack(spacing: 5) { HStack(spacing: 4) {
Image(getWeatherIconName(for: day.status)) Image(getWeatherIconName(for: day.localizedStatus(language: appState.selectedLanguage)))
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14) .frame(width: 14, height: 14)
if let dayName = day.dayOfWeek { if let date = day.originalDate {
Text(dayName) Text(getDayOfWeek(from: date))
.font(.system(size: 12)) .font(.system(size: 10))
.foregroundColor(.white) .foregroundColor(.white)
} }
@ -66,9 +95,12 @@ struct WeatherView: View {
} }
} }
// Влажность и скорость ветра Divider()
.background(Color.white.opacity(0.8))
.padding(.vertical, 1)
if let precipitation = today.precipitation { if let precipitation = today.precipitation {
HStack(spacing: 5) { HStack(spacing: 4) {
Image("det_humidity") Image("det_humidity")
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
@ -78,20 +110,25 @@ struct WeatherView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
} }
if let windSpeed = today.windSpeed { if let windSpeed = today.windSpeed {
HStack(spacing: 5) { HStack(spacing: 5) {
Image("det_wind_speed") Image("det_wind_speed")
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14) .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)) .font(.system(size: 12))
.foregroundColor(.white) .foregroundColor(.white)
} }
} }
} }
} else { } else {
Text("Загрузка погоды...") Text(appState.selectedLanguage == "ru" ? "Загрузка погоды..." :
appState.selectedLanguage == "zh" ? "正在加载天气..." :
"Loading weather...")
.foregroundColor(.white) .foregroundColor(.white)
.padding() .padding()
} }
@ -117,13 +154,16 @@ struct WeatherView: View {
.task { .task {
await fetchAndFormatWeather() await fetchAndFormatWeather()
} }
.onChange(of: appState.selectedLanguage) { _ in
// при смене языка день недели пересчитывается динамически в View
}
} }
private func fetchAndFormatWeather() async { private func fetchAndFormatWeather() async {
let lat = 59.938784 let lat = 59.938784
let lng = 30.314997 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) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
@ -136,14 +176,8 @@ struct WeatherView: View {
do { do {
request.httpBody = try JSONSerialization.data(withJSONObject: parameters) request.httpBody = try JSONSerialization.data(withJSONObject: parameters)
let (data, _) = try await URLSession.shared.data(for: request) 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) let weatherResponse = try JSONDecoder().decode(WeatherResponse.self, from: data)
formatWeatherData(data: weatherResponse) formatWeatherData(data: weatherResponse)
} catch { } catch {
print("Ошибка загрузки погоды: \(error)") print("Ошибка загрузки погоды: \(error)")
} }
@ -152,103 +186,85 @@ struct WeatherView: View {
private func formatWeatherData(data: WeatherResponse) { private func formatWeatherData(data: WeatherResponse) {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // UTC dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Добавление POSIX-локали для надежного парсинга dateFormatter.locale = Locale(identifier: "en_US_POSIX")
var calendar = Calendar.current 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 let middayForecast = data.forecast.filter { item in
guard let date = dateFormatter.date(from: item.date) else { guard let date = dateFormatter.date(from: item.date) else { return false }
// 💡 Отладка: если здесь вы увидите сообщения, значит, формат даты API не совпадает с форматом dateFormatter
// print("Парсинг даты провален для: \(item.date)")
return false
}
return calendar.component(.hour, from: date) == 12 return calendar.component(.hour, from: date) == 12
} }
// 💡 Отладка: Проверяем, сколько записей найдено
// print("Найдено записей на 12:00 UTC: \(middayForecast.count)")
// Сегодня
if let today = data.currentWeather { if let today = data.currentWeather {
self.todayWeather = FormattedWeather( todayWeather = FormattedWeather(
temperature: Int(today.temperatureCelsius.rounded()), temperature: Int(today.temperatureCelsius.rounded()),
status: WEATHER_STATUS_MAP[today.description] ?? today.description, statusCode: today.description,
precipitation: today.humidity, precipitation: today.humidity,
windSpeed: today.windSpeed, windSpeed: today.windSpeed,
dayOfWeek: nil dayOfWeek: nil,
originalDate: nil
) )
} }
// 🚀 ИСПРАВЛЕНИЕ: Прогноз на 3 дня. Берем первые 3 доступные записи с 12:00.
var formattedForecast: [FormattedWeather] = [] var formattedForecast: [FormattedWeather] = []
// Перебираем первые 3 записи с 12:00 UTC
for item in middayForecast.prefix(3) { for item in middayForecast.prefix(3) {
guard let date = dateFormatter.date(from: item.date) else { continue } guard let date = dateFormatter.date(from: item.date) else { continue }
let averageTemp = (item.minTemperatureCelsius + item.maxTemperatureCelsius) / 2 let averageTemp = (item.minTemperatureCelsius + item.maxTemperatureCelsius) / 2
let dayOfWeekString = getDayOfWeek(from: date)
// 💡 Отладка: Печатаем найденный прогноз
// print("Прогноз найден: \(dayOfWeekString) - \(Int(averageTemp.rounded()))°")
formattedForecast.append(FormattedWeather( formattedForecast.append(FormattedWeather(
temperature: Int(averageTemp.rounded()), temperature: Int(averageTemp.rounded()),
status: WEATHER_STATUS_MAP[item.description] ?? item.description, statusCode: item.description,
precipitation: item.humidity, precipitation: item.humidity,
windSpeed: item.windSpeed, windSpeed: item.windSpeed,
dayOfWeek: dayOfWeekString dayOfWeek: getDayOfWeek(from: date),
originalDate: date
)) ))
} }
// Если данных меньше 3, заполняем оставшиеся места нулями (N/A)
let daysToFill = 3 - formattedForecast.count let daysToFill = 3 - formattedForecast.count
if daysToFill > 0 { if daysToFill > 0 {
let baseDate = Date() let baseDate = Date()
for i in 1...daysToFill { for i in 1...daysToFill {
guard let nextDayForNA = Calendar.current.date(byAdding: .day, value: formattedForecast.count + i, to: baseDate) else { continue } guard let nextDay = Calendar.current.date(byAdding: .day, value: formattedForecast.count + i, to: baseDate) else { continue }
let dayOfWeekString = getDayOfWeek(from: nextDayForNA)
formattedForecast.append(FormattedWeather( formattedForecast.append(FormattedWeather(
temperature: 0, temperature: 0,
status: "N/A", statusCode: "N/A",
precipitation: nil, precipitation: nil,
windSpeed: nil, windSpeed: nil,
dayOfWeek: dayOfWeekString dayOfWeek: getDayOfWeek(from: nextDay),
originalDate: nextDay
)) ))
} }
} }
self.forecast = formattedForecast self.forecast = formattedForecast
} }
private func getDayOfWeek(from date: Date) -> String { private func getDayOfWeek(from date: Date) -> String {
let formatter = DateFormatter() 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" formatter.dateFormat = "E"
return formatter.string(from: date).capitalized return formatter.string(from: date).capitalized
} }
private func getWeatherIconName(for status: String) -> String { private func getWeatherIconName(for status: String) -> String {
let normalizedStatus = status.lowercased() let normalizedStatus = status.lowercased()
switch normalizedStatus { switch normalizedStatus {
case "солнечно": case "солнечно", "晴朗", "sunny":
return "cond_sunny" return "cond_sunny"
case "облачно": case "облачно", "多云", "cloudy":
return "cond_cloudy" return "cond_cloudy"
case "дождливо", "мелкий дождь": case "дождливо", "мелкий дождь", "下雨", "毛毛雨", "rainy", "drizzle":
return "cond_rainy" return "cond_rainy"
case "снег": case "снег", "下雪", "snowy":
return "cond_snowy" return "cond_snowy"
case "гроза": case "гроза", "雷雨", "thunderstorm":
return "cond_thunder" return "cond_thunder"
case "туман": case "туман", "", "fog":
return "det_humidity" return "det_humidity"
default: default:
return "cond_sunny" return "cond_sunny"