diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 8c8c6c1..b3c04d5 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -65,6 +65,10 @@ 7A4955822D58CCF900912E66 /* HistoryFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A4955812D58CCF900912E66 /* HistoryFilter.swift */; }; 7A530B7E24017FEE00CBFE6E /* VehicleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A530B7D24017FEE00CBFE6E /* VehicleCell.swift */; }; 7A54BFD32D43B95E00176D6D /* DbUpdatePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */; }; + 7A5911EE2D63226F00EC51BA /* SearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5911ED2D63226F00EC51BA /* SearchScreen.swift */; }; + 7A5911F02D63266B00EC51BA /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */; }; + 7A5911F22D63268400EC51BA /* SearchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5911F12D63268400EC51BA /* SearchCoordinator.swift */; }; + 7A5912052D648A6000EC51BA /* AutoCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A5912042D648A6000EC51BA /* AutoCancellable.swift */; }; 7A599C362C18AC7F00D47C18 /* ApiError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C352C18AC7F00D47C18 /* ApiError.swift */; }; 7A599C392C18B22900D47C18 /* FbRefreshTokenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */; }; 7A599C3B2C18B36A00D47C18 /* FbVerifyTokenModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */; }; @@ -124,6 +128,7 @@ 7A761C08267E8EA20005F28F /* JWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A43F9F7246C8A6200BA5B49 /* JWT.swift */; }; 7A761C09267E8EE40005F28F /* Base64FS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A96AE32246C095700297C33 /* Base64FS.swift */; }; 7A761C0B267E8FF90005F28F /* Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A761C0A267E8FF90005F28F /* Error.swift */; }; + 7A809F392D66755B00CF1B3C /* Error+Canceled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A809F382D66755B00CF1B3C /* Error+Canceled.swift */; }; 7A813DC32508EE4F00CC93B9 /* EventCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A813DC22508EE4F00CC93B9 /* EventCell.swift */; }; 7A8A2209248D10EC0073DFD9 /* ResizeImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8A2208248D10EC0073DFD9 /* ResizeImage.swift */; }; 7A8AB76525A0DB8F00ECF2C1 /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8AB76425A0DB8F00ECF2C1 /* BundleVersion.swift */; }; @@ -337,6 +342,10 @@ 7A530B7D24017FEE00CBFE6E /* VehicleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleCell.swift; sourceTree = ""; }; 7A530B7F2401803A00CBFE6E /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = ""; }; 7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DbUpdatePolicy.swift; sourceTree = ""; }; + 7A5911ED2D63226F00EC51BA /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = ""; }; + 7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + 7A5911F12D63268400EC51BA /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = ""; }; + 7A5912042D648A6000EC51BA /* AutoCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellable.swift; sourceTree = ""; }; 7A599C352C18AC7F00D47C18 /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = ""; }; 7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbRefreshTokenModel.swift; sourceTree = ""; }; 7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbVerifyTokenModel.swift; sourceTree = ""; }; @@ -398,6 +407,7 @@ 7A7158112C444A6400852088 /* AdsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdsViewModel.swift; sourceTree = ""; }; 7A71EF562D0A26B200943129 /* EventModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventModel.swift; sourceTree = ""; }; 7A761C0A267E8FF90005F28F /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = ""; }; + 7A809F382D66755B00CF1B3C /* Error+Canceled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+Canceled.swift"; sourceTree = ""; }; 7A813DBD2506A57100CC93B9 /* AuthenticationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AuthenticationServices.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/System/Library/Frameworks/AuthenticationServices.framework; sourceTree = DEVELOPER_DIR; }; 7A813DC22508EE4F00CC93B9 /* EventCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCell.swift; sourceTree = ""; }; 7A8A2208248D10EC0073DFD9 /* ResizeImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizeImage.swift; sourceTree = ""; }; @@ -711,6 +721,7 @@ 7A1441632C297E9800E79018 /* Screens */ = { isa = PBXGroup; children = ( + 7A5911EC2D63225500EC51BA /* SearchScreen */, 7A131FD12D37B74100DC7755 /* HistoryScreen */, 7AB9FE202D08C28E005DE374 /* EventsScreen */, 7ABD1B452D044A0900B43213 /* GalleryScreen */, @@ -807,6 +818,16 @@ path = Cells; sourceTree = ""; }; + 7A5911EC2D63225500EC51BA /* SearchScreen */ = { + isa = PBXGroup; + children = ( + 7A5911ED2D63226F00EC51BA /* SearchScreen.swift */, + 7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */, + 7A5911F12D63268400EC51BA /* SearchCoordinator.swift */, + ); + path = SearchScreen; + sourceTree = ""; + }; 7A599C372C18B21200D47C18 /* Firebase */ = { isa = PBXGroup; children = ( @@ -1100,6 +1121,7 @@ 7AE8424D26109F78002F6B31 /* Exportable.swift */, 7A1CF81529A42117007962DA /* Realm.swift */, 7A6B65B22CFB0DB500AABA6B /* NullifyDate.swift */, + 7A809F382D66755B00CF1B3C /* Error+Canceled.swift */, ); path = Extensions; sourceTree = ""; @@ -1120,6 +1142,7 @@ 7AABBE3A2CF9F85600346588 /* Binding+Map.swift */, 7A912F362D381B7400002938 /* LicensePlateView.swift */, 7AB4E42B2D397D8E0006D052 /* VehicleCellView.swift */, + 7A5912042D648A6000EC51BA /* AutoCancellable.swift */, ); path = SwiftUI; sourceTree = ""; @@ -1378,6 +1401,7 @@ 7AB9FE262D08C2D7005DE374 /* EventsCoordinator.swift in Sources */, 7AB9FE282D08C2F4005DE374 /* EventsViewModel.swift in Sources */, 7A4927D52CCE438600851C01 /* OptionalBinding.swift in Sources */, + 7A5911EE2D63226F00EC51BA /* SearchScreen.swift in Sources */, 7A17CE4A2A2E820300626A6E /* UIStackView.swift in Sources */, 7A1DC38E2517ED98002E9C99 /* BlockBarButtonItem.swift in Sources */, 7AE26A3324EEF9EC00625033 /* UIViewControllerExt.swift in Sources */, @@ -1412,6 +1436,7 @@ 7AE24C5F251F1B4E00758E39 /* Buttons.swift in Sources */, 7A11471A23FE839000B424AF /* AuthController.swift in Sources */, 7A64AE742469DFB600ABE48E /* MediaContentView.swift in Sources */, + 7A5911F22D63268400EC51BA /* SearchCoordinator.swift in Sources */, 7A1090EC24A4E3E100B4F0B2 /* CellProgressView.swift in Sources */, 7AB9FE2A2D08CF35005DE374 /* EventsScreenMode.swift in Sources */, 7A96AE2D246B2B7400297C33 /* GoogleSignInController.swift in Sources */, @@ -1448,12 +1473,14 @@ 7ABD1B472D044A3200B43213 /* GalleryScreen.swift in Sources */, 7ADF6C95250D037700F237B2 /* ShowEventController.swift in Sources */, 7A71580C2C44453200852088 /* AdsScreen.swift in Sources */, + 7A5912052D648A6000EC51BA /* AutoCancellable.swift in Sources */, 7A06E0B02C7065D8005731AC /* SettingsCoordinator.swift in Sources */, 7A91894F29A2BD8700519C74 /* GestureRecognizers.swift in Sources */, 7AFBE8CC2C3085C6003C491D /* ACProgressView.swift in Sources */, 7A131FD32D37B75500DC7755 /* HistoryScreen.swift in Sources */, 7AB9FE222D08C2A5005DE374 /* EventsScreen.swift in Sources */, 7ADF6C93250B954900F237B2 /* Navigation.swift in Sources */, + 7A5911F02D63266B00EC51BA /* SearchViewModel.swift in Sources */, 7A64AE752469DFB600ABE48E /* MediaBrowserViewController.swift in Sources */, 7ABD1B4B2D044A7D00B43213 /* GalleryCoordinator.swift in Sources */, 7A64AE732469DFB600ABE48E /* DismissAnimationController.swift in Sources */, @@ -1534,6 +1561,7 @@ 7A60D2512C5A9E4200D13F7B /* GeocoderProtocol.swift in Sources */, 7AB4E4382D3D0C5C0006D052 /* VehiclesArchive.swift in Sources */, 7A64A21C2C19E87B00284124 /* OsagoDto.swift in Sources */, + 7A809F392D66755B00CF1B3C /* Error+Canceled.swift in Sources */, 7AF6D21D2677C1680086EA64 /* Osago.swift in Sources */, 7A1CF81629A42117007962DA /* Realm.swift in Sources */, 7A64A2142C19E3B700284124 /* VehicleEngineDto.swift in Sources */, @@ -1745,7 +1773,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AutoCat/AutoCat.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 46DTTB8X4S; INFOPLIST_FILE = AutoCat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AutoCat; @@ -1772,7 +1800,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = AutoCat/AutoCat.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 147; + CURRENT_PROJECT_VERSION = 148; DEVELOPMENT_TEAM = 46DTTB8X4S; INFOPLIST_FILE = AutoCat/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = AutoCat; diff --git a/AutoCat/Base.lproj/Main.storyboard b/AutoCat/Base.lproj/Main.storyboard index 433e729..09f3db6 100644 --- a/AutoCat/Base.lproj/Main.storyboard +++ b/AutoCat/Base.lproj/Main.storyboard @@ -17,7 +17,7 @@ - + @@ -146,7 +146,7 @@ - + @@ -306,7 +306,6 @@ - @@ -458,6 +457,7 @@ + @@ -470,9 +470,8 @@ - - + diff --git a/AutoCat/Controllers/Location/GlobalEventsController.swift b/AutoCat/Controllers/Location/GlobalEventsController.swift index 0628720..641f53e 100644 --- a/AutoCat/Controllers/Location/GlobalEventsController.swift +++ b/AutoCat/Controllers/Location/GlobalEventsController.swift @@ -35,13 +35,24 @@ class EventPin: NSObject, MKAnnotation { class GlobalEventsController: UIViewController { - @IBOutlet weak var map: MKMapView! + var map: MKMapView! var filter: Filter! override func viewDidLoad() { super.viewDidLoad() + map = MKMapView() + map.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(map) + + NSLayoutConstraint.activate([ + map.leadingAnchor.constraint(equalTo: view.leadingAnchor), + map.trailingAnchor.constraint(equalTo: view.trailingAnchor), + map.topAnchor.constraint(equalTo: view.topAnchor), + map.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + #if targetEnvironment(macCatalyst) if #available(OSX 11.0, *) { diff --git a/AutoCat/Controllers/MainTabController.swift b/AutoCat/Controllers/MainTabController.swift index 0c337e3..239d184 100644 --- a/AutoCat/Controllers/MainTabController.swift +++ b/AutoCat/Controllers/MainTabController.swift @@ -22,6 +22,7 @@ class MainTabController: UITabBarController, UITabBarControllerDelegate { #if !targetEnvironment(macCatalyst) addDummyTab() #endif + addSearchTab() Task { await addSettings() } } @@ -42,6 +43,14 @@ class MainTabController: UITabBarController, UITabBarControllerDelegate { viewControllers?.insert(controller, at: 2) } + func addSearchTab() { + let coordinator = SearchCoordinator() + let controller = coordinator.start() + controller.tabBarItem = UITabBarItem(title: NSLocalizedString("Search", comment: ""), + image: UIImage(systemName: "magnifyingglass"), tag: 0) + viewControllers?.append(controller) + } + func addSettings() async { let coordinator = SettingsCoordinator(tabController: self) diff --git a/AutoCat/Screens/FiltersScreen/FiltersCoordinator.swift b/AutoCat/Screens/FiltersScreen/FiltersCoordinator.swift index eb46f76..fc54eb0 100644 --- a/AutoCat/Screens/FiltersScreen/FiltersCoordinator.swift +++ b/AutoCat/Screens/FiltersScreen/FiltersCoordinator.swift @@ -10,9 +10,9 @@ import AutoCatCore import UIKit @MainActor -class FiltersCoordinator: Coordinator { +class FiltersCoordinator { - let viewController: UINavigationController? + let viewController: UINavigationController let filter: Filter init(navController: UINavigationController, filter: Filter) { @@ -21,13 +21,13 @@ class FiltersCoordinator: Coordinator { self.filter = filter } - func start() async throws -> Filter? { + func start() async -> Filter? { let viewModel = FiltersViewModel( apiService: ServiceContainer.shared.resolve(ApiServiceProtocol.self), filter: filter ) let controller = CustomHostingController(rootView: FiltersScreen(viewModel: viewModel)) - viewController?.pushViewController(controller, animated: true) + viewController.pushViewController(controller, animated: true) await controller.waitForDisappear() return viewModel.filterResult } diff --git a/AutoCat/Screens/HistoryScreen/HistoryScreen.swift b/AutoCat/Screens/HistoryScreen/HistoryScreen.swift index 8e6fdb1..80838e6 100644 --- a/AutoCat/Screens/HistoryScreen/HistoryScreen.swift +++ b/AutoCat/Screens/HistoryScreen/HistoryScreen.swift @@ -42,6 +42,7 @@ struct HistoryScreen: View { .navigationTitle(String.localizedStringWithFormat(NSLocalizedString("vehicles found", comment: ""), viewModel.vehiclesCount)) .searchable(text: $viewModel.searchText, prompt: "Search plate numbers") + .searchPresentationToolbarBehavior(.avoidHidingContent) .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.asciiCapable) diff --git a/AutoCat/Screens/ReportScreen/ReportCoordinator.swift b/AutoCat/Screens/ReportScreen/ReportCoordinator.swift index 687823b..b5d7024 100644 --- a/AutoCat/Screens/ReportScreen/ReportCoordinator.swift +++ b/AutoCat/Screens/ReportScreen/ReportCoordinator.swift @@ -11,7 +11,7 @@ import SwiftUI import AutoCatCore @MainActor -class ReportCoordinator: Coordinator { +class ReportCoordinator { let viewController: UIViewController? let vehicle: VehicleDto @@ -26,10 +26,10 @@ class ReportCoordinator: Coordinator { self.isPersistent = isPersistent } - func start() async throws -> VehicleDto { + func start() async -> VehicleDto { let resolver = ServiceContainer.shared - let viewModel = await ReportViewModel( + let viewModel = ReportViewModel( apiService: resolver.resolve(ApiServiceProtocol.self), storageService: resolver.resolve(StorageServiceProtocol.self), settingsService: resolver.resolve(SettingsServiceProtocol.self), diff --git a/AutoCat/Screens/SearchScreen/SearchCoordinator.swift b/AutoCat/Screens/SearchScreen/SearchCoordinator.swift new file mode 100644 index 0000000..1ae01e7 --- /dev/null +++ b/AutoCat/Screens/SearchScreen/SearchCoordinator.swift @@ -0,0 +1,71 @@ +// +// SearchCoordinator.swift +// AutoCat +// +// Created by Selim Mustafaev on 17.02.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import UIKit +import SwiftUI +import AutoCatCore + +@MainActor +final class SearchCoordinator { + + var navController = UINavigationController() + + func start() -> UIViewController { + + let resolver = ServiceContainer.shared + let viewModel = SearchViewModel( + apiService: resolver.resolve(ApiServiceProtocol.self), + storageService: resolver.resolve(StorageServiceProtocol.self), + vehicleService: resolver.resolve(VehicleServiceProtocol.self) + ) + viewModel.coordinator = self + + let view = SearchScreen(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + + let navController = UINavigationController(rootViewController: controller) + self.navController = navController + return navController + } + + func openReport(vehicle: VehicleDto) async -> VehicleDto? { + + let coordinator = ReportCoordinator(controller: navController, + vehicle: vehicle, + isPersistent: false) + return await coordinator.start() + } + + func openFilterDetail(filter: Filter) async -> Filter? { + + let coordinator = FiltersCoordinator(navController: navController, filter: filter) + return await coordinator.start() + } + + func showOnMap(filter: Filter) { + + let controller = GlobalEventsController() + controller.filter = filter + controller.modalPresentationStyle = .fullScreen + navController.pushViewController(controller, animated: true) + } + + func export(url: URL) { + guard let currentController = navController.visibleViewController else { + return + } + +#if targetEnvironment(macCatalyst) + let controller = UIDocumentPickerViewController(forExporting: [url]) + currentController.present(controller, animated: true) +#else + let activityController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + currentController.present(activityController, animated: true) +#endif + } +} diff --git a/AutoCat/Screens/SearchScreen/SearchScreen.swift b/AutoCat/Screens/SearchScreen/SearchScreen.swift new file mode 100644 index 0000000..3bfaff7 --- /dev/null +++ b/AutoCat/Screens/SearchScreen/SearchScreen.swift @@ -0,0 +1,99 @@ +// +// SearchScreen.swift +// AutoCat +// +// Created by Selim Mustafaev on 17.02.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import SwiftUI +import AutoCatCore + +struct SearchScreen: View { + + @State var viewModel: SearchViewModel + + var body: some View { + List { + vehicles + if viewModel.hasMoreData && !viewModel.vehicleSections.isEmpty { + progressCell + } + } + .listStyle(.plain) + .hud($viewModel.hud) + .searchable(text: $viewModel.searchText, prompt: "Search plate numbers") + .searchPresentationToolbarBehavior(.avoidHidingContent) + .navigationTitle(String.localizedStringWithFormat(NSLocalizedString("vehicles found", comment: ""), + viewModel.vehiclesCount)) + .onAppear { + Task { await viewModel.onAppear() } + } + .refreshable { + Task { await viewModel.reloadData() } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + toolbarMenu + } + } + } + + var vehicles: some View { + ForEach(viewModel.vehicleSections) { section in + Section(header: Text(section.header)) { + ForEach(section.elements) { vehicle in + VehicleCellView(vehicle: vehicle) + .onTapGesture { + Task { await viewModel.openReport(vehicle: vehicle) } + } + .swipeActions(allowsFullSwipe: false) { + makeActions(for: vehicle) + } + .contextMenu { + makeActions(for: vehicle, useLabels: true) + } + } + } + } + } + + var progressCell: some View { + HStack { + Spacer() + ProgressView() + .id(UUID()) + Spacer() + } + .onAppear { + Task { await viewModel.loadMoreData() } + } + } + + var toolbarMenu: some View { + Menu("", systemImage: "ellipsis") { + Button("Filter results", systemImage: "line.horizontal.3.decrease") { + Task { await viewModel.openFilterDetail() } + } + Button("Show on map", systemImage: "map") { + viewModel.showOnMap() + } + ShareLink(item: viewModel.vehiclesArchive, preview: SharePreview(VehiclesArchive.fileName)) + //ShareLink(items: [viewModel.vehiclesArchive]) + } + } + + @ViewBuilder + func makeActions(for vehicle: VehicleDto, useLabels: Bool = false) -> some View { + + Button { + Task { await viewModel.updateVehicle(vehicle) } + } label: { + Label(useLabels ? "Update" : "", systemImage: "arrow.2.circlepath") + } + } +} + +//#Preview { +// SearchScreen(viewModel: .init()) +//} diff --git a/AutoCat/Screens/SearchScreen/SearchViewModel.swift b/AutoCat/Screens/SearchScreen/SearchViewModel.swift new file mode 100644 index 0000000..af45de4 --- /dev/null +++ b/AutoCat/Screens/SearchScreen/SearchViewModel.swift @@ -0,0 +1,189 @@ +// +// SearchViewModel.swift +// AutoCat +// +// Created by Selim Mustafaev on 17.02.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import AutoCatCore +import SwiftUI +import Combine + +@MainActor +@Observable +final class SearchViewModel: ACHudContainer { + + let apiService: ApiServiceProtocol + let storageService: StorageServiceProtocol + let vehicleService: VehicleServiceProtocol + var coordinator: SearchCoordinator? + + var hud: ACHud? + + @ObservationIgnored + var vehicles: [VehicleDto] = [] + var vehicleSections: [DateSection] = [] + + var filter = Filter() + var pageToken: String? + var hasMoreData: Bool = true + var vehiclesCount: Int = 0 + + var searchText: String = "" { + didSet { + if searchText != oldValue { + filter.searchString = searchText + searchTask = Task { [filter] in + try? await Task.sleep(for: .milliseconds(500)) + await reloadData(with: filter) + } + } + } + } + + @ObservationIgnored + @AutoCancellable + var searchTask: Task? + + var vehiclesArchive: VehiclesArchive { + VehiclesArchive(apiService: apiService, filter: filter) + } + + init(apiService: ApiServiceProtocol, + storageService: StorageServiceProtocol, + vehicleService: VehicleServiceProtocol) { + + self.apiService = apiService + self.storageService = storageService + self.vehicleService = vehicleService + } + + func onAppear() async { + guard vehicles.isEmpty else { + return + } + + resetData() + await wrapWithToast { [weak self] in + guard let self else { return } + try await loadSearchResults(filter: filter) + } + } + + func resetData() { + + vehicles = [] + vehicleSections = [] + pageToken = nil + } + + func loadSearchResults(filter: Filter) async throws { + + let query = filter.searchString + let response = try await apiService.getVehicles(with: filter, pageToken: pageToken, pageSize: 20) + + if response.items.isEmpty { + hasMoreData = false + } else { + vehicles += response.items + pageToken = response.pageToken + vehiclesCount = response.count ?? 0 + vehicleSections = vehicles.groupedByDate(type: .updatedDate) + } + } + + func loadMoreData() async { + do { + try await loadSearchResults(filter: filter) + } catch { + if !error.isCanceled { + hasMoreData = false + hud = .error(error) + } + } + } + + func reloadData() async { + await reloadData(with: filter) + } + + func reloadData(with filter: Filter) async { + resetData() + do { + try await loadSearchResults(filter: filter) + } catch { + if !error.isCanceled { + hasMoreData = false + hud = .error(error) + } + } + } + + func openReport(vehicle: VehicleDto) async { + guard let updatedVehicle = await coordinator?.openReport(vehicle: vehicle) else { + return + } + + if let index = vehicles.firstIndex(where: { $0.number == updatedVehicle.number }) { + vehicles[index] = updatedVehicle + vehicleSections = vehicles.groupedByDate(type: .updatedDate) + } + } + + func openFilterDetail() async { + guard let updatedFilter = await coordinator?.openFilterDetail(filter: filter) else { + return + } + + filter = updatedFilter + resetData() + await wrapWithToast { [weak self] in + guard let self else { return } + try await loadSearchResults(filter: filter) + } + } + + func showOnMap() { + coordinator?.showOnMap(filter: filter) + } + + func exportSearchResults() async { + await wrapWithToast { [weak self] in + guard let self else { + return + } + + let resp = try await apiService.getVehicles(with: filter, pageToken: nil, pageSize: 0) + + let newLine = "\r\n" + var csvString = VehicleDto.csvHeader + newLine + + for vehicle in resp.items { + csvString.append(vehicle.csvLine) + csvString.append(newLine) + } + + let tmpUrl = FileManager.default.tmpUrl(name: "search", ext: "csv") + try csvString.write(to: tmpUrl, atomically: true, encoding: .utf8) + + coordinator?.export(url: tmpUrl) + } + } + + func updateVehicle(_ vehicle: VehicleDto) async { + await wrapWithToast { [weak self] in + guard let self else { + return + } + + let updatedVehicle = try await apiService.checkVehicle(by: vehicle.getNumber(), notes: [], events: [], force: true) + try await storageService.updateVehicle(dto: updatedVehicle, policy: .ifExists) + + if let index = vehicles.firstIndex(where: { $0.number == updatedVehicle.number }) { + vehicles[index] = updatedVehicle + vehicleSections = vehicles.groupedByDate(type: .updatedDate) + } + } + } +} diff --git a/AutoCat/SwiftUI/ACProgressHud/ACMessageView.swift b/AutoCat/SwiftUI/ACProgressHud/ACMessageView.swift index 1d6c4c2..95ac9d3 100644 --- a/AutoCat/SwiftUI/ACProgressHud/ACMessageView.swift +++ b/AutoCat/SwiftUI/ACProgressHud/ACMessageView.swift @@ -41,10 +41,15 @@ struct ACMessageView: View { .padding(20) Divider() - Button("OK") { - action?() + Button(action: action ?? {}) { + HStack { + Spacer() + Text("OK") + .padding() + Spacer() + } + .contentShape(Rectangle()) } - .padding() } .background(.thickMaterial, in: RoundedRectangle(cornerRadius: 16)) diff --git a/AutoCat/SwiftUI/AutoCancellable.swift b/AutoCat/SwiftUI/AutoCancellable.swift new file mode 100644 index 0000000..e7de256 --- /dev/null +++ b/AutoCat/SwiftUI/AutoCancellable.swift @@ -0,0 +1,26 @@ +// +// AutoCancellable.swift +// AutoCat +// +// Created by Selim Mustafaev on 18.02.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +@propertyWrapper +struct AutoCancellable { + + var wrappedValue: Task? { + didSet { + if let oldValue, oldValue.isCancelled { + return + } + oldValue?.cancel() + } + } + + init(wrappedValue: Task?) { + self.wrappedValue = wrappedValue + } +} diff --git a/AutoCatCore/Extensions/Error+Canceled.swift b/AutoCatCore/Extensions/Error+Canceled.swift new file mode 100644 index 0000000..f334e75 --- /dev/null +++ b/AutoCatCore/Extensions/Error+Canceled.swift @@ -0,0 +1,19 @@ +// +// Error+Canceled.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 19.02.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +extension Error { + + public var isCanceled: Bool { + let nsError = self as NSError + + return nsError.domain == NSURLErrorDomain + && nsError.code == NSURLErrorCancelled + } +} diff --git a/AutoCatCore/Models/DTO/DebugInfoDto.swift b/AutoCatCore/Models/DTO/DebugInfoDto.swift index 1582ddf..cc48571 100644 --- a/AutoCatCore/Models/DTO/DebugInfoDto.swift +++ b/AutoCatCore/Models/DTO/DebugInfoDto.swift @@ -16,11 +16,11 @@ public enum DebugInfoStatus: Int, Sendable, Decodable, Equatable { public struct DebugInfoDto: Decodable, Sendable, Equatable { - public var autocod: DebugInfoEntryDto - public var vin01vin: DebugInfoEntryDto - public var vin01base: DebugInfoEntryDto - public var vin01history: DebugInfoEntryDto - public var nomerogram: DebugInfoEntryDto + public var autocod: DebugInfoEntryDto? + public var vin01vin: DebugInfoEntryDto? + public var vin01base: DebugInfoEntryDto? + public var vin01history: DebugInfoEntryDto? + public var nomerogram: DebugInfoEntryDto? } public struct DebugInfoEntryDto: Decodable, Sendable, Equatable { diff --git a/AutoCatCore/Models/VehiclesArchive.swift b/AutoCatCore/Models/VehiclesArchive.swift index b298e8f..09e33cf 100644 --- a/AutoCatCore/Models/VehiclesArchive.swift +++ b/AutoCatCore/Models/VehiclesArchive.swift @@ -21,11 +21,22 @@ public enum VehiclesArchiveError: LocalizedError { public final class VehiclesArchive { - let vehicles: [VehicleDto] + var vehicles: [VehicleDto] + let apiService: ApiServiceProtocol? + let filter: Filter? public init(vehiles: [VehicleDto]) { self.vehicles = vehiles + self.apiService = nil + self.filter = nil + } + + public init(apiService: ApiServiceProtocol, filter: Filter) { + + self.apiService = apiService + self.filter = filter + self.vehicles = [] } func makeCsvString() throws -> String { @@ -42,6 +53,13 @@ public final class VehiclesArchive { return result } + + func loadVehiclesIfNeeded() async throws { + if let apiService, let filter { + let result = try await apiService.getVehicles(with: filter, pageToken: nil, pageSize: 0) + vehicles = result.items + } + } } extension VehiclesArchive: Transferable { @@ -54,7 +72,9 @@ extension VehiclesArchive: Transferable { DataRepresentation(exportedContentType: .commaSeparatedText) { archive in + try await archive.loadVehiclesIfNeeded() let csvString = try archive.makeCsvString() + if let data = csvString.data(using: .utf8){ return data } else { diff --git a/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift b/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift index bf7766d..78853ae 100644 --- a/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift +++ b/AutoCatCore/Services/ApiService/ApiServiceProtocol.swift @@ -27,4 +27,6 @@ public protocol ApiServiceProtocol: Sendable { func checkVehicle(by number: String, notes: [VehicleNoteDto], events: [VehicleEventDto], force: Bool) async throws -> VehicleDto func checkVehicleGb(by number: String) async throws -> VehicleDto + + func getVehicles(with filter: Filter, pageToken: String?, pageSize: Int) async throws -> PagedResponse }