SwiftUI version of search screen

This commit is contained in:
Selim Mustafaev 2025-02-20 21:35:09 +03:00
parent bfd97877d3
commit c587783e86
16 changed files with 503 additions and 24 deletions

View File

@ -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 = "<group>"; };
7A530B7F2401803A00CBFE6E /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = "<group>"; };
7A54BFD22D43B95E00176D6D /* DbUpdatePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DbUpdatePolicy.swift; sourceTree = "<group>"; };
7A5911ED2D63226F00EC51BA /* SearchScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchScreen.swift; sourceTree = "<group>"; };
7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
7A5911F12D63268400EC51BA /* SearchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchCoordinator.swift; sourceTree = "<group>"; };
7A5912042D648A6000EC51BA /* AutoCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoCancellable.swift; sourceTree = "<group>"; };
7A599C352C18AC7F00D47C18 /* ApiError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiError.swift; sourceTree = "<group>"; };
7A599C382C18B22900D47C18 /* FbRefreshTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbRefreshTokenModel.swift; sourceTree = "<group>"; };
7A599C3A2C18B36A00D47C18 /* FbVerifyTokenModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FbVerifyTokenModel.swift; sourceTree = "<group>"; };
@ -398,6 +407,7 @@
7A7158112C444A6400852088 /* AdsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdsViewModel.swift; sourceTree = "<group>"; };
7A71EF562D0A26B200943129 /* EventModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventModel.swift; sourceTree = "<group>"; };
7A761C0A267E8FF90005F28F /* Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.swift; sourceTree = "<group>"; };
7A809F382D66755B00CF1B3C /* Error+Canceled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Error+Canceled.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
7A8A2208248D10EC0073DFD9 /* ResizeImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizeImage.swift; sourceTree = "<group>"; };
@ -711,6 +721,7 @@
7A1441632C297E9800E79018 /* Screens */ = {
isa = PBXGroup;
children = (
7A5911EC2D63225500EC51BA /* SearchScreen */,
7A131FD12D37B74100DC7755 /* HistoryScreen */,
7AB9FE202D08C28E005DE374 /* EventsScreen */,
7ABD1B452D044A0900B43213 /* GalleryScreen */,
@ -807,6 +818,16 @@
path = Cells;
sourceTree = "<group>";
};
7A5911EC2D63225500EC51BA /* SearchScreen */ = {
isa = PBXGroup;
children = (
7A5911ED2D63226F00EC51BA /* SearchScreen.swift */,
7A5911EF2D63266B00EC51BA /* SearchViewModel.swift */,
7A5911F12D63268400EC51BA /* SearchCoordinator.swift */,
);
path = SearchScreen;
sourceTree = "<group>";
};
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 = "<group>";
@ -1120,6 +1142,7 @@
7AABBE3A2CF9F85600346588 /* Binding+Map.swift */,
7A912F362D381B7400002938 /* LicensePlateView.swift */,
7AB4E42B2D397D8E0006D052 /* VehicleCellView.swift */,
7A5912042D648A6000EC51BA /* AutoCancellable.swift */,
);
path = SwiftUI;
sourceTree = "<group>";
@ -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;

View File

@ -17,7 +17,7 @@
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" translatesAutoresizingMaskIntoConstraints="NO" id="dB3-iP-QRo">
<rect key="frame" x="0.0" y="64" width="375" height="554"/>
<rect key="frame" x="0.0" y="64" width="375" height="603"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" selectionStyle="blue" hidesAccessoryWhenEditing="NO" indentationLevel="1" indentationWidth="0.0" reuseIdentifier="VehicleCell" id="VEP-QD-i6y" customClass="VehicleCell" customModule="AutoCat" customModuleProvider="target">
@ -146,7 +146,7 @@
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="xsk-7S-rvc" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4200.8000000000002" y="142.57871064467767"/>
<point key="canvasLocation" x="3553" y="143"/>
</scene>
<!--Voice records-->
<scene sceneID="9pI-G0-wG0">
@ -306,7 +306,6 @@
</tabBar>
<connections>
<segue destination="RK6-pn-2Bg" kind="relationship" relationship="viewControllers" id="KNz-WF-Kyy"/>
<segue destination="GCa-Re-j14" kind="relationship" relationship="viewControllers" id="FGp-f6-fUh"/>
</connections>
</tabBarController>
<placeholder placeholderIdentifier="IBFirstResponder" id="AJs-8F-Qbu" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
@ -458,6 +457,7 @@
<!--Search-->
<scene sceneID="kiS-EQ-VFl">
<objects>
<placeholder placeholderIdentifier="IBFirstResponder" id="XQB-kc-hUv" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="GCa-Re-j14" sceneMemberID="viewController">
<tabBarItem key="tabBarItem" title="Search" image="search" landscapeImage="search-compact" id="gDG-z8-R0t"/>
<toolbarItems/>
@ -470,9 +470,8 @@
<segue destination="UPf-uT-oOr" kind="relationship" relationship="rootViewController" id="aun-Tj-SJT"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="XQB-kc-hUv" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3261.5999999999999" y="142.57871064467767"/>
<point key="canvasLocation" x="2614" y="143"/>
</scene>
<!--Records-->
<scene sceneID="oyu-oz-pC4">

View File

@ -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, *) {

View File

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

View File

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

View File

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

View File

@ -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),

View File

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

View File

@ -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())
//}

View File

@ -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<VehicleDto>] = []
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<Void, Never>?
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)
}
}
}
}

View File

@ -41,10 +41,15 @@ struct ACMessageView: View {
.padding(20)
Divider()
Button("OK") {
action?()
}
Button(action: action ?? {}) {
HStack {
Spacer()
Text("OK")
.padding()
Spacer()
}
.contentShape(Rectangle())
}
}
.background(.thickMaterial,
in: RoundedRectangle(cornerRadius: 16))

View File

@ -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<C: Sendable, E: Error> {
var wrappedValue: Task<C,E>? {
didSet {
if let oldValue, oldValue.isCancelled {
return
}
oldValue?.cancel()
}
}
init(wrappedValue: Task<C,E>?) {
self.wrappedValue = wrappedValue
}
}

View File

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

View File

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

View File

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

View File

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