diff --git a/ScheduleTracker.xcodeproj/project.pbxproj b/ScheduleTracker.xcodeproj/project.pbxproj
index 3b1b00b..1b08e70 100644
--- a/ScheduleTracker.xcodeproj/project.pbxproj
+++ b/ScheduleTracker.xcodeproj/project.pbxproj
@@ -218,6 +218,7 @@
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_STRICT_CONCURRENCY = complete;
};
name = Debug;
};
@@ -274,6 +275,7 @@
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_STRICT_CONCURRENCY = complete;
VALIDATE_PRODUCT = YES;
};
name = Release;
diff --git a/ScheduleTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ScheduleTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..0c67376
--- /dev/null
+++ b/ScheduleTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/ScheduleTracker/API/APIServicesContainer.swift b/ScheduleTracker/API/APIServicesContainer.swift
index f1ec419..2092eae 100644
--- a/ScheduleTracker/API/APIServicesContainer.swift
+++ b/ScheduleTracker/API/APIServicesContainer.swift
@@ -4,8 +4,9 @@
//
// Created by Василий Ханин on 12.06.2025.
//
+import Combine
-final class APIServicesContainer {
+final class APIServicesContainer: ObservableObject {
let nearestStationsService: NearestStationsServiceProtocol
let copyrightService: CopyrightServiceProtocol
let searchService: SearchServiceProtocol
diff --git a/ScheduleTracker/App/ScheduleTrackerApp.swift b/ScheduleTracker/App/ScheduleTrackerApp.swift
index 9f68135..cb5cd81 100644
--- a/ScheduleTracker/App/ScheduleTrackerApp.swift
+++ b/ScheduleTracker/App/ScheduleTrackerApp.swift
@@ -9,11 +9,21 @@ import SwiftUI
@main
struct ScheduleTrackerApp: App {
+ private static var servicesInstance: APIServicesContainer = {
+ do {
+ return try APIServicesContainer()
+ } catch {
+ fatalError("Failed to initialize APIServicesContainer: \(error)")
+ }
+ }()
+
+ @StateObject private var services = servicesInstance
@AppStorage("isDarkMode") private var isDarkMode = false
var body: some Scene {
WindowGroup {
BaseView()
+ .environmentObject(services)
.preferredColorScheme(isDarkMode ? .dark : .light)
}
}
diff --git a/ScheduleTracker/Helpers/AppErrorType.swift b/ScheduleTracker/Helpers/AppErrorType.swift
new file mode 100644
index 0000000..f572158
--- /dev/null
+++ b/ScheduleTracker/Helpers/AppErrorType.swift
@@ -0,0 +1,35 @@
+//
+// AppErrorType.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 24.07.2025.
+//
+
+
+import Foundation
+
+enum AppErrorType {
+ case none
+ case server
+ case internet
+}
+
+@MainActor
+protocol ErrorHandleable: ObservableObject {
+ var errorType: AppErrorType { get set }
+}
+
+extension ErrorHandleable {
+ func handle(error: Error) {
+ if let urlError = error as? URLError {
+ switch urlError.code {
+ case .notConnectedToInternet:
+ errorType = .internet
+ default:
+ errorType = .server
+ }
+ } else {
+ errorType = .server
+ }
+ }
+}
diff --git a/ScheduleTracker/ViewModels/NavigationCoordinator.swift b/ScheduleTracker/Helpers/NavigationCoordinator.swift
similarity index 79%
rename from ScheduleTracker/ViewModels/NavigationCoordinator.swift
rename to ScheduleTracker/Helpers/NavigationCoordinator.swift
index 49adfe0..6996a28 100644
--- a/ScheduleTracker/ViewModels/NavigationCoordinator.swift
+++ b/ScheduleTracker/Helpers/NavigationCoordinator.swift
@@ -15,8 +15,10 @@ final class NavigationCoordinator: ObservableObject {
@Published var selectedCityTo: String = ""
@Published var selectedStationTo: String = ""
@Published var timeFilters: Set = []
- @Published var showTransfers: Bool? = nil
-
+ @Published var showTransfers: Bool?
+ @Published var selectedStationFromCode: String = ""
+ @Published var selectedStationToCode: String = ""
+
var isFiltersValid: Bool {
!timeFilters.isEmpty && showTransfers != nil
}
diff --git a/ScheduleTracker/Helpers/StateWrapperView.swift b/ScheduleTracker/Helpers/StateWrapperView.swift
new file mode 100644
index 0000000..c7c86ce
--- /dev/null
+++ b/ScheduleTracker/Helpers/StateWrapperView.swift
@@ -0,0 +1,31 @@
+//
+// StateWrapperView.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 25.07.2025.
+//
+import SwiftUI
+
+struct StateWrapperView: View {
+ let isLoading: Bool
+ let errorType: AppErrorType?
+ let content: () -> Content
+
+ var body: some View {
+ Group {
+ if isLoading {
+ VStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ } else if errorType == .internet {
+ ErrorInternetView()
+ } else if errorType == .server {
+ ErrorServerView()
+ } else {
+ content()
+ }
+ }
+ }
+}
diff --git a/ScheduleTracker/Models/CarrierInfoModel.swift b/ScheduleTracker/Models/CarrierInfoModel.swift
new file mode 100644
index 0000000..531cbba
--- /dev/null
+++ b/ScheduleTracker/Models/CarrierInfoModel.swift
@@ -0,0 +1,18 @@
+//
+// CarrierInfoModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 24.07.2025.
+//
+
+import Foundation
+
+struct CarrierInfoModel: Identifiable, Sendable {
+ var id: Int { code }
+ let code: Int
+ let title: String
+ let logo: String?
+ let email: String?
+ let phone: String?
+}
+
diff --git a/ScheduleTracker/Models/StationModel.swift b/ScheduleTracker/Models/StationModel.swift
new file mode 100644
index 0000000..be52c60
--- /dev/null
+++ b/ScheduleTracker/Models/StationModel.swift
@@ -0,0 +1,12 @@
+//
+// StationModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 23.07.2025.
+//
+import Foundation
+
+struct StationModel: Identifiable, Sendable {
+ let id: String
+ let title: String
+}
diff --git a/ScheduleTracker/Models/StoryModel.swift b/ScheduleTracker/Models/StoryModel.swift
index f9de768..81f9c23 100644
--- a/ScheduleTracker/Models/StoryModel.swift
+++ b/ScheduleTracker/Models/StoryModel.swift
@@ -1,6 +1,6 @@
import SwiftUI
-struct Story: Identifiable {
+struct Story: Identifiable, Sendable {
let id = UUID()
let previewImageName: String
let fullImageName: String
diff --git a/ScheduleTracker/Models/TicketModel.swift b/ScheduleTracker/Models/TicketModel.swift
index 96c1103..e5fa828 100644
--- a/ScheduleTracker/Models/TicketModel.swift
+++ b/ScheduleTracker/Models/TicketModel.swift
@@ -7,7 +7,7 @@
import SwiftUI
-struct TicketModel: Hashable, Identifiable {
+struct TicketModel: Hashable, Identifiable, Sendable {
let id = UUID()
let operatorName: String
let date: String
@@ -16,5 +16,6 @@ struct TicketModel: Hashable, Identifiable {
let duration: String
let withTransfer: Bool
let operatorLogo: String
- let note: String?
+ let transfer: String?
+ let carrierCode: Int?
}
diff --git a/ScheduleTracker/Services/CarrierServiceProtocol.swift b/ScheduleTracker/Services/CarrierServiceProtocol.swift
index d778d82..e7aee24 100644
--- a/ScheduleTracker/Services/CarrierServiceProtocol.swift
+++ b/ScheduleTracker/Services/CarrierServiceProtocol.swift
@@ -10,11 +10,11 @@ import OpenAPIURLSession
typealias Carrier = Components.Schemas.CarrierResponse
-protocol CarrierServiceProtocol {
+@preconcurrency protocol CarrierServiceProtocol {
func getCarrierInfo(code: String) async throws -> Carrier
}
-final class CarrierService: CarrierServiceProtocol {
+actor CarrierService: CarrierServiceProtocol {
private let client: Client
private let apikey: String
diff --git a/ScheduleTracker/Services/CopyrightServiceProtocol.swift b/ScheduleTracker/Services/CopyrightServiceProtocol.swift
index 0c7775a..dd23898 100644
--- a/ScheduleTracker/Services/CopyrightServiceProtocol.swift
+++ b/ScheduleTracker/Services/CopyrightServiceProtocol.swift
@@ -10,11 +10,11 @@ import OpenAPIURLSession
typealias Copyright = Components.Schemas.Copyright
-protocol CopyrightServiceProtocol {
+@preconcurrency protocol CopyrightServiceProtocol {
func getCopyright() async throws -> Copyright
}
-final class CopyrightService: CopyrightServiceProtocol {
+actor CopyrightService: CopyrightServiceProtocol {
private let client: Client
private let apikey: String
diff --git a/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift b/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift
index 1cafc4d..160dff4 100644
--- a/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift
+++ b/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift
@@ -10,11 +10,11 @@ import OpenAPIURLSession
typealias Settlement = Components.Schemas.Settlement
-protocol NearestSettlementServiceProtocol {
+@preconcurrency protocol NearestSettlementServiceProtocol {
func getNearestCity(lat: Double, lng: Double) async throws -> Settlement
}
-final class SettlementService: NearestSettlementServiceProtocol {
+actor SettlementService: NearestSettlementServiceProtocol {
private let client: Client
private let apikey: String
diff --git a/ScheduleTracker/Services/NearestStationsServiceProtocol.swift b/ScheduleTracker/Services/NearestStationsServiceProtocol.swift
index b60c76b..b2c7eaf 100644
--- a/ScheduleTracker/Services/NearestStationsServiceProtocol.swift
+++ b/ScheduleTracker/Services/NearestStationsServiceProtocol.swift
@@ -17,13 +17,13 @@ import OpenAPIURLSession
typealias NearestStations = Components.Schemas.Stations
// Определяем протокол для нашего сервиса (хорошая практика для тестирования и гибкости)
-protocol NearestStationsServiceProtocol {
+@preconcurrency protocol NearestStationsServiceProtocol {
// Функция для получения станций, асинхронная и может выбросить ошибку
func getNearestStations(lat: Double, lng: Double, distance: Int) async throws -> NearestStations
}
// Конкретная реализация сервиса
-final class NearestStationsService: NearestStationsServiceProtocol {
+actor NearestStationsService: NearestStationsServiceProtocol {
// Хранит экземпляр сгенерированного клиента
private let client: Client
// Хранит API-ключ (лучше передавать его извне, чем хранить прямо в сервисе)
diff --git a/ScheduleTracker/Services/ScheduleServiceProtocol.swift b/ScheduleTracker/Services/ScheduleServiceProtocol.swift
index 7fb584d..e8e99c7 100644
--- a/ScheduleTracker/Services/ScheduleServiceProtocol.swift
+++ b/ScheduleTracker/Services/ScheduleServiceProtocol.swift
@@ -10,11 +10,11 @@ import OpenAPIURLSession
typealias Schedule = Components.Schemas.ScheduleSchema
-protocol ScheduleServiceProtocol {
+@preconcurrency protocol ScheduleServiceProtocol {
func getStationSchedule(station: String) async throws -> Schedule
}
-final class ScheduleService: ScheduleServiceProtocol {
+actor ScheduleService: ScheduleServiceProtocol {
private let client: Client
private let apikey: String
diff --git a/ScheduleTracker/Services/SearchServiceProtocol.swift b/ScheduleTracker/Services/SearchServiceProtocol.swift
index 3f3b447..f2b677a 100644
--- a/ScheduleTracker/Services/SearchServiceProtocol.swift
+++ b/ScheduleTracker/Services/SearchServiceProtocol.swift
@@ -10,11 +10,11 @@ import OpenAPIURLSession
typealias Search = Components.Schemas.SearchSchema
-protocol SearchServiceProtocol {
- func getSchedualBetweenStations(from: String, to: String) async throws -> Search
+@preconcurrency protocol SearchServiceProtocol {
+ func getScheduleBetweenStations(from: String, to: String) async throws -> Search
}
-final class SearchService: SearchServiceProtocol {
+actor SearchService: SearchServiceProtocol {
private let client: Client
private let apikey: String
@@ -24,7 +24,7 @@ final class SearchService: SearchServiceProtocol {
self.apikey = apikey
}
- func getSchedualBetweenStations(from: String, to: String) async throws -> Search {
+ func getScheduleBetweenStations(from: String, to: String) async throws -> Search {
let response = try await client.getSearch(query: .init(apikey: apikey, from: from, to: to))
return try response.ok.body.json
diff --git a/ScheduleTracker/Services/StationListServiceProtocol.swift b/ScheduleTracker/Services/StationListServiceProtocol.swift
index 7b0d98a..677e153 100644
--- a/ScheduleTracker/Services/StationListServiceProtocol.swift
+++ b/ScheduleTracker/Services/StationListServiceProtocol.swift
@@ -11,11 +11,11 @@ import Foundation
typealias AllStation = Components.Schemas.StationsList
-protocol StationListServiceProtocol {
+@preconcurrency protocol StationListServiceProtocol {
func getAllStations() async throws -> AllStation
}
-final class StationtService: StationListServiceProtocol {
+actor StationtService: StationListServiceProtocol {
private let client: Client
private let apikey: String
diff --git a/ScheduleTracker/Services/ThreadServiceProtocol.swift b/ScheduleTracker/Services/ThreadServiceProtocol.swift
index dbd924e..6095f03 100644
--- a/ScheduleTracker/Services/ThreadServiceProtocol.swift
+++ b/ScheduleTracker/Services/ThreadServiceProtocol.swift
@@ -10,11 +10,11 @@ import OpenAPIURLSession
typealias Thread = Components.Schemas.Thread
-protocol ThreadServiceProtocol {
+@preconcurrency protocol ThreadServiceProtocol {
func getRouteStations(uid: String) async throws -> Thread
}
-final class ThreadService: ThreadServiceProtocol {
+actor ThreadService: ThreadServiceProtocol {
private let client: Client
private let apikey: String
diff --git a/ScheduleTracker/ViewModels/CarrierInfoViewModel.swift b/ScheduleTracker/ViewModels/CarrierInfoViewModel.swift
new file mode 100644
index 0000000..22dc819
--- /dev/null
+++ b/ScheduleTracker/ViewModels/CarrierInfoViewModel.swift
@@ -0,0 +1,46 @@
+//
+// CarrierInfoViewModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 24.07.2025.
+//
+
+import Foundation
+
+@MainActor
+final class CarrierInfoViewModel: ObservableObject, ErrorHandleable {
+ @Published var carrier: CarrierInfoModel?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+ @Published var errorType: AppErrorType = .none
+
+ private let service: CarrierServiceProtocol
+
+ init(service: CarrierServiceProtocol) {
+ self.service = service
+ }
+
+ func fetchCarrier(code: String) async {
+ isLoading = true
+ errorType = .none
+ do {
+ let response = try await service.getCarrierInfo(code: code)
+ guard let carrier = response.carrier else {
+ errorMessage = "Не найдено информации о перевозчике"
+ isLoading = false
+ return
+ }
+ self.carrier = CarrierInfoModel(
+ code: carrier.code ?? 0,
+ title: carrier.title ?? "—",
+ logo: carrier.logo,
+ email: carrier.email,
+ phone: carrier.phone
+ )
+ } catch {
+ self.handle(error: error)
+ }
+ isLoading = false
+ }
+}
+
diff --git a/ScheduleTracker/ViewModels/CitySelectionViewModel.swift b/ScheduleTracker/ViewModels/CitySelectionViewModel.swift
new file mode 100644
index 0000000..7459ad8
--- /dev/null
+++ b/ScheduleTracker/ViewModels/CitySelectionViewModel.swift
@@ -0,0 +1,70 @@
+//
+// CitySelectionViewModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 21.07.2025.
+//
+
+import Foundation
+import Combine
+
+@MainActor
+final class CitySelectionViewModel: ObservableObject, ErrorHandleable {
+ @Published private(set) var allCities: [String] = []
+ @Published private(set) var filteredCities: [String] = []
+ @Published var isLoading: Bool = false
+ @Published var searchText: String = "" {
+ didSet { filterCities() }
+ }
+ @Published var errorType: AppErrorType = .none
+ private let stationListService: StationListServiceProtocol
+ private var allSettlements: [Components.Schemas.StationsList.countriesPayloadPayload.regionsPayloadPayload.settlementsPayloadPayload] = []
+ private var loadTask: Task?
+
+ init(stationListService: StationListServiceProtocol) {
+ self.stationListService = stationListService
+ loadCities()
+ }
+
+ func loadCities() {
+ isLoading = true
+ errorType = .none
+ loadTask?.cancel()
+ loadTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let stationsList = try await stationListService.getAllStations()
+ let settlements = stationsList.countries?
+ .flatMap { $0.regions ?? [] }
+ .flatMap { $0.settlements ?? [] }
+ ?? []
+ self.allSettlements = settlements
+ let cities = settlements
+ .compactMap { $0.title }
+ .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
+ .removingDuplicates()
+ self.allCities = cities
+ self.filterCities()
+ } catch {
+ self.handle(error: error)
+ }
+ self.isLoading = false
+ }
+ }
+
+ private func filterCities() {
+ let text = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if text.isEmpty {
+ filteredCities = allCities
+ } else {
+ filteredCities = allCities.filter { $0.localizedCaseInsensitiveContains(text) }
+ }
+ }
+}
+
+private extension Array where Element == String {
+ func removingDuplicates() -> [String] {
+ var set = Set()
+ return filter { set.insert($0).inserted }
+ }
+}
diff --git a/ScheduleTracker/ViewModels/SettingsViewModel.swift b/ScheduleTracker/ViewModels/SettingsViewModel.swift
new file mode 100644
index 0000000..ee1ba6b
--- /dev/null
+++ b/ScheduleTracker/ViewModels/SettingsViewModel.swift
@@ -0,0 +1,40 @@
+//
+// SettingsViewModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 24.07.2025.
+//
+
+import Foundation
+
+@MainActor
+final class SettingsViewModel: ObservableObject, ErrorHandleable {
+ @Published var copyrightText: String = ""
+ @Published var copyrightURL: URL?
+ @Published var errorType: AppErrorType = .none
+ @Published var isLoading = false
+
+ private let copyrightService: CopyrightServiceProtocol
+
+ init(copyrightService: CopyrightServiceProtocol) {
+ self.copyrightService = copyrightService
+ }
+
+ func fetchCopyright() async {
+ isLoading = true
+ errorType = .none
+ defer { isLoading = false }
+ do {
+ let copyright = try await copyrightService.getCopyright()
+ if let text = copyright.copyright?.text {
+ self.copyrightText = text
+ }
+ if let urlStr = copyright.copyright?.url, let url = URL(string: urlStr) {
+ self.copyrightURL = url
+ }
+ } catch {
+ handle(error: error)
+ }
+ }
+}
+
diff --git a/ScheduleTracker/ViewModels/StationSelectionViewModel.swift b/ScheduleTracker/ViewModels/StationSelectionViewModel.swift
new file mode 100644
index 0000000..25f0c00
--- /dev/null
+++ b/ScheduleTracker/ViewModels/StationSelectionViewModel.swift
@@ -0,0 +1,78 @@
+//
+// StationSelectionViewModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 21.07.2025.
+//
+
+
+import Foundation
+import Combine
+
+@MainActor
+final class StationSelectionViewModel: ObservableObject, ErrorHandleable {
+ @Published private(set) var allStations: [StationModel] = []
+ @Published private(set) var filteredStations: [StationModel] = []
+ @Published var isLoading: Bool = false
+ @Published var searchText: String = "" {
+ didSet { filterStations() }
+ }
+ @Published var errorType: AppErrorType = .none
+
+ private let stationListService: StationListServiceProtocol
+ private let city: String
+ private var loadTask: Task?
+
+ init(stationListService: StationListServiceProtocol, city: String) {
+ self.stationListService = stationListService
+ self.city = city
+ loadStations()
+ }
+
+ func loadStations() {
+ isLoading = true
+ errorType = .none
+ loadTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let stationsList = try await stationListService.getAllStations()
+ let settlements = stationsList.countries?
+ .flatMap { $0.regions ?? [] }
+ .flatMap { $0.settlements ?? [] }
+ ?? []
+ let settlement = settlements.first { $0.title == self.city }
+ let stations = settlement?.stations ?? []
+
+ let StationModels = stations
+ .compactMap { station in
+ guard let title = station.title,
+ let code = station.codes?.yandex_code
+ else { return nil }
+ return StationModel(id: code, title: title)
+ }
+ .removingDuplicates()
+ self.allStations = StationModels
+ self.filterStations()
+ } catch {
+ handle(error: error)
+ }
+ self.isLoading = false
+ }
+ }
+
+ private func filterStations() {
+ let text = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if text.isEmpty {
+ filteredStations = allStations
+ } else {
+ filteredStations = allStations.filter { $0.title.localizedCaseInsensitiveContains(text) }
+ }
+ }
+}
+
+private extension Array where Element == StationModel {
+ func removingDuplicates() -> [StationModel] {
+ var set = Set()
+ return filter { set.insert($0.id).inserted }
+ }
+}
diff --git a/ScheduleTracker/ViewModels/TicketListViewModel.swift b/ScheduleTracker/ViewModels/TicketListViewModel.swift
new file mode 100644
index 0000000..a2da0fc
--- /dev/null
+++ b/ScheduleTracker/ViewModels/TicketListViewModel.swift
@@ -0,0 +1,133 @@
+//
+// TicketListViewModel.swift
+// ScheduleTracker
+//
+// Created by Василий Ханин on 20.07.2025.
+//
+
+import Foundation
+import Combine
+
+@MainActor
+final class TicketListViewModel: ObservableObject, ErrorHandleable {
+ @Published var tickets: [TicketModel] = []
+ @Published var isLoading = false
+ @Published var errorType: AppErrorType = .none
+ @Published var showTransfers: Bool?
+ @Published var timeFilters: Set = []
+
+ private let searchService: SearchServiceProtocol
+ let fromStation: String
+ let toStation: String
+
+ init(
+ searchService: SearchServiceProtocol,
+ fromStation: String,
+ toStation: String,
+ showTransfers: Bool?,
+ timeFilters: Set = []
+ ) {
+ self.searchService = searchService
+ self.fromStation = fromStation
+ self.toStation = toStation
+ self.showTransfers = showTransfers
+ self.timeFilters = timeFilters
+ }
+
+ func loadTickets() async {
+ isLoading = true
+ errorType = .none
+ do {
+ let search = try await searchService.getScheduleBetweenStations(from: fromStation, to: toStation)
+ self.tickets = TicketListViewModel.parseTickets(from: search)
+ } catch {
+ self.handle(error: error)
+ self.tickets = []
+ }
+ isLoading = false
+ }
+
+ var filteredTickets: [TicketModel] {
+ tickets.filter { ticket in
+ if let show = showTransfers, show != ticket.withTransfer { return false }
+ if !timeFilters.isEmpty {
+ let depHour = Int(ticket.departure.prefix(2)) ?? 0
+ let period: TimePeriod = {
+ switch depHour {
+ case 6..<12: return .morning
+ case 12..<18: return .day
+ case 18..<24: return .evening
+ default: return .night
+ }
+ }()
+ if !timeFilters.contains(period) { return false }
+ }
+ return true
+ }
+ }
+
+ static func parseTickets(from search: Search) -> [TicketModel] {
+ guard let segments = search.segments else { return [] }
+ return segments.compactMap { seg in
+ let thread = seg.thread
+ let carrier = thread?.carrier
+
+ let departureTime = parseDateOrTime(seg.departure ?? "", asTime: true)
+ let arrivalTime = parseDateOrTime(seg.arrival ?? "", asTime: true)
+ let date = parseDateOrTime(seg.start_date ?? "", asTime: false)
+
+ let durationSeconds = Int(seg.duration ?? 0)
+ let hours = durationSeconds / 3600
+ let minutes = (durationSeconds % 3600) / 60
+
+ let durationString: String
+ if hours > 0 {
+ durationString = "\(hours) ч \(minutes) мин"
+ } else if minutes > 0 {
+ durationString = "\(minutes) мин"
+ } else {
+ durationString = "—"
+ }
+
+ return TicketModel(
+ operatorName: carrier?.title ?? "—",
+ date: date,
+ departure: departureTime,
+ arrival: arrivalTime,
+ duration: durationString,
+ withTransfer: seg.has_transfers ?? false,
+ operatorLogo: carrier?.logo ?? "",
+ transfer: seg.stops,
+ carrierCode: carrier?.code.flatMap { Int($0) }
+ )
+ }
+ }
+
+ static func parseDateOrTime(_ isoString: String, asTime: Bool = false) -> String {
+ if isoString.range(of: #"^\d{2}:\d{2}(:\d{2})?$"#, options: .regularExpression) != nil {
+ return asTime ? String(isoString.prefix(5)) : "—"
+ }
+ let formats = [
+ "yyyy-MM-dd'T'HH:mm:ssZ",
+ "yyyy-MM-dd'T'HH:mm:ss",
+ "yyyy-MM-dd HH:mm:ss",
+ "yyyy-MM-dd HH:mm",
+ "yyyy-MM-dd"
+ ]
+ for format in formats {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "ru_RU")
+ formatter.timeZone = .current
+ formatter.dateFormat = format
+ if let date = formatter.date(from: isoString) {
+ let outFormatter = DateFormatter()
+ outFormatter.locale = Locale(identifier: "ru_RU")
+ outFormatter.timeZone = .current
+ outFormatter.dateFormat = asTime ? "HH:mm" : "d MMMM"
+ return outFormatter.string(from: date)
+ }
+ }
+ return "—"
+ }
+
+}
diff --git a/ScheduleTracker/Views/BaseView.swift b/ScheduleTracker/Views/BaseView.swift
index 9f50b19..3d02518 100644
--- a/ScheduleTracker/Views/BaseView.swift
+++ b/ScheduleTracker/Views/BaseView.swift
@@ -10,6 +10,7 @@ import SwiftUI
struct BaseView: View {
@StateObject private var coordinator = NavigationCoordinator()
@StateObject private var viewModel = MockReelsModel()
+ @EnvironmentObject var services: APIServicesContainer
var body: some View {
ZStack {
@@ -22,7 +23,8 @@ struct BaseView: View {
.renderingMode(.template)
}
- SettingsView()
+ SettingsView(viewModel: SettingsViewModel(copyrightService: services.copyrightService))
+
.tabItem {
Image(.gearTabItem)
.renderingMode(.template)
@@ -32,15 +34,36 @@ struct BaseView: View {
.navigationDestination(for: EnumAppRoute.self) { route in
switch route {
case .cityPicker(let fromField):
- ChangeCityView(coordinator: coordinator, fromField: fromField)
+ ChangeCityView(
+ coordinator: coordinator,
+ fromField: fromField,
+ stationListService: services.allStationService
+ )
case .stationPicker(let city, let fromField):
- ChangeStationView(coordinator: coordinator, city: city, fromField: fromField)
+ ChangeStationView(
+ coordinator: coordinator,
+ city: city,
+ fromField: fromField,
+ stationListService: services.allStationService
+ )
case .tickets:
- TicketListView(coordinator: coordinator)
+ TicketListView(
+ coordinator: coordinator,
+ viewModel: TicketListViewModel(
+ searchService: services.searchService,
+ fromStation: coordinator.selectedStationFromCode,
+ toStation: coordinator.selectedStationToCode,
+ showTransfers: coordinator.showTransfers,
+ timeFilters: coordinator.timeFilters
+ )
+ )
case .filters:
FiltersView(coordinator: coordinator)
case .carrierInfo(let ticket):
- CarrierInfoView(carrier: ticket)
+ CarrierInfoView(
+ viewModel: CarrierInfoViewModel(service: services.carrierService),
+ code: ticket.carrierCode.map(String.init) ?? ""
+ )
}
}
diff --git a/ScheduleTracker/Views/CarrierInfoView.swift b/ScheduleTracker/Views/CarrierInfoView.swift
index d0b5f97..594ed22 100644
--- a/ScheduleTracker/Views/CarrierInfoView.swift
+++ b/ScheduleTracker/Views/CarrierInfoView.swift
@@ -7,85 +7,101 @@
import SwiftUI
-
struct CarrierInfoView: View {
@Environment(\.dismiss) var dismiss
- let carrier: TicketModel
+ @ObservedObject var viewModel: CarrierInfoViewModel
+ let code: String
var body: some View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
- VStack(spacing: 16) {
- Image(carrier.operatorLogo)
- .resizable()
- .scaledToFit()
- .frame(width: 343, height: 104)
- .padding(.top, 16)
- VStack(alignment: .leading, spacing: 16) {
- Text(carrier.operatorName == "РЖД" ? "ОАО «РЖД»" : carrier.operatorName)
- .font(.custom("SFPro-Bold", size: 24))
- .foregroundStyle(Color("dayOrNightColor"))
- VStack(alignment: .leading, spacing: 0) {
- // email
- Text("E-mail")
- .font(.custom("SFPro-Regular", size: 16))
- .foregroundStyle(Color("dayOrNightColor"))
- .padding(.top, 12)
- if let mailURL = URL(string: "mailto:i.lozgkina@yandex.ru") {
- Link("i.lozgkina@yandex.ru", destination: mailURL)
- .font(.custom("SFPro-Regular", size: 16))
- .foregroundColor(.blue)
- .padding(.bottom, 12)
+ StateWrapperView(isLoading: viewModel.isLoading, errorType: viewModel.errorType) {
+ Group {
+ if let carrier = viewModel.carrier {
+ VStack(spacing: 16) {
+ if let logo = carrier.logo, let url = URL(string: logo) {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .empty:
+ ProgressView()
+ case .success(let image):
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ case .failure:
+ Image(systemName: "photo")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ @unknown default:
+ EmptyView()
+ }
+ }
+ .frame(width: 343, height: 104)
+ .clipped()
+ }
+ VStack(alignment: .leading, spacing: 16) {
+ Text(carrier.title)
+ .font(.custom("SFPro-Bold", size: 24))
+ .foregroundStyle(Color("dayOrNightColor"))
+ if let email = carrier.email {
+ Text("E-mail")
+ .font(.custom("SFPro-Regular", size: 16))
+ .foregroundStyle(Color("dayOrNightColor"))
+ .padding(.top, 12)
+ if let mailURL = URL(string: "mailto:\(email)") {
+ Link(email, destination: mailURL)
+ .font(.custom("SFPro-Regular", size: 16))
+ .foregroundColor(.blue)
+ .padding(.bottom, 12)
+ }
+ }
+ if let phone = carrier.phone {
+ Text("Телефон")
+ .font(.custom("SFPro-Regular", size: 16))
+ .foregroundStyle(Color("dayOrNightColor"))
+ .padding(.top, 12)
+ if let telURL = URL(string: "tel:\(phone.onlyDigits())") {
+ Link(phone, destination: telURL)
+ .font(.custom("SFPro-Regular", size: 16))
+ .foregroundColor(.blue)
+ .padding(.bottom, 12)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 16)
+ Spacer()
}
- // телефон
- Text("Телефон")
- .font(.custom("SFPro-Regular", size: 16))
- .foregroundStyle(Color("dayOrNightColor"))
- .padding(.top, 12)
- Link("+7 (904) 329-27-71", destination: URL(string: "tel:+79043292771")!)
- .font(.custom("SFPro-Regular", size: 16))
- .foregroundColor(.blue)
- .padding(.bottom, 12)
+ .padding()
}
}
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.leading, 16)
- Spacer()
}
- .padding()
- .navigationBarBackButtonHidden(true)
- .toolbar {
- ToolbarItem(placement: .navigationBarLeading) {
- Button(action: { dismiss() }) {
- Image("leftChevron")
- .renderingMode(.template)
- .foregroundStyle(Color("dayOrNightColor"))
- }
- }
- ToolbarItem(placement: .principal) {
- Text("Информация о перевозчике")
- .font(.custom("SFPro-Bold", size: 17))
+ }
+ .navigationBarBackButtonHidden(true)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarLeading) {
+ Button(action: { dismiss() }) {
+ Image("leftChevron")
+ .renderingMode(.template)
.foregroundStyle(Color("dayOrNightColor"))
}
}
+ ToolbarItem(placement: .principal) {
+ Text("Информация о перевозчике")
+ .font(.custom("SFPro-Bold", size: 17))
+ .foregroundStyle(Color("dayOrNightColor"))
+ }
+ }
+ .task {
+ await viewModel.fetchCarrier(code: code)
}
}
}
-#if DEBUG
-struct CarrierInfoView_Previews: PreviewProvider {
- static var previews: some View {
- CarrierInfoView(
- carrier: TicketModel(
- operatorName: "РЖД",
- date: "14 января",
- departure: "22:30",
- arrival: "08:15",
- duration: "20 часов",
- withTransfer: true,
- operatorLogo: "RJDImage",
- note: "С пересадкой в Костроме"
- )
- )
+
+extension String {
+ func onlyDigits() -> String {
+ filter { $0.isNumber }
}
}
-#endif
diff --git a/ScheduleTracker/Views/ChangeCityView.swift b/ScheduleTracker/Views/ChangeCityView.swift
index 0213ac0..b871749 100644
--- a/ScheduleTracker/Views/ChangeCityView.swift
+++ b/ScheduleTracker/Views/ChangeCityView.swift
@@ -8,70 +8,69 @@
import SwiftUI
struct ChangeCityView: View {
- @State private var searchText = ""
- @Environment(\.dismiss) var dismiss
@ObservedObject var coordinator: NavigationCoordinator
let fromField: Bool
+ let stationListService: StationListServiceProtocol
+ @StateObject private var viewModel: CitySelectionViewModel
+ @Environment(\.dismiss) var dismiss
- let cities = ["Москва", "Санкт-Петербург", "Сочи", "Горный воздух", "Краснодар", "Казань", "Омск"]
-
- var filteredItems: [String] {
- if searchText.isEmpty {
- return cities
- } else {
- return cities.filter { $0.localizedCaseInsensitiveContains(searchText) }
- }
+ init(coordinator: NavigationCoordinator, fromField: Bool, stationListService: StationListServiceProtocol) {
+ self.coordinator = coordinator
+ self.fromField = fromField
+ self.stationListService = stationListService
+ _viewModel = StateObject(wrappedValue: CitySelectionViewModel(stationListService: stationListService))
}
var body: some View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
VStack(spacing: 0) {
- CustomSearchBar(text: $searchText, placeholder: "Введите запрос")
-
- ScrollView(.vertical, showsIndicators: false) {
- LazyVStack(alignment: .leading) {
- if filteredItems.isEmpty {
- VStack {
- Text("Город не найден")
- .font(.custom("SFPro-Bold", size: 24))
- .foregroundStyle(Color("dayOrNightColor"))
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 176)
- }
- } else {
- ForEach(filteredItems, id: \.self) { item in
- Button(action: {
- if fromField {
- coordinator.selectedCityFrom = item
- coordinator.selectedStationFrom = ""
- } else {
- coordinator.selectedCityTo = item
- coordinator.selectedStationTo = ""
- }
- coordinator.path.append(EnumAppRoute.stationPicker(city: item, fromField: fromField))
- }) {
- HStack {
- Text("\(item)")
- .font(.custom("SFPro-Regular", size: 17))
- .foregroundStyle(Color("dayOrNightColor"))
-
- Spacer()
-
- Image("rightChevron")
- .renderingMode(.template)
+ CustomSearchBar(text: $viewModel.searchText, placeholder: "Введите запрос")
+ StateWrapperView(isLoading: viewModel.isLoading, errorType: viewModel.errorType) {
+ Group {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading) {
+ if viewModel.filteredCities.isEmpty {
+ VStack {
+ Text("Город не найден")
+ .font(.custom("SFPro-Bold", size: 24))
.foregroundStyle(Color("dayOrNightColor"))
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 176)
+ }
+ } else {
+ ForEach(viewModel.filteredCities, id: \.self) { item in
+ Button(action: {
+ if fromField {
+ coordinator.selectedCityFrom = item
+ coordinator.selectedStationFrom = ""
+ } else {
+ coordinator.selectedCityTo = item
+ coordinator.selectedStationTo = ""
+ }
+ coordinator.path.append(EnumAppRoute.stationPicker(city: item, fromField: fromField))
+ }) {
+ HStack {
+ Text("\(item)")
+ .font(.custom("SFPro-Regular", size: 17))
+ .foregroundStyle(Color("dayOrNightColor"))
+ Spacer()
+ Image("rightChevron")
+ .renderingMode(.template)
+ .foregroundStyle(Color("dayOrNightColor"))
+ }
+ .padding(.vertical, 19)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PlainButtonStyle())
}
- .padding(.vertical, 19)
- .contentShape(Rectangle())
}
- .buttonStyle(PlainButtonStyle())
}
}
+ .padding(.horizontal, 16)
}
}
- .padding(.horizontal, 16)
}
.navigationBarBackButtonHidden(true)
.toolbar {
@@ -82,7 +81,6 @@ struct ChangeCityView: View {
.foregroundStyle(Color("dayOrNightColor"))
}
}
-
ToolbarItem(placement: .principal) {
Text("Выбор города")
.font(.custom("SFPro-Bold", size: 17))
@@ -92,8 +90,3 @@ struct ChangeCityView: View {
}
}
}
-
-#Preview {
- ChangeCityView(coordinator: NavigationCoordinator(), fromField: true)
-}
-
diff --git a/ScheduleTracker/Views/ChangeStationView.swift b/ScheduleTracker/Views/ChangeStationView.swift
index 61b1f95..90c40e6 100644
--- a/ScheduleTracker/Views/ChangeStationView.swift
+++ b/ScheduleTracker/Views/ChangeStationView.swift
@@ -8,73 +8,78 @@
import SwiftUI
struct ChangeStationView: View {
- @State private var searchText = ""
- @Environment(\.dismiss) var dismiss
@ObservedObject var coordinator: NavigationCoordinator
let city: String
let fromField: Bool
+ let stationListService: StationListServiceProtocol
- let stations = [
- "Киевский вокзал", "Курский вокзал", "Ярославский вокзал",
- "Белорусский вокзал", "Савеловский вокзал", "Ленинградский вокзал"
- ]
+ @StateObject private var viewModel: StationSelectionViewModel
+ @Environment(\.dismiss) var dismiss
- var filteredItems: [String] {
- if searchText.isEmpty {
- return stations
- } else {
- return stations.filter { $0.localizedCaseInsensitiveContains(searchText) }
- }
+ init(
+ coordinator: NavigationCoordinator,
+ city: String,
+ fromField: Bool,
+ stationListService: StationListServiceProtocol
+ ) {
+ self.coordinator = coordinator
+ self.city = city
+ self.fromField = fromField
+ self.stationListService = stationListService
+ _viewModel = StateObject(wrappedValue: StationSelectionViewModel(stationListService: stationListService, city: city))
}
var body: some View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
VStack(spacing: 0) {
- CustomSearchBar(text: $searchText, placeholder: "Введите запрос")
-
- ScrollView(.vertical, showsIndicators: false) {
- LazyVStack(alignment: .leading) {
- if filteredItems.isEmpty {
- VStack {
- Text("Станция не найдена")
- .font(.custom("SFPro-Bold", size: 24))
- .foregroundStyle(Color("dayOrNightColor"))
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 176)
- }
- } else {
- ForEach(filteredItems, id: \.self) { item in
- Button(action: {
- if fromField {
- coordinator.selectedCityFrom = city
- coordinator.selectedStationFrom = item
- } else {
- coordinator.selectedCityTo = city
- coordinator.selectedStationTo = item
- }
- coordinator.path = NavigationPath()
- }) {
- HStack {
- Text("\(item)")
- .font(.custom("SFPro-Regular", size: 17))
- .foregroundStyle(Color("dayOrNightColor"))
-
- Spacer()
-
- Image("rightChevron")
- .renderingMode(.template)
+ CustomSearchBar(text: $viewModel.searchText, placeholder: "Введите запрос")
+ StateWrapperView(isLoading: viewModel.isLoading, errorType: viewModel.errorType) {
+ Group {
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading) {
+ if viewModel.filteredStations.isEmpty {
+ VStack {
+ Text("Станция не найдена")
+ .font(.custom("SFPro-Bold", size: 24))
.foregroundStyle(Color("dayOrNightColor"))
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 176)
+ }
+ } else {
+ ForEach(viewModel.filteredStations, id: \.id) { station in
+ Button(action: {
+ if fromField {
+ coordinator.selectedCityFrom = city
+ coordinator.selectedStationFrom = station.title
+ coordinator.selectedStationFromCode = station.id
+ } else {
+ coordinator.selectedCityTo = city
+ coordinator.selectedStationTo = station.title
+ coordinator.selectedStationToCode = station.id
+ }
+ coordinator.path = NavigationPath()
+ }) {
+ HStack {
+ Text(station.title)
+ .font(.custom("SFPro-Regular", size: 17))
+ .foregroundStyle(Color("dayOrNightColor"))
+ Spacer()
+ Image("rightChevron")
+ .renderingMode(.template)
+ .foregroundStyle(Color("dayOrNightColor"))
+ }
+ .padding(.vertical, 19)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(PlainButtonStyle())
}
- .padding(.vertical, 19)
- .contentShape(Rectangle())
}
- .buttonStyle(PlainButtonStyle())
}
+ .padding(.horizontal, 16)
}
}
- .padding(.horizontal, 16)
}
}
.navigationBarBackButtonHidden(true)
@@ -93,14 +98,7 @@ struct ChangeStationView: View {
}
}
}
+ .onAppear { viewModel.loadStations() }
}
}
-
-#Preview {
- ChangeStationView(
- coordinator: NavigationCoordinator(),
- city: "Москва",
- fromField: true
- )
-}
diff --git a/ScheduleTracker/Views/ContentView.swift b/ScheduleTracker/Views/ContentView.swift
deleted file mode 100644
index 6ce584f..0000000
--- a/ScheduleTracker/Views/ContentView.swift
+++ /dev/null
@@ -1,159 +0,0 @@
-////
-//// ContentView.swift
-//// ScheduleTracker
-////
-//// Created by Василий Ханин on 12.06.2025.
-////
-//
-//import SwiftUI
-//import OpenAPIURLSession
-//
-//struct ContentView: View {
-// var body: some View {
-// VStack {
-// Image(systemName: "bus")
-// .imageScale(.large)
-// .foregroundStyle(.tint)
-// Text("Расписание")
-// .font(.system(size: 30))
-// .fontWeight(.medium)
-// }
-// .padding()
-// .onAppear {
-// // Вызываем нашу тестовую функцию при появлении View
-//// testFetchStations()
-//// testCopyright()
-//// testSearchRoute()
-//// testScheduleStation()
-// testThreadService()
-//// testNearestSettlement()
-//// testCarrierInfo()
-//// testAllStations()
-// }
-// }
-// // Функция для тестового вызова API
-// func testFetchStations() {
-// // Создаём Task для выполнения асинхронного кода
-// Task {
-// do {
-// let service = try APIServicesContainer()
-//
-// // 3. Вызываем метод сервиса
-// print("Fetching stations...")
-// let stations = try await service.nearestStationsService.getNearestStations(
-// lat: 59.864177, // Пример координат
-// lng: 30.319163, // Пример координат
-// distance: 2 // Пример дистанции
-// )
-//
-// // 4. Если всё успешно, печатаем результат в консоль
-// print("Successfully fetched stations: \(stations)")
-// } catch {
-// // 5. Если произошла ошибка на любом из этапов (создание клиента, вызов сервиса, обработка ответа),
-// // она будет поймана здесь, и мы выведем её в консоль
-// print("Error fetching stations: \(error)")
-// // В реальном приложении здесь должна быть логика обработки ошибок (показ алерта и т. д.)
-// }
-// }
-// }
-//
-// func testCopyright() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let copyright = try await service.copyrightService.getCopyright()
-//
-// print("Successfully fetched copyright: \(copyright)")
-// }
-// catch {
-// print("Error fetching copyright: \(error)")
-// }
-// }
-// }
-//
-// func testSearchRoute() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let search = try await service.searchService.getSchedualBetweenStations(from: "s9600213", to: "c146")
-//
-// print("Successfully fetched search route: \(search)")
-// }
-// catch {
-// print("Error fetching search route: \(error)")
-// }
-// }
-// }
-//
-// func testScheduleStation() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let schedule = try await service.scheduleService.getStationSchedule(station: "s9600215")
-//
-// print("Successfully fetched station shedule: \(schedule)")
-// }
-// catch {
-// print("Error fetching station shedule: \(error)")
-// }
-// }
-// }
-//
-// func testThreadService() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let threadService = try await service.threadService.getRouteStations(uid: "A4-7071_251026_c60780_12")
-//
-// print("Succesfully fetched threadRouteStations: \(threadService)")
-// }
-// catch {
-// print("Error fetching threadRouteStations: \(error)")
-// }
-// }
-// }
-//
-// func testNearestSettlement() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let settlement = try await service.settlementService.getNearestCity(lat: 59.864177, lng: 30.319163)
-//
-// print("Successfully fetched settlement: \(settlement)")
-// } catch {
-// print("Error fetching settlement: \(error)")
-// }
-// }
-// }
-//
-// func testCarrierInfo() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let carrier = try await service.carrierService.getCarrierInfo(code: "60780")
-//
-// print("Successfully fetched carrier: \(carrier)")
-// } catch {
-// print("Error fetching carrier: \(error)")
-// }
-// }
-// }
-//
-// func testAllStations() {
-// Task {
-// do {
-// let service = try APIServicesContainer()
-// let stations = try await service.allStationService.getAllStations()
-//
-// print("Successfully fetched all stations: \(String(describing: stations.countries?.count))")
-// }
-// catch {
-// print("Error fetching all stations: \(error)")
-// }
-// }
-// }
-//}
-//
-//#Preview {
-// ContentView()
-//}
diff --git a/ScheduleTracker/Views/ErrorInternetView.swift b/ScheduleTracker/Views/ErrorInternetView.swift
index 05a90b1..92f8787 100644
--- a/ScheduleTracker/Views/ErrorInternetView.swift
+++ b/ScheduleTracker/Views/ErrorInternetView.swift
@@ -12,10 +12,12 @@ struct ErrorInternetView: View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
VStack (spacing: 16) {
+ Spacer()
Image("errorInternetImage")
Text("Нет интернета")
.font(.custom("SFPro-Bold", size: 24))
.foregroundStyle(Color("dayOrNightColor"))
+ Spacer()
}
}
}
diff --git a/ScheduleTracker/Views/ErrorServerView.swift b/ScheduleTracker/Views/ErrorServerView.swift
index 644fa21..bdc9b5b 100644
--- a/ScheduleTracker/Views/ErrorServerView.swift
+++ b/ScheduleTracker/Views/ErrorServerView.swift
@@ -11,7 +11,7 @@ struct ErrorServerView: View {
var body: some View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
- VStack (spacing: 16) {
+ VStack (alignment: .center, spacing: 16) {
Image("errorServerImage")
Text("Ошибка сервера")
.font(.custom("SFPro-Bold", size: 24))
diff --git a/ScheduleTracker/Views/MainView.swift b/ScheduleTracker/Views/MainView.swift
index fe43df6..0210eec 100644
--- a/ScheduleTracker/Views/MainView.swift
+++ b/ScheduleTracker/Views/MainView.swift
@@ -63,36 +63,18 @@ struct MainView: View {
HStack {
VStack(alignment: .leading, spacing: 0) {
NavigationLink(value: EnumAppRoute.cityPicker(fromField: true)) {
- Text(fromTofromTo ? (coordinator.selectedCityFrom.isEmpty ? "Откуда" : "\(coordinator.selectedCityFrom) (\(coordinator.selectedStationFrom))")
- : (coordinator.selectedCityTo.isEmpty ? "Куда" : "\(coordinator.selectedCityTo) (\(coordinator.selectedStationTo))")
- )
- .foregroundStyle(
- (fromTofromTo
- ? coordinator.selectedCityFrom.isEmpty
- : coordinator.selectedCityTo.isEmpty
- )
- ? Color("grayUniversal")
- : Color("blackUniversal")
- )
- .padding(.vertical, 14)
- .padding(.horizontal, 16)
- .frame(maxWidth: .infinity, alignment: .leading)
+ Text(coordinator.selectedCityFrom.isEmpty ? "Откуда" : "\(coordinator.selectedCityFrom) (\(coordinator.selectedStationFrom))")
+ .foregroundStyle(coordinator.selectedCityFrom.isEmpty ? Color("grayUniversal") : Color("blackUniversal"))
+ .padding(.vertical, 14)
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
NavigationLink(value: EnumAppRoute.cityPicker(fromField: false)) {
- Text(fromTofromTo ? (coordinator.selectedCityTo.isEmpty ? "Куда" : "\(coordinator.selectedCityTo) (\(coordinator.selectedStationTo))")
- : (coordinator.selectedCityFrom.isEmpty ? "Откуда" : "\(coordinator.selectedCityFrom) (\(coordinator.selectedStationFrom))")
- )
- .foregroundStyle(
- (fromTofromTo
- ? coordinator.selectedCityTo.isEmpty
- : coordinator.selectedCityFrom.isEmpty
- )
- ? Color("grayUniversal")
- : Color("blackUniversal")
- )
- .padding(.vertical, 14)
- .padding(.horizontal, 16)
- .frame(maxWidth: .infinity, alignment: .leading)
+ Text(coordinator.selectedCityTo.isEmpty ? "Куда" : "\(coordinator.selectedCityTo) (\(coordinator.selectedStationTo))")
+ .foregroundStyle(coordinator.selectedCityTo.isEmpty ? Color("grayUniversal") : Color("blackUniversal"))
+ .padding(.vertical, 14)
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(
@@ -101,15 +83,18 @@ struct MainView: View {
)
.padding(.horizontal, 16)
- Button(action: { fromTofromTo.toggle() }) {
- Image(systemName: "arrow.2.squarepath")
- .font(.system(size: 24))
- .foregroundStyle(Color("blueUniversal"))
- .padding(6)
- .background(.white)
- .clipShape(Circle())
- }
- .padding(.trailing, 16)
+ Button(action: {
+ swap(&coordinator.selectedCityFrom, &coordinator.selectedCityTo)
+ swap(&coordinator.selectedStationFrom, &coordinator.selectedStationTo)
+ swap(&coordinator.selectedStationFromCode, &coordinator.selectedStationToCode) }) {
+ Image(systemName: "arrow.2.squarepath")
+ .font(.system(size: 24))
+ .foregroundStyle(Color("blueUniversal"))
+ .padding(6)
+ .background(.white)
+ .clipShape(Circle())
+ }
+ .padding(.trailing, 16)
}
.padding(.vertical, 16)
}
diff --git a/ScheduleTracker/Views/SettingsView.swift b/ScheduleTracker/Views/SettingsView.swift
index e958f1f..2c8e9f5 100644
--- a/ScheduleTracker/Views/SettingsView.swift
+++ b/ScheduleTracker/Views/SettingsView.swift
@@ -8,13 +8,12 @@
import SwiftUI
struct SettingsView: View {
-
@AppStorage("isDarkMode") private var isDarkMode = false
-
+ @StateObject var viewModel: SettingsViewModel
+
var body: some View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
-
VStack {
Toggle(isOn: $isDarkMode) {
Text("Темная тема")
@@ -29,9 +28,7 @@ struct SettingsView: View {
Text("Пользовательское соглашение")
.font(.custom("SFPro-Regular", size: 17))
.foregroundStyle(Color("dayOrNightColor"))
-
Spacer()
-
Image("rightChevron")
.renderingMode(.template)
.foregroundStyle(Color("dayOrNightColor"))
@@ -41,12 +38,24 @@ struct SettingsView: View {
Spacer()
- Text("Приложение использует API «Яндекс.Расписания»\nВерсия 1.0 (beta)")
- .multilineTextAlignment(.center)
- .lineSpacing(16)
- .font(.custom("SFPro-Regular", size: 12))
- .foregroundStyle(Color("dayOrNightColor"))
-
+ StateWrapperView(isLoading: viewModel.isLoading, errorType: viewModel.errorType) {
+ Group {
+ if !viewModel.copyrightText.isEmpty {
+ Text(viewModel.copyrightText)
+ .multilineTextAlignment(.center)
+ .lineSpacing(16)
+ .font(.custom("SFPro-Regular", size: 12))
+ .foregroundStyle(Color("dayOrNightColor"))
+ } else {
+ Text("Приложение использует API «Яндекс.Расписания»\nВерсия 1.0 (beta)")
+ .lineLimit(2)
+ .multilineTextAlignment(.center)
+ .lineSpacing(16)
+ .font(.custom("SFPro-Regular", size: 12))
+ .foregroundStyle(Color("dayOrNightColor"))
+ }
+ }
+ }
Divider()
.frame(height: 3)
.padding(.top, 24)
@@ -54,9 +63,8 @@ struct SettingsView: View {
.padding(.horizontal, 16)
.padding(.top, 24)
}
+ .task {
+ await viewModel.fetchCopyright()
+ }
}
}
-
-#Preview {
- SettingsView()
-}
diff --git a/ScheduleTracker/Views/StoriesScreenView.swift b/ScheduleTracker/Views/StoriesScreenView.swift
index c793ff3..5746a43 100644
--- a/ScheduleTracker/Views/StoriesScreenView.swift
+++ b/ScheduleTracker/Views/StoriesScreenView.swift
@@ -123,7 +123,3 @@ struct StoriesScreenView: View {
Timer.publish(every: configuration.timerTickInternal, on: .main, in: .common)
}
}
-
-//#Preview {
-// StoriesScreenView()
-//}
diff --git a/ScheduleTracker/Views/TicketCell.swift b/ScheduleTracker/Views/TicketCell.swift
index 10f19af..1074781 100644
--- a/ScheduleTracker/Views/TicketCell.swift
+++ b/ScheduleTracker/Views/TicketCell.swift
@@ -14,24 +14,49 @@ struct TicketCell: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
- Image(ticket.operatorLogo)
- .resizable()
- .frame(width: 38, height: 38)
+ if let url = URL(string: ticket.operatorLogo) {
+ AsyncImage(url: url) { phase in
+ switch phase {
+ case .empty:
+ ProgressView()
+ case .success(let image):
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ case .failure:
+ Image(systemName: "photo.on.rectangle")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 38, height: 38)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ @unknown default:
+ EmptyView()
+ }
+ }
+ .frame(width: 38, height: 38, alignment: .center)
+ .background(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
+ .clipped()
+ }
+
VStack(alignment: .leading, spacing: 2) {
- Text(ticket.operatorName)
- .font(.custom("SFPro-Regular", size: 17))
- .foregroundStyle(Color("blackUniversal"))
- if let note = ticket.note {
- Text(note)
+ HStack {
+ Text(ticket.operatorName)
+ .font(.custom("SFPro-Regular", size: 17))
+ .foregroundStyle(Color("blackUniversal"))
+
+ Spacer()
+ Text(ticket.date)
+ .font(.custom("SFPro-Regular", size: 12))
+ .foregroundStyle(Color("blackUniversal"))
+ }
+ if ticket.withTransfer {
+ Text("С пересадкой в \(String(describing: ticket.transfer))")
.font(.custom("SFPro-Regular", size: 17))
.foregroundStyle(Color("redUniversal"))
}
}
- Spacer()
- Text(ticket.date)
- .font(.custom("SFPro-Regular", size: 12))
- .foregroundStyle(Color("blackUniversal"))
+
}
.padding(.bottom, 5)
HStack {
@@ -56,5 +81,6 @@ struct TicketCell: View {
.background(Color(.lightGrayUniversal))
.clipShape(RoundedRectangle(cornerRadius: 24))
}
+
}
diff --git a/ScheduleTracker/Views/TicketListView.swift b/ScheduleTracker/Views/TicketListView.swift
index 6a4b61c..04be2e1 100644
--- a/ScheduleTracker/Views/TicketListView.swift
+++ b/ScheduleTracker/Views/TicketListView.swift
@@ -10,45 +10,13 @@ import SwiftUI
struct TicketListView: View {
@ObservedObject var coordinator: NavigationCoordinator
+ @ObservedObject var viewModel: TicketListViewModel
@Environment(\.dismiss) var dismiss
- let tickets: [TicketModel] = [
- .init(operatorName: "РЖД", date: "14 января", departure: "22:30", arrival: "08:15", duration: "20 часов", withTransfer: true, operatorLogo: "RJDImage", note: "С пересадкой в Костроме"),
- .init(operatorName: "ФГК", date: "15 января", departure: "01:15", arrival: "09:00", duration: "9 часов", withTransfer: false, operatorLogo: "FGKImage", note: nil),
- .init(operatorName: "Урал логистика", date: "16 января", departure: "12:30", arrival: "21:00", duration: "9 часов", withTransfer: false, operatorLogo: "URALImage", note: nil),
- .init(operatorName: "РЖД", date: "17 января", departure: "22:30", arrival: "08:15", duration: "20 часов", withTransfer: true, operatorLogo: "RJDImage", note: "С пересадкой в Костроме"),
- .init(operatorName: "РЖД", date: "17 января", departure: "22:30", arrival: "08:15", duration: "20 часов", withTransfer: false, operatorLogo: "RJDImage", note: nil),
- .init(operatorName: "РЖД", date: "17 января", departure: "22:30", arrival: "08:15", duration: "20 часов", withTransfer: false, operatorLogo: "RJDImage", note: nil),
- .init(operatorName: "РЖД", date: "17 января", departure: "22:30", arrival: "08:15", duration: "20 часов", withTransfer: false, operatorLogo: "RJDImage", note: nil)
- ]
-
-
- var filteredTickets: [TicketModel] {
- tickets.filter { ticket in
- // Фильтр по пересадкам
- if let show = coordinator.showTransfers, show != ticket.withTransfer { return false }
- // Фильтр по времени
- if !coordinator.timeFilters.isEmpty {
- let depHour = Int(ticket.departure.prefix(2)) ?? 0
- let period: TimePeriod = {
- switch depHour {
- case 6..<12: return .morning
- case 12..<18: return .day
- case 18..<24: return .evening
- default: return .night
- }
- }()
- if !coordinator.timeFilters.contains(period) { return false }
- }
- return true
- }
- }
-
var body: some View {
ZStack {
Color("nightOrDayColor").ignoresSafeArea()
VStack(spacing: 16) {
- // Заголовок
HStack {
Text("\(coordinator.selectedCityFrom) (\(coordinator.selectedStationFrom)) → \(coordinator.selectedCityTo) (\(coordinator.selectedStationTo))")
.font(.custom("SFPro-Bold", size: 24))
@@ -56,38 +24,38 @@ struct TicketListView: View {
.multilineTextAlignment(.leading)
Spacer()
}
- // Расписание
ZStack(alignment: .bottom) {
-
ScrollView {
LazyVStack(spacing: 8) {
- if filteredTickets.isEmpty {
- VStack {
- Text("Вариантов нет")
- .font(.custom("SFPro-Bold", size: 24))
- .foregroundStyle(Color("dayOrNightColor"))
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- .padding(.vertical, 231)
- }
- } else {
- ForEach(filteredTickets) { ticket in
- Button(action: {
- coordinator.path.append(EnumAppRoute.carrierInfo(ticket))
- }) {
- TicketCell(ticket: ticket)
+ StateWrapperView(isLoading: viewModel.isLoading, errorType: viewModel.errorType) {
+ Group {
+ if viewModel.filteredTickets.isEmpty {
+ VStack {
+ Text("Вариантов нет")
+ .font(.custom("SFPro-Bold", size: 24))
+ .foregroundStyle(Color("dayOrNightColor"))
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 231)
+ }
+ } else {
+ ForEach(viewModel.filteredTickets) { ticket in
+ Button(action: {
+ coordinator.path.append(EnumAppRoute.carrierInfo(ticket))
+ }) {
+ TicketCell(ticket: ticket)
+ }
+ }
}
}
}
}
}
-
- Button(action: {coordinator.path.append(EnumAppRoute.filters)}) {
+ Button(action: { coordinator.path.append(EnumAppRoute.filters) }) {
HStack(spacing: 4) {
Text("Уточнить время")
.font(.custom("SFPro-Bold", size: 17))
.foregroundStyle(Color(.white))
-
if coordinator.isFiltersValid {
Circle()
.foregroundStyle(Color("redUniversal"))
@@ -114,6 +82,9 @@ struct TicketListView: View {
}
}
}
+ .task {
+ await viewModel.loadTickets()
+ }
}
}
diff --git a/ScheduleTracker/YAML/openapi.yaml b/ScheduleTracker/YAML/openapi.yaml
index c282463..73200fc 100644
--- a/ScheduleTracker/YAML/openapi.yaml
+++ b/ScheduleTracker/YAML/openapi.yaml
@@ -583,6 +583,15 @@ components:
type: string
has_transfers:
type: boolean
+ duration:
+ type: number
+ description: Длительность в секундах
+ departure:
+ type: string
+ description: Время отправления (HH:mm:ss или ISO 8601)
+ start_date:
+ type: string
+ description: Дата отправления (yyyy-MM-dd)
tickets_info:
type: object
properties:
@@ -732,6 +741,15 @@ components:
type: string
has_transfers:
type: boolean
+ duration:
+ type: number
+ description: Длительность в секундах
+ departure:
+ type: string
+ description: Время отправления (HH:mm:ss или ISO 8601)
+ start_date:
+ type: string
+ description: Дата отправления (yyyy-MM-dd)
tickets_info:
type: object
properties: