From 5a583d9415fb30afcfef5a4b9f5115e0ceefbbf7 Mon Sep 17 00:00:00 2001 From: "15lu.akari" <92751432+mikaeloganesian@users.noreply.github.com> Date: Sun, 24 Aug 2025 14:44:50 +0300 Subject: [PATCH] Initial commit --- WhiteNights.xcodeproj/project.pbxproj | 210 +++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 68 ++++ .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 + WhiteNights/AppState.swift | 72 +++++ .../GAT_Icon.imageset/Contents.json | 21 ++ .../GAT_Icon.imageset/GAT_Icon.svg | 7 + .../cond_cloudy.imageset/Contents.json | 21 ++ .../cond_cloudy.imageset/cond_cloudy.svg | 3 + .../cond_partycloud.imageset/Contents.json | 21 ++ .../cond_partlycloudy.svg | 4 + .../cond_rainy.imageset/Contents.json | 21 ++ .../cond_rainy.imageset/cond_rainy.svg | 7 + .../cond_snow.imageset/Contents.json | 21 ++ .../cond_snow.imageset/cond_snow.svg | 11 + .../cond_snowy.imageset/Contents.json | 21 ++ .../cond_snowy.imageset/cond_snowy.svg | 11 + .../cond_sunny.imageset/Contents.json | 21 ++ .../cond_sunny.imageset/cond_sunny.svg | 18 ++ .../cond_thunder.imageset/Contents.json | 21 ++ .../cond_thunder.imageset/cond_thunder.svg | 4 + .../det_humidity.imageset/Contents.json | 21 ++ .../det_humidity.imageset/det_humidity.svg | 3 + .../det_wind_speed.imageset/Contents.json | 21 ++ .../det_wind_speed.imageset/det_wind.svg | 5 + .../en_lang_icon.imageset/Contents.json | 21 ++ .../en_lang_icon.imageset/en_lang_icon.svg | 3 + .../ru_lang_icon.imageset/Contents.json | 21 ++ .../ru_lang_icon.imageset/ru_lang_icon.svg | 4 + .../zh_lang_icon.imageset/Contents.json | 21 ++ .../zh_lang_icon.imageset/zh_lang_icon.svg | 6 + WhiteNights/ContentView.swift | 94 +++++- WhiteNights/Models/Article.swift | 17 + WhiteNights/Models/ArticleMedia.swift | 8 + WhiteNights/Models/Route.swift | 11 + WhiteNights/Models/SightModel.swift | 33 ++ WhiteNights/Models/Station.swift | 6 + WhiteNights/Models/StopModels.swift | 27 ++ WhiteNights/Models/WeaherModel.swift | 22 ++ WhiteNights/Utils/Color+Extensions.swift | 13 + WhiteNights/Utils/MarqueeText.swift | 71 +++++ WhiteNights/Utils/View+CornerRadius.swift | 17 + WhiteNights/Utils/View+Extensions.swift | 39 +++ WhiteNights/WhiteNightsApp.swift | 3 + WhiteNights/Widgets/BottomMenu.swift | 297 ++++++++++++++++++ WhiteNights/Widgets/RouteSelectionView.swift | 52 +++ WhiteNights/Widgets/RouteView.swift | 108 +++++++ WhiteNights/Widgets/SightView.swift | 137 ++++++++ WhiteNights/Widgets/SightViewModel.swift | 98 ++++++ WhiteNights/Widgets/VisualEffectBlur.swift | 11 + WhiteNights/Widgets/WeatherView.swift | 257 +++++++++++++++ 50 files changed, 2019 insertions(+), 17 deletions(-) create mode 100644 WhiteNights.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 WhiteNights.xcodeproj/xcuserdata/mikaeloganesan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist create mode 100644 WhiteNights/AppState.swift create mode 100644 WhiteNights/Assets.xcassets/GAT_Icon.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/GAT_Icon.imageset/GAT_Icon.svg create mode 100644 WhiteNights/Assets.xcassets/cond_cloudy.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_cloudy.imageset/cond_cloudy.svg create mode 100644 WhiteNights/Assets.xcassets/cond_partycloud.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_partycloud.imageset/cond_partlycloudy.svg create mode 100644 WhiteNights/Assets.xcassets/cond_rainy.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_rainy.imageset/cond_rainy.svg create mode 100644 WhiteNights/Assets.xcassets/cond_snow.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_snow.imageset/cond_snow.svg create mode 100644 WhiteNights/Assets.xcassets/cond_snowy.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_snowy.imageset/cond_snowy.svg create mode 100644 WhiteNights/Assets.xcassets/cond_sunny.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_sunny.imageset/cond_sunny.svg create mode 100644 WhiteNights/Assets.xcassets/cond_thunder.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/cond_thunder.imageset/cond_thunder.svg create mode 100644 WhiteNights/Assets.xcassets/det_humidity.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/det_humidity.imageset/det_humidity.svg create mode 100644 WhiteNights/Assets.xcassets/det_wind_speed.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/det_wind_speed.imageset/det_wind.svg create mode 100644 WhiteNights/Assets.xcassets/en_lang_icon.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/en_lang_icon.imageset/en_lang_icon.svg create mode 100644 WhiteNights/Assets.xcassets/ru_lang_icon.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/ru_lang_icon.imageset/ru_lang_icon.svg create mode 100644 WhiteNights/Assets.xcassets/zh_lang_icon.imageset/Contents.json create mode 100644 WhiteNights/Assets.xcassets/zh_lang_icon.imageset/zh_lang_icon.svg create mode 100644 WhiteNights/Models/Article.swift create mode 100644 WhiteNights/Models/ArticleMedia.swift create mode 100644 WhiteNights/Models/Route.swift create mode 100644 WhiteNights/Models/SightModel.swift create mode 100644 WhiteNights/Models/Station.swift create mode 100644 WhiteNights/Models/StopModels.swift create mode 100644 WhiteNights/Models/WeaherModel.swift create mode 100644 WhiteNights/Utils/Color+Extensions.swift create mode 100644 WhiteNights/Utils/MarqueeText.swift create mode 100644 WhiteNights/Utils/View+CornerRadius.swift create mode 100644 WhiteNights/Utils/View+Extensions.swift create mode 100644 WhiteNights/Widgets/BottomMenu.swift create mode 100644 WhiteNights/Widgets/RouteSelectionView.swift create mode 100644 WhiteNights/Widgets/RouteView.swift create mode 100644 WhiteNights/Widgets/SightView.swift create mode 100644 WhiteNights/Widgets/SightViewModel.swift create mode 100644 WhiteNights/Widgets/VisualEffectBlur.swift create mode 100644 WhiteNights/Widgets/WeatherView.swift diff --git a/WhiteNights.xcodeproj/project.pbxproj b/WhiteNights.xcodeproj/project.pbxproj index 35fb3d2..6463679 100644 --- a/WhiteNights.xcodeproj/project.pbxproj +++ b/WhiteNights.xcodeproj/project.pbxproj @@ -14,6 +14,33 @@ 628A47742E5A91DA0099CAA0 /* WhiteNightsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47732E5A91DA0099CAA0 /* WhiteNightsTests.swift */; }; 628A477E2E5A91DA0099CAA0 /* WhiteNightsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A477D2E5A91DA0099CAA0 /* WhiteNightsUITests.swift */; }; 628A47802E5A91DA0099CAA0 /* WhiteNightsUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A477F2E5A91DA0099CAA0 /* WhiteNightsUITestsLaunchTests.swift */; }; + 628A478D2E5A922A0099CAA0 /* WeatherView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A478C2E5A922A0099CAA0 /* WeatherView.swift */; }; + 628A47902E5A92560099CAA0 /* RouteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A478F2E5A92560099CAA0 /* RouteView.swift */; }; + 628A47922E5A926A0099CAA0 /* SightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47912E5A926A0099CAA0 /* SightView.swift */; }; + 628A47962E5A92CF0099CAA0 /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47952E5A92CF0099CAA0 /* Color+Extensions.swift */; }; + 628A47982E5A92FC0099CAA0 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47972E5A92FC0099CAA0 /* View+Extensions.swift */; }; + 628A479B2E5A935E0099CAA0 /* WeaherModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A479A2E5A935E0099CAA0 /* WeaherModel.swift */; }; + 628A479D2E5A94D30099CAA0 /* SightModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A479C2E5A94D30099CAA0 /* SightModel.swift */; }; + 628A479F2E5A94EB0099CAA0 /* Article.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A479E2E5A94EB0099CAA0 /* Article.swift */; }; + 628A47A12E5A94FA0099CAA0 /* ArticleMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47A02E5A94FA0099CAA0 /* ArticleMedia.swift */; }; + 628A47A32E5A951B0099CAA0 /* SightViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47A22E5A951B0099CAA0 /* SightViewModel.swift */; }; + 628A47A52E5A957A0099CAA0 /* MarqueeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47A42E5A957A0099CAA0 /* MarqueeText.swift */; }; + 628A47A72E5A95F60099CAA0 /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47A62E5A95F60099CAA0 /* Station.swift */; }; + 628A47A92E5A961D0099CAA0 /* View+CornerRadius.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47A82E5A961D0099CAA0 /* View+CornerRadius.swift */; }; + 628A47AB2E5A96480099CAA0 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47AA2E5A96480099CAA0 /* AppState.swift */; }; + 628A47AD2E5A965D0099CAA0 /* BottomMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47AC2E5A965D0099CAA0 /* BottomMenu.swift */; }; + 628A47B02E5A96AC0099CAA0 /* SDWebImageSVGCoder in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47AF2E5A96AC0099CAA0 /* SDWebImageSVGCoder */; }; + 628A47B32E5A96B80099CAA0 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47B22E5A96B80099CAA0 /* SDWebImageSwiftUI */; }; + 628A47B62E5A96D00099CAA0 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47B52E5A96D00099CAA0 /* SVGKit */; }; + 628A47B82E5A96D00099CAA0 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47B72E5A96D00099CAA0 /* SVGKitSwift */; }; + 628A47BB2E5A96E80099CAA0 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47BA2E5A96E80099CAA0 /* Nuke */; }; + 628A47BD2E5A96E80099CAA0 /* NukeExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47BC2E5A96E80099CAA0 /* NukeExtensions */; }; + 628A47BF2E5A96E80099CAA0 /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47BE2E5A96E80099CAA0 /* NukeUI */; }; + 628A47C12E5A96E80099CAA0 /* NukeVideo in Frameworks */ = {isa = PBXBuildFile; productRef = 628A47C02E5A96E80099CAA0 /* NukeVideo */; }; + 628A47C32E5A98980099CAA0 /* Route.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C22E5A98980099CAA0 /* Route.swift */; }; + 628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */; }; + 628A47C92E5A9A970099CAA0 /* StopModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47C82E5A9A970099CAA0 /* StopModels.swift */; }; + 628A47CB2E5A9BD60099CAA0 /* RouteSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,6 +72,25 @@ 628A47792E5A91DA0099CAA0 /* WhiteNightsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WhiteNightsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 628A477D2E5A91DA0099CAA0 /* WhiteNightsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhiteNightsUITests.swift; sourceTree = ""; }; 628A477F2E5A91DA0099CAA0 /* WhiteNightsUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhiteNightsUITestsLaunchTests.swift; sourceTree = ""; }; + 628A478C2E5A922A0099CAA0 /* WeatherView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherView.swift; sourceTree = ""; }; + 628A478F2E5A92560099CAA0 /* RouteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteView.swift; sourceTree = ""; }; + 628A47912E5A926A0099CAA0 /* SightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SightView.swift; sourceTree = ""; }; + 628A47952E5A92CF0099CAA0 /* Color+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Extensions.swift"; sourceTree = ""; }; + 628A47972E5A92FC0099CAA0 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + 628A479A2E5A935E0099CAA0 /* WeaherModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeaherModel.swift; sourceTree = ""; }; + 628A479C2E5A94D30099CAA0 /* SightModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SightModel.swift; sourceTree = ""; }; + 628A479E2E5A94EB0099CAA0 /* Article.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Article.swift; sourceTree = ""; }; + 628A47A02E5A94FA0099CAA0 /* ArticleMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleMedia.swift; sourceTree = ""; }; + 628A47A22E5A951B0099CAA0 /* SightViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SightViewModel.swift; sourceTree = ""; }; + 628A47A42E5A957A0099CAA0 /* MarqueeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarqueeText.swift; sourceTree = ""; }; + 628A47A62E5A95F60099CAA0 /* Station.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Station.swift; sourceTree = ""; }; + 628A47A82E5A961D0099CAA0 /* View+CornerRadius.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+CornerRadius.swift"; sourceTree = ""; }; + 628A47AA2E5A96480099CAA0 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + 628A47AC2E5A965D0099CAA0 /* BottomMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomMenu.swift; sourceTree = ""; }; + 628A47C22E5A98980099CAA0 /* Route.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Route.swift; sourceTree = ""; }; + 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectBlur.swift; sourceTree = ""; }; + 628A47C82E5A9A970099CAA0 /* StopModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopModels.swift; sourceTree = ""; }; + 628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RouteSelectionView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -52,6 +98,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 628A47B82E5A96D00099CAA0 /* SVGKitSwift in Frameworks */, + 628A47BF2E5A96E80099CAA0 /* NukeUI in Frameworks */, + 628A47B62E5A96D00099CAA0 /* SVGKit in Frameworks */, + 628A47C12E5A96E80099CAA0 /* NukeVideo in Frameworks */, + 628A47B32E5A96B80099CAA0 /* SDWebImageSwiftUI in Frameworks */, + 628A47B02E5A96AC0099CAA0 /* SDWebImageSVGCoder in Frameworks */, + 628A47BB2E5A96E80099CAA0 /* Nuke in Frameworks */, + 628A47BD2E5A96E80099CAA0 /* NukeExtensions in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,11 +149,15 @@ 628A47602E5A91D80099CAA0 /* WhiteNights */ = { isa = PBXGroup; children = ( + 628A47992E5A934A0099CAA0 /* Models */, + 628A47942E5A92BE0099CAA0 /* Utils */, + 628A478E2E5A923E0099CAA0 /* Widgets */, 628A47612E5A91D80099CAA0 /* WhiteNightsApp.swift */, 628A47632E5A91D80099CAA0 /* ContentView.swift */, 628A47652E5A91DA0099CAA0 /* Assets.xcassets */, 628A47672E5A91DA0099CAA0 /* WhiteNights.entitlements */, 628A47682E5A91DA0099CAA0 /* Preview Content */, + 628A47AA2E5A96480099CAA0 /* AppState.swift */, ); path = WhiteNights; sourceTree = ""; @@ -129,6 +187,45 @@ path = WhiteNightsUITests; sourceTree = ""; }; + 628A478E2E5A923E0099CAA0 /* Widgets */ = { + isa = PBXGroup; + children = ( + 628A478C2E5A922A0099CAA0 /* WeatherView.swift */, + 628A478F2E5A92560099CAA0 /* RouteView.swift */, + 628A47912E5A926A0099CAA0 /* SightView.swift */, + 628A47A22E5A951B0099CAA0 /* SightViewModel.swift */, + 628A47AC2E5A965D0099CAA0 /* BottomMenu.swift */, + 628A47C62E5A99A20099CAA0 /* VisualEffectBlur.swift */, + 628A47CA2E5A9BD60099CAA0 /* RouteSelectionView.swift */, + ); + path = Widgets; + sourceTree = ""; + }; + 628A47942E5A92BE0099CAA0 /* Utils */ = { + isa = PBXGroup; + children = ( + 628A47952E5A92CF0099CAA0 /* Color+Extensions.swift */, + 628A47972E5A92FC0099CAA0 /* View+Extensions.swift */, + 628A47A42E5A957A0099CAA0 /* MarqueeText.swift */, + 628A47A82E5A961D0099CAA0 /* View+CornerRadius.swift */, + ); + path = Utils; + sourceTree = ""; + }; + 628A47992E5A934A0099CAA0 /* Models */ = { + isa = PBXGroup; + children = ( + 628A479A2E5A935E0099CAA0 /* WeaherModel.swift */, + 628A479C2E5A94D30099CAA0 /* SightModel.swift */, + 628A479E2E5A94EB0099CAA0 /* Article.swift */, + 628A47A02E5A94FA0099CAA0 /* ArticleMedia.swift */, + 628A47A62E5A95F60099CAA0 /* Station.swift */, + 628A47C22E5A98980099CAA0 /* Route.swift */, + 628A47C82E5A9A970099CAA0 /* StopModels.swift */, + ); + path = Models; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -145,6 +242,16 @@ dependencies = ( ); name = WhiteNights; + packageProductDependencies = ( + 628A47AF2E5A96AC0099CAA0 /* SDWebImageSVGCoder */, + 628A47B22E5A96B80099CAA0 /* SDWebImageSwiftUI */, + 628A47B52E5A96D00099CAA0 /* SVGKit */, + 628A47B72E5A96D00099CAA0 /* SVGKitSwift */, + 628A47BA2E5A96E80099CAA0 /* Nuke */, + 628A47BC2E5A96E80099CAA0 /* NukeExtensions */, + 628A47BE2E5A96E80099CAA0 /* NukeUI */, + 628A47C02E5A96E80099CAA0 /* NukeVideo */, + ); productName = WhiteNights; productReference = 628A475E2E5A91D80099CAA0 /* WhiteNights.app */; productType = "com.apple.product-type.application"; @@ -217,6 +324,12 @@ Base, ); mainGroup = 628A47552E5A91D80099CAA0; + packageReferences = ( + 628A47AE2E5A96AC0099CAA0 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */, + 628A47B12E5A96B80099CAA0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + 628A47B42E5A96D00099CAA0 /* XCRemoteSwiftPackageReference "SVGKit" */, + 628A47B92E5A96E80099CAA0 /* XCRemoteSwiftPackageReference "Nuke" */, + ); productRefGroup = 628A475F2E5A91D80099CAA0 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -259,8 +372,27 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 628A47902E5A92560099CAA0 /* RouteView.swift in Sources */, + 628A479B2E5A935E0099CAA0 /* WeaherModel.swift in Sources */, + 628A479D2E5A94D30099CAA0 /* SightModel.swift in Sources */, + 628A478D2E5A922A0099CAA0 /* WeatherView.swift in Sources */, + 628A47C72E5A99A20099CAA0 /* VisualEffectBlur.swift in Sources */, + 628A479F2E5A94EB0099CAA0 /* Article.swift in Sources */, + 628A47922E5A926A0099CAA0 /* SightView.swift in Sources */, + 628A47A72E5A95F60099CAA0 /* Station.swift in Sources */, + 628A47A32E5A951B0099CAA0 /* SightViewModel.swift in Sources */, + 628A47C92E5A9A970099CAA0 /* StopModels.swift in Sources */, + 628A47982E5A92FC0099CAA0 /* View+Extensions.swift in Sources */, + 628A47C32E5A98980099CAA0 /* Route.swift in Sources */, + 628A47A92E5A961D0099CAA0 /* View+CornerRadius.swift in Sources */, + 628A47CB2E5A9BD60099CAA0 /* RouteSelectionView.swift in Sources */, 628A47642E5A91D80099CAA0 /* ContentView.swift in Sources */, + 628A47AB2E5A96480099CAA0 /* AppState.swift in Sources */, 628A47622E5A91D80099CAA0 /* WhiteNightsApp.swift in Sources */, + 628A47AD2E5A965D0099CAA0 /* BottomMenu.swift in Sources */, + 628A47962E5A92CF0099CAA0 /* Color+Extensions.swift in Sources */, + 628A47A12E5A94FA0099CAA0 /* ArticleMedia.swift in Sources */, + 628A47A52E5A957A0099CAA0 /* MarqueeText.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -617,6 +749,84 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 628A47AE2E5A96AC0099CAA0 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSVGCoder.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.8.0; + }; + }; + 628A47B12E5A96B80099CAA0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.1.3; + }; + }; + 628A47B42E5A96D00099CAA0 /* XCRemoteSwiftPackageReference "SVGKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SVGKit/SVGKit.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; + 628A47B92E5A96E80099CAA0 /* XCRemoteSwiftPackageReference "Nuke" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kean/Nuke.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 12.8.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 628A47AF2E5A96AC0099CAA0 /* SDWebImageSVGCoder */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47AE2E5A96AC0099CAA0 /* XCRemoteSwiftPackageReference "SDWebImageSVGCoder" */; + productName = SDWebImageSVGCoder; + }; + 628A47B22E5A96B80099CAA0 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B12E5A96B80099CAA0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; + 628A47B52E5A96D00099CAA0 /* SVGKit */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B42E5A96D00099CAA0 /* XCRemoteSwiftPackageReference "SVGKit" */; + productName = SVGKit; + }; + 628A47B72E5A96D00099CAA0 /* SVGKitSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B42E5A96D00099CAA0 /* XCRemoteSwiftPackageReference "SVGKit" */; + productName = SVGKitSwift; + }; + 628A47BA2E5A96E80099CAA0 /* Nuke */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B92E5A96E80099CAA0 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = Nuke; + }; + 628A47BC2E5A96E80099CAA0 /* NukeExtensions */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B92E5A96E80099CAA0 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = NukeExtensions; + }; + 628A47BE2E5A96E80099CAA0 /* NukeUI */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B92E5A96E80099CAA0 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = NukeUI; + }; + 628A47C02E5A96E80099CAA0 /* NukeVideo */ = { + isa = XCSwiftPackageProductDependency; + package = 628A47B92E5A96E80099CAA0 /* XCRemoteSwiftPackageReference "Nuke" */; + productName = NukeVideo; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 628A47562E5A91D80099CAA0 /* Project object */; } diff --git a/WhiteNights.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WhiteNights.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..985847e --- /dev/null +++ b/WhiteNights.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,68 @@ +{ + "pins" : [ + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "4b8714a7fb84d42393314ce897127b3939885ec3", + "version" : "3.8.5" + } + }, + { + "identity" : "nuke", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kean/Nuke.git", + "state" : { + "revision" : "0ead44350d2737db384908569c012fe67c421e4d", + "version" : "12.8.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "b62cb63bf4ed1f04c961a56c9c6c9d5ab8524ec6", + "version" : "5.21.1" + } + }, + { + "identity" : "sdwebimagesvgcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSVGCoder.git", + "state" : { + "revision" : "85b5d58ad02c207c496fa34426dc6560d6ae32f0", + "version" : "1.8.0" + } + }, + { + "identity" : "sdwebimageswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImageSwiftUI.git", + "state" : { + "revision" : "451c6dfd5ecec2cf626d1d9ca81c2d4a60355172", + "version" : "3.1.3" + } + }, + { + "identity" : "svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SVGKit/SVGKit.git", + "state" : { + "revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666", + "version" : "3.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", + "version" : "1.6.4" + } + } + ], + "version" : 2 +} diff --git a/WhiteNights.xcodeproj/xcuserdata/mikaeloganesan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/WhiteNights.xcodeproj/xcuserdata/mikaeloganesan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..7bd33cd --- /dev/null +++ b/WhiteNights.xcodeproj/xcuserdata/mikaeloganesan.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/WhiteNights/AppState.swift b/WhiteNights/AppState.swift new file mode 100644 index 0000000..06b4461 --- /dev/null +++ b/WhiteNights/AppState.swift @@ -0,0 +1,72 @@ +import Foundation +import SwiftUI + +@MainActor +class AppState: ObservableObject { + @Published var selectedRoute: Route? = nil { + didSet { + if let routeId = selectedRoute?.id { + Task { + await fetchSights(for: routeId) + } + } else { + self.sights = [] + self.sightId = nil + } + } + } + + @Published var allRoutes: [Route] = [] + @Published var sightId: Int? = nil + @Published var sights: [SightModel] = [] // <- все достопримечательности маршрута + + // MARK: - Fetch Sights + private func fetchSights(for routeId: Int) async { + let urlString = "https://white-nights.krbl.ru/services/content/route/\(routeId)/sight" + guard let url = URL(string: urlString) else { return } + + do { + let (data, _) = try await URLSession.shared.data(from: url) + let decodedSights = try JSONDecoder().decode([SightModel].self, from: data) + + DispatchQueue.main.async { + self.sights = decodedSights + self.sightId = decodedSights.first?.id + } + + // 🔹 Предзагрузка миниатюр + preloadThumbnails(for: decodedSights) + + } catch { + print("Ошибка загрузки достопримечательностей: \(error)") + DispatchQueue.main.async { + self.sights = [] + self.sightId = nil + } + } + } + + // MARK: - Preload thumbnails into URLCache + 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 } + let request = URLRequest(url: url) + + // Если уже в кэше, пропускаем + if URLCache.shared.cachedResponse(for: request) != nil { continue } + + let task = session.dataTask(with: request) { data, response, _ in + guard let data = data, let response = response else { return } + let cachedResponse = CachedURLResponse(response: response, data: data) + URLCache.shared.storeCachedResponse(cachedResponse, for: request) + } + task.resume() + } + } + + // Optional computed property + var routeId: Int? { + selectedRoute?.id + } +} diff --git a/WhiteNights/Assets.xcassets/GAT_Icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/GAT_Icon.imageset/Contents.json new file mode 100644 index 0000000..96f2c39 --- /dev/null +++ b/WhiteNights/Assets.xcassets/GAT_Icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "GAT_Icon.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/GAT_Icon.imageset/GAT_Icon.svg b/WhiteNights/Assets.xcassets/GAT_Icon.imageset/GAT_Icon.svg new file mode 100644 index 0000000..4ff1bfb --- /dev/null +++ b/WhiteNights/Assets.xcassets/GAT_Icon.imageset/GAT_Icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/WhiteNights/Assets.xcassets/cond_cloudy.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_cloudy.imageset/Contents.json new file mode 100644 index 0000000..eba3f1e --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_cloudy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_cloudy.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_cloudy.imageset/cond_cloudy.svg b/WhiteNights/Assets.xcassets/cond_cloudy.imageset/cond_cloudy.svg new file mode 100644 index 0000000..f44242f --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_cloudy.imageset/cond_cloudy.svg @@ -0,0 +1,3 @@ + + + diff --git a/WhiteNights/Assets.xcassets/cond_partycloud.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_partycloud.imageset/Contents.json new file mode 100644 index 0000000..55b5569 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_partycloud.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_partlycloudy.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_partycloud.imageset/cond_partlycloudy.svg b/WhiteNights/Assets.xcassets/cond_partycloud.imageset/cond_partlycloudy.svg new file mode 100644 index 0000000..ac3626d --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_partycloud.imageset/cond_partlycloudy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/cond_rainy.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_rainy.imageset/Contents.json new file mode 100644 index 0000000..568927c --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_rainy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_rainy.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_rainy.imageset/cond_rainy.svg b/WhiteNights/Assets.xcassets/cond_rainy.imageset/cond_rainy.svg new file mode 100644 index 0000000..41b4a56 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_rainy.imageset/cond_rainy.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/WhiteNights/Assets.xcassets/cond_snow.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_snow.imageset/Contents.json new file mode 100644 index 0000000..34d9d0a --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_snow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_snow.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_snow.imageset/cond_snow.svg b/WhiteNights/Assets.xcassets/cond_snow.imageset/cond_snow.svg new file mode 100644 index 0000000..824cb22 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_snow.imageset/cond_snow.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/WhiteNights/Assets.xcassets/cond_snowy.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_snowy.imageset/Contents.json new file mode 100644 index 0000000..4bc23ad --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_snowy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_snowy.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_snowy.imageset/cond_snowy.svg b/WhiteNights/Assets.xcassets/cond_snowy.imageset/cond_snowy.svg new file mode 100644 index 0000000..e3ab988 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_snowy.imageset/cond_snowy.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/WhiteNights/Assets.xcassets/cond_sunny.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_sunny.imageset/Contents.json new file mode 100644 index 0000000..63f5180 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_sunny.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_sunny.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_sunny.imageset/cond_sunny.svg b/WhiteNights/Assets.xcassets/cond_sunny.imageset/cond_sunny.svg new file mode 100644 index 0000000..fde5957 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_sunny.imageset/cond_sunny.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/WhiteNights/Assets.xcassets/cond_thunder.imageset/Contents.json b/WhiteNights/Assets.xcassets/cond_thunder.imageset/Contents.json new file mode 100644 index 0000000..0aea9a2 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_thunder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "cond_thunder.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/cond_thunder.imageset/cond_thunder.svg b/WhiteNights/Assets.xcassets/cond_thunder.imageset/cond_thunder.svg new file mode 100644 index 0000000..3158680 --- /dev/null +++ b/WhiteNights/Assets.xcassets/cond_thunder.imageset/cond_thunder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/det_humidity.imageset/Contents.json b/WhiteNights/Assets.xcassets/det_humidity.imageset/Contents.json new file mode 100644 index 0000000..4729925 --- /dev/null +++ b/WhiteNights/Assets.xcassets/det_humidity.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "det_humidity.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/det_humidity.imageset/det_humidity.svg b/WhiteNights/Assets.xcassets/det_humidity.imageset/det_humidity.svg new file mode 100644 index 0000000..0227bad --- /dev/null +++ b/WhiteNights/Assets.xcassets/det_humidity.imageset/det_humidity.svg @@ -0,0 +1,3 @@ + + + diff --git a/WhiteNights/Assets.xcassets/det_wind_speed.imageset/Contents.json b/WhiteNights/Assets.xcassets/det_wind_speed.imageset/Contents.json new file mode 100644 index 0000000..35f1792 --- /dev/null +++ b/WhiteNights/Assets.xcassets/det_wind_speed.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "det_wind.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/det_wind_speed.imageset/det_wind.svg b/WhiteNights/Assets.xcassets/det_wind_speed.imageset/det_wind.svg new file mode 100644 index 0000000..7d719f0 --- /dev/null +++ b/WhiteNights/Assets.xcassets/det_wind_speed.imageset/det_wind.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/WhiteNights/Assets.xcassets/en_lang_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/en_lang_icon.imageset/Contents.json new file mode 100644 index 0000000..2a67fc0 --- /dev/null +++ b/WhiteNights/Assets.xcassets/en_lang_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "en_lang_icon.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/en_lang_icon.imageset/en_lang_icon.svg b/WhiteNights/Assets.xcassets/en_lang_icon.imageset/en_lang_icon.svg new file mode 100644 index 0000000..a9dff90 --- /dev/null +++ b/WhiteNights/Assets.xcassets/en_lang_icon.imageset/en_lang_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/WhiteNights/Assets.xcassets/ru_lang_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/ru_lang_icon.imageset/Contents.json new file mode 100644 index 0000000..0b97ead --- /dev/null +++ b/WhiteNights/Assets.xcassets/ru_lang_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "ru_lang_icon.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/ru_lang_icon.imageset/ru_lang_icon.svg b/WhiteNights/Assets.xcassets/ru_lang_icon.imageset/ru_lang_icon.svg new file mode 100644 index 0000000..77f7bcc --- /dev/null +++ b/WhiteNights/Assets.xcassets/ru_lang_icon.imageset/ru_lang_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WhiteNights/Assets.xcassets/zh_lang_icon.imageset/Contents.json b/WhiteNights/Assets.xcassets/zh_lang_icon.imageset/Contents.json new file mode 100644 index 0000000..b2c64bc --- /dev/null +++ b/WhiteNights/Assets.xcassets/zh_lang_icon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "zh_lang_icon.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhiteNights/Assets.xcassets/zh_lang_icon.imageset/zh_lang_icon.svg b/WhiteNights/Assets.xcassets/zh_lang_icon.imageset/zh_lang_icon.svg new file mode 100644 index 0000000..fa4248d --- /dev/null +++ b/WhiteNights/Assets.xcassets/zh_lang_icon.imageset/zh_lang_icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/WhiteNights/ContentView.swift b/WhiteNights/ContentView.swift index ba1a3fa..1a983ac 100644 --- a/WhiteNights/ContentView.swift +++ b/WhiteNights/ContentView.swift @@ -1,24 +1,84 @@ -// -// ContentView.swift -// WhiteNights -// -// Created by Микаэл Оганесян on 24.08.2025. -// - import SwiftUI struct ContentView: View { + @EnvironmentObject var appState: AppState + @State private var showMenu = false + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + NavigationStack { + ZStack { + VStack(spacing: 10) { + HStack(spacing: 10) { + RouteView() + WeatherView() + } + + if let sightId = appState.sightId { + SightView(sightId: sightId) + } else { + Text("Выберите маршрут, чтобы увидеть достопримечательность") + .foregroundColor(.gray) + .padding() + } + } + .padding() + + // Плавающая кнопка в правом нижнем углу + VStack { + Spacer() + HStack { + Spacer() + Button(action: { + withAnimation(.spring()) { + showMenu.toggle() + } + }) { + Image(systemName: "line.3.horizontal") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.white) + .padding() + .background(Color.blue) + .clipShape(Circle()) + .shadow(radius: 5) + } + .padding(.trailing, 20) + // .padding(.bottom, -20) // Убираем отрицательный паддинг + .padding(.bottom, -20) // Добавляем паддинг, чтобы кнопка не перекрывалась + + } + } + + // Используем кастомное BottomMenu + if showMenu { + // Используем Binding для двусторонней связи + BottomMenu(isPresented: $showMenu) + .transition(.move(edge: .bottom)) + // Добавляем игнорирование safe area сверху, если BottomMenu + // должно полностью закрывать контент, но обычно для + // BottomSheet это не требуется, GeometryReader в BottomMenu + // уже делает нужное растяжение. + } + } + } + .task { + await fetchRoutes() + } + .preferredColorScheme(.light) + } + + private func fetchRoutes() async { + 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 routes = try JSONDecoder().decode([Route].self, from: data) + + appState.allRoutes = routes + if let firstRoute = routes.first { + appState.selectedRoute = firstRoute + } + } catch { + print("Ошибка загрузки маршрутов: \(error)") } - .padding() } } - -#Preview { - ContentView() -} diff --git a/WhiteNights/Models/Article.swift b/WhiteNights/Models/Article.swift new file mode 100644 index 0000000..6afc3e3 --- /dev/null +++ b/WhiteNights/Models/Article.swift @@ -0,0 +1,17 @@ +import Foundation + +struct Article: Decodable, Identifiable, Equatable { + let id: Int + let body: String + let heading: String + let service_name: String? + var isReviewArticle: Bool? = false + + init(id: Int, body: String, heading: String, service_name: String? = nil, isReviewArticle: Bool = false) { + self.id = id + self.body = body + self.heading = heading + self.service_name = service_name + self.isReviewArticle = isReviewArticle + } +} diff --git a/WhiteNights/Models/ArticleMedia.swift b/WhiteNights/Models/ArticleMedia.swift new file mode 100644 index 0000000..199d7fb --- /dev/null +++ b/WhiteNights/Models/ArticleMedia.swift @@ -0,0 +1,8 @@ +import Foundation + +struct ArticleMedia: Decodable, Identifiable { + let id: String + let filename: String + let media_name: String + let media_type: Int +} diff --git a/WhiteNights/Models/Route.swift b/WhiteNights/Models/Route.swift new file mode 100644 index 0000000..c28510c --- /dev/null +++ b/WhiteNights/Models/Route.swift @@ -0,0 +1,11 @@ +struct Route: Decodable, Identifiable { + let id: Int + let routeNumber: String + let routeSysNumber: String + + enum CodingKeys: String, CodingKey { + case id + case routeNumber = "route_number" + case routeSysNumber = "route_sys_number" + } +} diff --git a/WhiteNights/Models/SightModel.swift b/WhiteNights/Models/SightModel.swift new file mode 100644 index 0000000..d89a038 --- /dev/null +++ b/WhiteNights/Models/SightModel.swift @@ -0,0 +1,33 @@ +import Foundation + +struct SightModel: Decodable, Identifiable { + let id: Int + let address: String + let city: String? + let city_id: Int + let latitude: Double + let longitude: Double + let left_article: Int + let name: String + let preview_media: String? + let thumbnail: String + let video_preview: String? + let watermark_lu: String + let watermark_rd: String + + var previewMediaURL: URL? { + guard let preview_media = preview_media else { return nil } + return URL(string: preview_media) + } + + var videoPreviewURL: URL? { + guard let video_preview = video_preview else { return nil } + return URL(string: video_preview) + } +} + +struct SightContent: Decodable { + let name: String + let preview_media: String + let video_preview: String? +} diff --git a/WhiteNights/Models/Station.swift b/WhiteNights/Models/Station.swift new file mode 100644 index 0000000..b54f29e --- /dev/null +++ b/WhiteNights/Models/Station.swift @@ -0,0 +1,6 @@ +import Foundation + +struct Station: Codable, Identifiable { + var id: Int + var name: String +} diff --git a/WhiteNights/Models/StopModels.swift b/WhiteNights/Models/StopModels.swift new file mode 100644 index 0000000..80267b1 --- /dev/null +++ b/WhiteNights/Models/StopModels.swift @@ -0,0 +1,27 @@ +import Foundation + +// MARK: - Stop +struct Stop: Identifiable, Codable { + let id: Int + let name: String +} + +// MARK: - StopDetail +struct StopDetail: Identifiable, Codable { + let id: Int + let name: String + let transfers: Transfers +} + +// MARK: - Transfers +struct Transfers: Codable { + let tram: String? + let trolleybus: String? + let bus: String? + let train: String? + let metroRed: String? + let metroGreen: String? + let metroBlue: String? + let metroPurple: String? + let metroOrange: String? +} diff --git a/WhiteNights/Models/WeaherModel.swift b/WhiteNights/Models/WeaherModel.swift new file mode 100644 index 0000000..f618743 --- /dev/null +++ b/WhiteNights/Models/WeaherModel.swift @@ -0,0 +1,22 @@ +import Foundation + +struct WeatherResponse: Codable { + let currentWeather: CurrentWeatherItem? + let forecast: [ForecastItem] +} + +struct CurrentWeatherItem: Codable { + let temperatureCelsius: Double + let description: String + let humidity: Int + let windSpeed: Double +} + +struct ForecastItem: Codable { + let date: String + let description: String + let humidity: Int + let windSpeed: Double + let minTemperatureCelsius: Double + let maxTemperatureCelsius: Double +} diff --git a/WhiteNights/Utils/Color+Extensions.swift b/WhiteNights/Utils/Color+Extensions.swift new file mode 100644 index 0000000..bbfb1e1 --- /dev/null +++ b/WhiteNights/Utils/Color+Extensions.swift @@ -0,0 +1,13 @@ +import SwiftUI + +extension Color { + init(hex: UInt, alpha: Double = 1) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xFF)/255, + green: Double((hex >> 8) & 0xFF)/255, + blue: Double(hex & 0xFF)/255, + opacity: alpha + ) + } +} diff --git a/WhiteNights/Utils/MarqueeText.swift b/WhiteNights/Utils/MarqueeText.swift new file mode 100644 index 0000000..c5b2f12 --- /dev/null +++ b/WhiteNights/Utils/MarqueeText.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct MarqueeText: View { + let text: String + let font: Font + let foregroundColor: Color + private let speed: CGFloat = 10 // пикселей в секунду + + @State private var textWidth: CGFloat = 0 + @State private var containerWidth: CGFloat = 0 + @State private var offset: CGFloat = 0 + @State private var animationStarted = false + + var body: some View { + GeometryReader { geo in + Text(text) + .font(font) + .foregroundColor(foregroundColor) + .lineLimit(1) + .fixedSize() // важно, чтобы не обрезался + .background(WidthGetter()) + .offset(x: offset) + .onAppear { + containerWidth = geo.size.width + if textWidth > containerWidth, !animationStarted { + animationStarted = true + let distance = textWidth - containerWidth + let duration = Double(distance / speed) + withAnimation(.linear(duration: duration).repeatForever(autoreverses: true)) { + offset = -distance + } + } + } + .clipped() + } + .frame(height: lineHeight(for: font)) + .onPreferenceChange(WidthKey.self) { width in + textWidth = width + } + } + + private func lineHeight(for font: Font) -> CGFloat { + switch font { + case .largeTitle: return 34 + case .title: return 28 + case .title2: return 22 + case .title3: return 20 + case .headline: return 17 + case .body: return 17 + case .callout: return 16 + case .subheadline: return 15 + case .caption: return 13 + case .caption2: return 12 + default: return 17 + } + } +} + +// MARK: — Helpers для измерения ширины текста +private struct WidthKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + +private struct WidthGetter: View { + var body: some View { + GeometryReader { geo in + Color.clear.preference(key: WidthKey.self, value: geo.size.width) + } + } +} diff --git a/WhiteNights/Utils/View+CornerRadius.swift b/WhiteNights/Utils/View+CornerRadius.swift new file mode 100644 index 0000000..7967b80 --- /dev/null +++ b/WhiteNights/Utils/View+CornerRadius.swift @@ -0,0 +1,17 @@ +import SwiftUI + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +private struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} diff --git a/WhiteNights/Utils/View+Extensions.swift b/WhiteNights/Utils/View+Extensions.swift new file mode 100644 index 0000000..bbd5071 --- /dev/null +++ b/WhiteNights/Utils/View+Extensions.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct BlockStyle: ViewModifier { + // Добавляем свойство для хранения радиуса скругления + let cornerRadius: CGFloat + + func body(content: Content) -> some View { + content + .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(cornerRadius) // Применяем скругление к фону + ) + .shadow( + color: Color.black.opacity(0.10), + radius: 8, + x: 0, + y: 2 + ) + // Применяем скругление к содержимому (опционально, но лучше для теней) + .cornerRadius(cornerRadius) + } +} + +extension View { + // Изменяем расширение, чтобы оно принимало параметр cornerRadius + func blockStyle(cornerRadius: CGFloat) -> some View { + modifier(BlockStyle(cornerRadius: cornerRadius)) + } +} diff --git a/WhiteNights/WhiteNightsApp.swift b/WhiteNights/WhiteNightsApp.swift index 474cb6b..8d54cff 100644 --- a/WhiteNights/WhiteNightsApp.swift +++ b/WhiteNights/WhiteNightsApp.swift @@ -9,9 +9,12 @@ import SwiftUI @main struct WhiteNightsApp: App { + @StateObject private var appState = AppState() + var body: some Scene { WindowGroup { ContentView() + .environmentObject(appState) // <- обязательно! } } } diff --git a/WhiteNights/Widgets/BottomMenu.swift b/WhiteNights/Widgets/BottomMenu.swift new file mode 100644 index 0000000..ee9e507 --- /dev/null +++ b/WhiteNights/Widgets/BottomMenu.swift @@ -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) + } + } + ) + } + } +} diff --git a/WhiteNights/Widgets/RouteSelectionView.swift b/WhiteNights/Widgets/RouteSelectionView.swift new file mode 100644 index 0000000..a868068 --- /dev/null +++ b/WhiteNights/Widgets/RouteSelectionView.swift @@ -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)") + } + } +} diff --git a/WhiteNights/Widgets/RouteView.swift b/WhiteNights/Widgets/RouteView.swift new file mode 100644 index 0000000..b878194 --- /dev/null +++ b/WhiteNights/Widgets/RouteView.swift @@ -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)") + } + } +} diff --git a/WhiteNights/Widgets/SightView.swift b/WhiteNights/Widgets/SightView.swift new file mode 100644 index 0000000..5f7c103 --- /dev/null +++ b/WhiteNights/Widgets/SightView.swift @@ -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) + } +} diff --git a/WhiteNights/Widgets/SightViewModel.swift b/WhiteNights/Widgets/SightViewModel.swift new file mode 100644 index 0000000..f37b5da --- /dev/null +++ b/WhiteNights/Widgets/SightViewModel.swift @@ -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(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) + } +} diff --git a/WhiteNights/Widgets/VisualEffectBlur.swift b/WhiteNights/Widgets/VisualEffectBlur.swift new file mode 100644 index 0000000..e81a004 --- /dev/null +++ b/WhiteNights/Widgets/VisualEffectBlur.swift @@ -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) { } +} diff --git a/WhiteNights/Widgets/WeatherView.swift b/WhiteNights/Widgets/WeatherView.swift new file mode 100644 index 0000000..790317f --- /dev/null +++ b/WhiteNights/Widgets/WeatherView.swift @@ -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" + } + } +}