From 0dce1bb47cd865fec00e13ac05f8a4db13801219 Mon Sep 17 00:00:00 2001 From: Vasiliy Khanin Date: Thu, 24 Jul 2025 22:48:09 +0300 Subject: [PATCH 1/3] feat(allProject): add mvvm, network services --- ScheduleTracker.xcodeproj/project.pbxproj | 2 + .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../API/APIServicesContainer.swift | 3 +- ScheduleTracker/App/ScheduleTrackerApp.swift | 2 + ScheduleTracker/Helpers/AppErrorType.swift | 35 ++++ .../NavigationCoordinator.swift | 4 +- ScheduleTracker/Models/CarrierInfoModel.swift | 18 ++ ScheduleTracker/Models/StationModel.swift | 12 ++ ScheduleTracker/Models/TicketModel.swift | 1 + .../Services/CarrierServiceProtocol.swift | 2 +- .../Services/CopyrightServiceProtocol.swift | 2 +- .../NearestSettlementServiceProtocol.swift | 2 +- .../NearestStationsServiceProtocol.swift | 2 +- .../Services/ScheduleServiceProtocol.swift | 2 +- .../Services/SearchServiceProtocol.swift | 6 +- .../Services/StationListServiceProtocol.swift | 2 +- .../Services/ThreadServiceProtocol.swift | 2 +- .../ViewModels/CarrierInfoViewModel.swift | 46 +++++ .../ViewModels/CitySelectionViewModel.swift | 70 ++++++++ .../ViewModels/SettingsViewModel.swift | 40 +++++ .../StationSelectionViewModel.swift | 78 +++++++++ .../ViewModels/TicketListViewModel.swift | 133 +++++++++++++++ ScheduleTracker/Views/BaseView.swift | 33 +++- ScheduleTracker/Views/CarrierInfoView.swift | 143 +++++++++------- ScheduleTracker/Views/ChangeCityView.swift | 109 ++++++------ ScheduleTracker/Views/ChangeStationView.swift | 122 +++++++------- ScheduleTracker/Views/ContentView.swift | 159 ------------------ ScheduleTracker/Views/MainView.swift | 59 +++---- ScheduleTracker/Views/SettingsView.swift | 44 +++-- ScheduleTracker/Views/StoriesScreenView.swift | 4 - ScheduleTracker/Views/TicketCell.swift | 27 ++- ScheduleTracker/Views/TicketListView.swift | 56 ++---- ScheduleTracker/YAML/openapi.yaml | 18 ++ 33 files changed, 791 insertions(+), 452 deletions(-) create mode 100644 ScheduleTracker.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 ScheduleTracker/Helpers/AppErrorType.swift rename ScheduleTracker/{ViewModels => Helpers}/NavigationCoordinator.swift (84%) create mode 100644 ScheduleTracker/Models/CarrierInfoModel.swift create mode 100644 ScheduleTracker/Models/StationModel.swift create mode 100644 ScheduleTracker/ViewModels/CarrierInfoViewModel.swift create mode 100644 ScheduleTracker/ViewModels/CitySelectionViewModel.swift create mode 100644 ScheduleTracker/ViewModels/SettingsViewModel.swift create mode 100644 ScheduleTracker/ViewModels/StationSelectionViewModel.swift create mode 100644 ScheduleTracker/ViewModels/TicketListViewModel.swift delete mode 100644 ScheduleTracker/Views/ContentView.swift 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..3417f75 100644 --- a/ScheduleTracker/App/ScheduleTrackerApp.swift +++ b/ScheduleTracker/App/ScheduleTrackerApp.swift @@ -9,11 +9,13 @@ import SwiftUI @main struct ScheduleTrackerApp: App { + @StateObject private var services = try! APIServicesContainer() @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 84% rename from ScheduleTracker/ViewModels/NavigationCoordinator.swift rename to ScheduleTracker/Helpers/NavigationCoordinator.swift index 49adfe0..ad5e1d2 100644 --- a/ScheduleTracker/ViewModels/NavigationCoordinator.swift +++ b/ScheduleTracker/Helpers/NavigationCoordinator.swift @@ -16,7 +16,9 @@ final class NavigationCoordinator: ObservableObject { @Published var selectedStationTo: String = "" @Published var timeFilters: Set = [] @Published var showTransfers: Bool? = nil - + @Published var selectedStationFromCode: String = "" + @Published var selectedStationToCode: String = "" + var isFiltersValid: Bool { !timeFilters.isEmpty && showTransfers != nil } diff --git a/ScheduleTracker/Models/CarrierInfoModel.swift b/ScheduleTracker/Models/CarrierInfoModel.swift new file mode 100644 index 0000000..c6dfbbf --- /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 { + 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..cd65c1d --- /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, Hashable { + let id: String + let title: String +} diff --git a/ScheduleTracker/Models/TicketModel.swift b/ScheduleTracker/Models/TicketModel.swift index 96c1103..814af54 100644 --- a/ScheduleTracker/Models/TicketModel.swift +++ b/ScheduleTracker/Models/TicketModel.swift @@ -17,4 +17,5 @@ struct TicketModel: Hashable, Identifiable { let withTransfer: Bool let operatorLogo: String let note: String? + let carrierCode: Int? } diff --git a/ScheduleTracker/Services/CarrierServiceProtocol.swift b/ScheduleTracker/Services/CarrierServiceProtocol.swift index d778d82..9c54928 100644 --- a/ScheduleTracker/Services/CarrierServiceProtocol.swift +++ b/ScheduleTracker/Services/CarrierServiceProtocol.swift @@ -10,7 +10,7 @@ import OpenAPIURLSession typealias Carrier = Components.Schemas.CarrierResponse -protocol CarrierServiceProtocol { +@preconcurrency protocol CarrierServiceProtocol { func getCarrierInfo(code: String) async throws -> Carrier } diff --git a/ScheduleTracker/Services/CopyrightServiceProtocol.swift b/ScheduleTracker/Services/CopyrightServiceProtocol.swift index 0c7775a..f46f8d4 100644 --- a/ScheduleTracker/Services/CopyrightServiceProtocol.swift +++ b/ScheduleTracker/Services/CopyrightServiceProtocol.swift @@ -10,7 +10,7 @@ import OpenAPIURLSession typealias Copyright = Components.Schemas.Copyright -protocol CopyrightServiceProtocol { +@preconcurrency protocol CopyrightServiceProtocol { func getCopyright() async throws -> Copyright } diff --git a/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift b/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift index 1cafc4d..c5134c9 100644 --- a/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift +++ b/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift @@ -10,7 +10,7 @@ import OpenAPIURLSession typealias Settlement = Components.Schemas.Settlement -protocol NearestSettlementServiceProtocol { +@preconcurrency protocol NearestSettlementServiceProtocol { func getNearestCity(lat: Double, lng: Double) async throws -> Settlement } diff --git a/ScheduleTracker/Services/NearestStationsServiceProtocol.swift b/ScheduleTracker/Services/NearestStationsServiceProtocol.swift index b60c76b..3a38cff 100644 --- a/ScheduleTracker/Services/NearestStationsServiceProtocol.swift +++ b/ScheduleTracker/Services/NearestStationsServiceProtocol.swift @@ -17,7 +17,7 @@ import OpenAPIURLSession typealias NearestStations = Components.Schemas.Stations // Определяем протокол для нашего сервиса (хорошая практика для тестирования и гибкости) -protocol NearestStationsServiceProtocol { +@preconcurrency protocol NearestStationsServiceProtocol { // Функция для получения станций, асинхронная и может выбросить ошибку func getNearestStations(lat: Double, lng: Double, distance: Int) async throws -> NearestStations } diff --git a/ScheduleTracker/Services/ScheduleServiceProtocol.swift b/ScheduleTracker/Services/ScheduleServiceProtocol.swift index 7fb584d..79bf8db 100644 --- a/ScheduleTracker/Services/ScheduleServiceProtocol.swift +++ b/ScheduleTracker/Services/ScheduleServiceProtocol.swift @@ -10,7 +10,7 @@ import OpenAPIURLSession typealias Schedule = Components.Schemas.ScheduleSchema -protocol ScheduleServiceProtocol { +@preconcurrency protocol ScheduleServiceProtocol { func getStationSchedule(station: String) async throws -> Schedule } diff --git a/ScheduleTracker/Services/SearchServiceProtocol.swift b/ScheduleTracker/Services/SearchServiceProtocol.swift index 3f3b447..08b70b5 100644 --- a/ScheduleTracker/Services/SearchServiceProtocol.swift +++ b/ScheduleTracker/Services/SearchServiceProtocol.swift @@ -10,8 +10,8 @@ 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 { @@ -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..6209cc5 100644 --- a/ScheduleTracker/Services/StationListServiceProtocol.swift +++ b/ScheduleTracker/Services/StationListServiceProtocol.swift @@ -11,7 +11,7 @@ import Foundation typealias AllStation = Components.Schemas.StationsList -protocol StationListServiceProtocol { +@preconcurrency protocol StationListServiceProtocol { func getAllStations() async throws -> AllStation } diff --git a/ScheduleTracker/Services/ThreadServiceProtocol.swift b/ScheduleTracker/Services/ThreadServiceProtocol.swift index dbd924e..306ecb1 100644 --- a/ScheduleTracker/Services/ThreadServiceProtocol.swift +++ b/ScheduleTracker/Services/ThreadServiceProtocol.swift @@ -10,7 +10,7 @@ import OpenAPIURLSession typealias Thread = Components.Schemas.Thread -protocol ThreadServiceProtocol { +@preconcurrency protocol ThreadServiceProtocol { func getRouteStations(uid: String) async throws -> Thread } 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..64386dd --- /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? = nil + @Published var timeFilters: Set = [] + + private let searchService: SearchServiceProtocol + let fromStation: String + let toStation: String + + init( + searchService: SearchServiceProtocol, + fromStation: String, + toStation: String, + showTransfers: Bool? = nil, + 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 ?? "", + note: 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..e554499 100644 --- a/ScheduleTracker/Views/CarrierInfoView.swift +++ b/ScheduleTracker/Views/CarrierInfoView.swift @@ -7,85 +7,104 @@ import SwiftUI - struct CarrierInfoView: View { @Environment(\.dismiss) var dismiss - let carrier: TicketModel + @StateObject 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)) + if viewModel.isLoading { + ProgressView() + } + else if viewModel.errorType == .internet { + ErrorInternetView() + } else if viewModel.errorType == .server { + ErrorServerView() + } else 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")) - .padding(.top, 12) - if let mailURL = URL(string: "mailto:i.lozgkina@yandex.ru") { - Link("i.lozgkina@yandex.ru", destination: mailURL) + if let email = carrier.email { + Text("E-mail") .font(.custom("SFPro-Regular", size: 16)) - .foregroundColor(.blue) - .padding(.bottom, 12) + .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) + } } - // телефон - 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) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 16) + Spacer() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 16) - Spacer() + .padding() } - .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..cd9cb93 100644 --- a/ScheduleTracker/Views/ChangeCityView.swift +++ b/ScheduleTracker/Views/ChangeCityView.swift @@ -8,70 +8,75 @@ 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) - .foregroundStyle(Color("dayOrNightColor")) + CustomSearchBar(text: $viewModel.searchText, placeholder: "Введите запрос") + if viewModel.isLoading { + Spacer() + ProgressView() + Spacer() + } else if viewModel.errorType == .internet { + ErrorInternetView() + } else if viewModel.errorType == .server { + ErrorServerView() + } else { + 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()) } - .padding(.vertical, 19) - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } .navigationBarBackButtonHidden(true) .toolbar { @@ -82,7 +87,6 @@ struct ChangeCityView: View { .foregroundStyle(Color("dayOrNightColor")) } } - ToolbarItem(placement: .principal) { Text("Выбор города") .font(.custom("SFPro-Bold", size: 17)) @@ -92,8 +96,3 @@ struct ChangeCityView: View { } } } - -#Preview { - ChangeCityView(coordinator: NavigationCoordinator(), fromField: true) -} - diff --git a/ScheduleTracker/Views/ChangeStationView.swift b/ScheduleTracker/Views/ChangeStationView.swift index 61b1f95..966110e 100644 --- a/ScheduleTracker/Views/ChangeStationView.swift +++ b/ScheduleTracker/Views/ChangeStationView.swift @@ -8,73 +8,84 @@ 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) - .foregroundStyle(Color("dayOrNightColor")) + CustomSearchBar(text: $viewModel.searchText, placeholder: "Введите запрос") + if viewModel.isLoading { + Spacer() + ProgressView() + Spacer() + } else if viewModel.errorType == .internet { + ErrorInternetView() + } else if viewModel.errorType == .server { + ErrorServerView() + } else { + 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()) } - .padding(.vertical, 19) - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } } .navigationBarBackButtonHidden(true) @@ -93,14 +104,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/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..8d062ba 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,30 @@ struct SettingsView: View { Spacer() - Text("Приложение использует API «Яндекс.Расписания»\nВерсия 1.0 (beta)") - .multilineTextAlignment(.center) - .lineSpacing(16) - .font(.custom("SFPro-Regular", size: 12)) - .foregroundStyle(Color("dayOrNightColor")) - + if viewModel.isLoading { + Spacer() + ProgressView() + Spacer() + } else if viewModel.errorType == .internet { + ErrorInternetView() + } else if viewModel.errorType == .server { + ErrorServerView() + } else { + 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 +69,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..27e531c 100644 --- a/ScheduleTracker/Views/TicketCell.swift +++ b/ScheduleTracker/Views/TicketCell.swift @@ -14,10 +14,30 @@ 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: .fill) + case .failure: + Image(systemName: "photo") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 38, height: 38) + .clipShape(RoundedRectangle(cornerRadius: 12)) + @unknown default: + EmptyView() + } + } + .frame(width: 220, height: 38, alignment: .leading) .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipped() + } + VStack(alignment: .leading, spacing: 2) { Text(ticket.operatorName) .font(.custom("SFPro-Regular", size: 17)) @@ -56,5 +76,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..69a2cba 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,12 +24,17 @@ struct TicketListView: View { .multilineTextAlignment(.leading) Spacer() } - // Расписание ZStack(alignment: .bottom) { - ScrollView { LazyVStack(spacing: 8) { - if filteredTickets.isEmpty { + if viewModel.isLoading { + ProgressView() + .padding(.top, 100) + } else if viewModel.errorType == .internet { + ErrorInternetView() + } else if viewModel.errorType == .server { + ErrorServerView() + } else if viewModel.filteredTickets.isEmpty { VStack { Text("Вариантов нет") .font(.custom("SFPro-Bold", size: 24)) @@ -71,7 +44,7 @@ struct TicketListView: View { .padding(.vertical, 231) } } else { - ForEach(filteredTickets) { ticket in + ForEach(viewModel.filteredTickets) { ticket in Button(action: { coordinator.path.append(EnumAppRoute.carrierInfo(ticket)) }) { @@ -81,13 +54,11 @@ struct TicketListView: View { } } } - - 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 +85,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: From bce9aa21d639987a5c4dc5ebb758fd93ae149f1d Mon Sep 17 00:00:00 2001 From: Vasiliy Khanin Date: Fri, 25 Jul 2025 23:51:07 +0300 Subject: [PATCH 2/3] fix(services) add actor for services feat(StateWrapperView) add StateWrapperView in app --- ScheduleTracker/App/ScheduleTrackerApp.swift | 10 +- .../Helpers/NavigationCoordinator.swift | 2 +- .../Helpers/StateWrapperView.swift | 31 +++++ .../Services/CarrierServiceProtocol.swift | 2 +- .../Services/CopyrightServiceProtocol.swift | 2 +- .../NearestSettlementServiceProtocol.swift | 2 +- .../NearestStationsServiceProtocol.swift | 2 +- .../Services/ScheduleServiceProtocol.swift | 2 +- .../Services/SearchServiceProtocol.swift | 2 +- .../Services/StationListServiceProtocol.swift | 2 +- .../Services/ThreadServiceProtocol.swift | 2 +- .../ViewModels/TicketListViewModel.swift | 4 +- ScheduleTracker/Views/CarrierInfoView.swift | 119 +++++++++--------- ScheduleTracker/Views/ChangeCityView.swift | 82 ++++++------ ScheduleTracker/Views/ChangeStationView.swift | 86 ++++++------- ScheduleTracker/Views/SettingsView.swift | 38 +++--- ScheduleTracker/Views/TicketListView.swift | 100 +++++++-------- 17 files changed, 251 insertions(+), 237 deletions(-) create mode 100644 ScheduleTracker/Helpers/StateWrapperView.swift diff --git a/ScheduleTracker/App/ScheduleTrackerApp.swift b/ScheduleTracker/App/ScheduleTrackerApp.swift index 3417f75..cb5cd81 100644 --- a/ScheduleTracker/App/ScheduleTrackerApp.swift +++ b/ScheduleTracker/App/ScheduleTrackerApp.swift @@ -9,7 +9,15 @@ import SwiftUI @main struct ScheduleTrackerApp: App { - @StateObject private var services = try! APIServicesContainer() + 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 { diff --git a/ScheduleTracker/Helpers/NavigationCoordinator.swift b/ScheduleTracker/Helpers/NavigationCoordinator.swift index ad5e1d2..6996a28 100644 --- a/ScheduleTracker/Helpers/NavigationCoordinator.swift +++ b/ScheduleTracker/Helpers/NavigationCoordinator.swift @@ -15,7 +15,7 @@ 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 = "" 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/Services/CarrierServiceProtocol.swift b/ScheduleTracker/Services/CarrierServiceProtocol.swift index 9c54928..e7aee24 100644 --- a/ScheduleTracker/Services/CarrierServiceProtocol.swift +++ b/ScheduleTracker/Services/CarrierServiceProtocol.swift @@ -14,7 +14,7 @@ typealias Carrier = Components.Schemas.CarrierResponse 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 f46f8d4..dd23898 100644 --- a/ScheduleTracker/Services/CopyrightServiceProtocol.swift +++ b/ScheduleTracker/Services/CopyrightServiceProtocol.swift @@ -14,7 +14,7 @@ typealias Copyright = Components.Schemas.Copyright 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 c5134c9..160dff4 100644 --- a/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift +++ b/ScheduleTracker/Services/NearestSettlementServiceProtocol.swift @@ -14,7 +14,7 @@ typealias Settlement = Components.Schemas.Settlement 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 3a38cff..b2c7eaf 100644 --- a/ScheduleTracker/Services/NearestStationsServiceProtocol.swift +++ b/ScheduleTracker/Services/NearestStationsServiceProtocol.swift @@ -23,7 +23,7 @@ typealias NearestStations = Components.Schemas.Stations } // Конкретная реализация сервиса -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 79bf8db..e8e99c7 100644 --- a/ScheduleTracker/Services/ScheduleServiceProtocol.swift +++ b/ScheduleTracker/Services/ScheduleServiceProtocol.swift @@ -14,7 +14,7 @@ typealias Schedule = Components.Schemas.ScheduleSchema 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 08b70b5..f2b677a 100644 --- a/ScheduleTracker/Services/SearchServiceProtocol.swift +++ b/ScheduleTracker/Services/SearchServiceProtocol.swift @@ -14,7 +14,7 @@ typealias Search = Components.Schemas.SearchSchema func getScheduleBetweenStations(from: String, to: String) async throws -> Search } -final class SearchService: SearchServiceProtocol { +actor SearchService: SearchServiceProtocol { private let client: Client private let apikey: String diff --git a/ScheduleTracker/Services/StationListServiceProtocol.swift b/ScheduleTracker/Services/StationListServiceProtocol.swift index 6209cc5..677e153 100644 --- a/ScheduleTracker/Services/StationListServiceProtocol.swift +++ b/ScheduleTracker/Services/StationListServiceProtocol.swift @@ -15,7 +15,7 @@ typealias AllStation = Components.Schemas.StationsList 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 306ecb1..6095f03 100644 --- a/ScheduleTracker/Services/ThreadServiceProtocol.swift +++ b/ScheduleTracker/Services/ThreadServiceProtocol.swift @@ -14,7 +14,7 @@ typealias Thread = Components.Schemas.Thread 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/TicketListViewModel.swift b/ScheduleTracker/ViewModels/TicketListViewModel.swift index 64386dd..7ded77d 100644 --- a/ScheduleTracker/ViewModels/TicketListViewModel.swift +++ b/ScheduleTracker/ViewModels/TicketListViewModel.swift @@ -13,7 +13,7 @@ final class TicketListViewModel: ObservableObject, ErrorHandleable { @Published var tickets: [TicketModel] = [] @Published var isLoading = false @Published var errorType: AppErrorType = .none - @Published var showTransfers: Bool? = nil + @Published var showTransfers: Bool? @Published var timeFilters: Set = [] private let searchService: SearchServiceProtocol @@ -24,7 +24,7 @@ final class TicketListViewModel: ObservableObject, ErrorHandleable { searchService: SearchServiceProtocol, fromStation: String, toStation: String, - showTransfers: Bool? = nil, + showTransfers: Bool?, timeFilters: Set = [] ) { self.searchService = searchService diff --git a/ScheduleTracker/Views/CarrierInfoView.swift b/ScheduleTracker/Views/CarrierInfoView.swift index e554499..594ed22 100644 --- a/ScheduleTracker/Views/CarrierInfoView.swift +++ b/ScheduleTracker/Views/CarrierInfoView.swift @@ -9,77 +9,74 @@ import SwiftUI struct CarrierInfoView: View { @Environment(\.dismiss) var dismiss - @StateObject var viewModel: CarrierInfoViewModel + @ObservedObject var viewModel: CarrierInfoViewModel let code: String var body: some View { ZStack { Color("nightOrDayColor").ignoresSafeArea() - if viewModel.isLoading { - ProgressView() - } - else if viewModel.errorType == .internet { - ErrorInternetView() - } else if viewModel.errorType == .server { - ErrorServerView() - } else 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() + 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() } - } - .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) + 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() } + .padding() } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 16) - Spacer() } - .padding() } } .navigationBarBackButtonHidden(true) diff --git a/ScheduleTracker/Views/ChangeCityView.swift b/ScheduleTracker/Views/ChangeCityView.swift index cd9cb93..b871749 100644 --- a/ScheduleTracker/Views/ChangeCityView.swift +++ b/ScheduleTracker/Views/ChangeCityView.swift @@ -26,56 +26,50 @@ struct ChangeCityView: View { Color("nightOrDayColor").ignoresSafeArea() VStack(spacing: 0) { CustomSearchBar(text: $viewModel.searchText, placeholder: "Введите запрос") - if viewModel.isLoading { - Spacer() - ProgressView() - Spacer() - } else if viewModel.errorType == .internet { - ErrorInternetView() - } else if viewModel.errorType == .server { - ErrorServerView() - } else { - 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")) + 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()) } - .padding(.vertical, 19) - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } } .navigationBarBackButtonHidden(true) diff --git a/ScheduleTracker/Views/ChangeStationView.swift b/ScheduleTracker/Views/ChangeStationView.swift index 966110e..90c40e6 100644 --- a/ScheduleTracker/Views/ChangeStationView.swift +++ b/ScheduleTracker/Views/ChangeStationView.swift @@ -34,57 +34,51 @@ struct ChangeStationView: View { Color("nightOrDayColor").ignoresSafeArea() VStack(spacing: 0) { CustomSearchBar(text: $viewModel.searchText, placeholder: "Введите запрос") - if viewModel.isLoading { - Spacer() - ProgressView() - Spacer() - } else if viewModel.errorType == .internet { - ErrorInternetView() - } else if viewModel.errorType == .server { - ErrorServerView() - } else { - 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")) + 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()) } - .padding(.vertical, 19) - .contentShape(Rectangle()) + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) } } } diff --git a/ScheduleTracker/Views/SettingsView.swift b/ScheduleTracker/Views/SettingsView.swift index 8d062ba..2c8e9f5 100644 --- a/ScheduleTracker/Views/SettingsView.swift +++ b/ScheduleTracker/Views/SettingsView.swift @@ -38,28 +38,22 @@ struct SettingsView: View { Spacer() - if viewModel.isLoading { - Spacer() - ProgressView() - Spacer() - } else if viewModel.errorType == .internet { - ErrorInternetView() - } else if viewModel.errorType == .server { - ErrorServerView() - } else { - 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")) + 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() diff --git a/ScheduleTracker/Views/TicketListView.swift b/ScheduleTracker/Views/TicketListView.swift index 69a2cba..e390314 100644 --- a/ScheduleTracker/Views/TicketListView.swift +++ b/ScheduleTracker/Views/TicketListView.swift @@ -12,7 +12,7 @@ struct TicketListView: View { @ObservedObject var coordinator: NavigationCoordinator @ObservedObject var viewModel: TicketListViewModel @Environment(\.dismiss) var dismiss - + var body: some View { ZStack { Color("nightOrDayColor").ignoresSafeArea() @@ -27,67 +27,63 @@ struct TicketListView: View { ZStack(alignment: .bottom) { ScrollView { LazyVStack(spacing: 8) { - if viewModel.isLoading { - ProgressView() - .padding(.top, 100) - } else if viewModel.errorType == .internet { - ErrorInternetView() - } else if viewModel.errorType == .server { - ErrorServerView() - } else 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) + 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) }) { - HStack(spacing: 4) { - Text("Уточнить время") - .font(.custom("SFPro-Bold", size: 17)) - .foregroundStyle(Color(.white)) - if coordinator.isFiltersValid { - Circle() - .foregroundStyle(Color("redUniversal")) - .frame(width: 8, height: 8) + 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")) + .frame(width: 8, height: 8) + } } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) + .background(Color("blueUniversal")) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.bottom, 24) } - .background(Color("blueUniversal")) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.bottom, 24) } - } - .padding(.horizontal, 16) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: { dismiss() }) { - Image("leftChevron") - .renderingMode(.template) - .foregroundStyle(Color("dayOrNightColor")) + .padding(.horizontal, 16) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }) { + Image("leftChevron") + .renderingMode(.template) + .foregroundStyle(Color("dayOrNightColor")) + } } } } - } - .task { - await viewModel.loadTickets() + .task { + await viewModel.loadTickets() + } } } -} - + From fd9a9302f822e9b879b275d551ea5e953ff6d07e Mon Sep 17 00:00:00 2001 From: Vasiliy Khanin Date: Sat, 26 Jul 2025 13:22:06 +0300 Subject: [PATCH 3/3] fix(Models) add Sendable --- ScheduleTracker/Models/CarrierInfoModel.swift | 2 +- ScheduleTracker/Models/StationModel.swift | 2 +- ScheduleTracker/Models/StoryModel.swift | 2 +- ScheduleTracker/Models/TicketModel.swift | 4 +- .../ViewModels/TicketListViewModel.swift | 2 +- ScheduleTracker/Views/ErrorInternetView.swift | 2 + ScheduleTracker/Views/ErrorServerView.swift | 2 +- ScheduleTracker/Views/TicketCell.swift | 31 +++++----- ScheduleTracker/Views/TicketListView.swift | 57 ++++++++++--------- 9 files changed, 56 insertions(+), 48 deletions(-) diff --git a/ScheduleTracker/Models/CarrierInfoModel.swift b/ScheduleTracker/Models/CarrierInfoModel.swift index c6dfbbf..531cbba 100644 --- a/ScheduleTracker/Models/CarrierInfoModel.swift +++ b/ScheduleTracker/Models/CarrierInfoModel.swift @@ -7,7 +7,7 @@ import Foundation -struct CarrierInfoModel: Identifiable { +struct CarrierInfoModel: Identifiable, Sendable { var id: Int { code } let code: Int let title: String diff --git a/ScheduleTracker/Models/StationModel.swift b/ScheduleTracker/Models/StationModel.swift index cd65c1d..be52c60 100644 --- a/ScheduleTracker/Models/StationModel.swift +++ b/ScheduleTracker/Models/StationModel.swift @@ -6,7 +6,7 @@ // import Foundation -struct StationModel: Identifiable, Hashable { +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 814af54..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,6 +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/ViewModels/TicketListViewModel.swift b/ScheduleTracker/ViewModels/TicketListViewModel.swift index 7ded77d..a2da0fc 100644 --- a/ScheduleTracker/ViewModels/TicketListViewModel.swift +++ b/ScheduleTracker/ViewModels/TicketListViewModel.swift @@ -97,7 +97,7 @@ final class TicketListViewModel: ObservableObject, ErrorHandleable { duration: durationString, withTransfer: seg.has_transfers ?? false, operatorLogo: carrier?.logo ?? "", - note: seg.stops, + transfer: seg.stops, carrierCode: carrier?.code.flatMap { Int($0) } ) } 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/TicketCell.swift b/ScheduleTracker/Views/TicketCell.swift index 27e531c..1074781 100644 --- a/ScheduleTracker/Views/TicketCell.swift +++ b/ScheduleTracker/Views/TicketCell.swift @@ -22,36 +22,41 @@ struct TicketCell: View { case .success(let image): image .resizable() - .aspectRatio(contentMode: .fill) + .aspectRatio(contentMode: .fit) case .failure: - Image(systemName: "photo") + Image(systemName: "photo.on.rectangle") .resizable() - .aspectRatio(contentMode: .fill) + .aspectRatio(contentMode: .fit) .frame(width: 38, height: 38) .clipShape(RoundedRectangle(cornerRadius: 12)) @unknown default: EmptyView() } } - .frame(width: 220, height: 38, alignment: .leading) + .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 { diff --git a/ScheduleTracker/Views/TicketListView.swift b/ScheduleTracker/Views/TicketListView.swift index e390314..04be2e1 100644 --- a/ScheduleTracker/Views/TicketListView.swift +++ b/ScheduleTracker/Views/TicketListView.swift @@ -50,40 +50,41 @@ struct TicketListView: View { } } } - 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")) - .frame(width: 8, height: 8) - } + } + 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")) + .frame(width: 8, height: 8) } - .frame(maxWidth: .infinity) - .padding(.vertical, 20) } - .background(Color("blueUniversal")) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.bottom, 24) + .frame(maxWidth: .infinity) + .padding(.vertical, 20) } + .background(Color("blueUniversal")) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.bottom, 24) } - .padding(.horizontal, 16) - .navigationBarBackButtonHidden(true) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: { dismiss() }) { - Image("leftChevron") - .renderingMode(.template) - .foregroundStyle(Color("dayOrNightColor")) - } + } + .padding(.horizontal, 16) + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: { dismiss() }) { + Image("leftChevron") + .renderingMode(.template) + .foregroundStyle(Color("dayOrNightColor")) } } } - .task { - await viewModel.loadTickets() - } + } + .task { + await viewModel.loadTickets() } } - +} +