diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index a775b17..9a618da 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -48,7 +48,6 @@ 7A1E78FA2CE9005C0004B740 /* ReportCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E78F92CE9005C0004B740 /* ReportCoordinator.swift */; }; 7A1E78FF2CE91A740004B740 /* Vehicle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E78FE2CE91A740004B740 /* Vehicle.swift */; }; 7A27ADF3249F8B650035F39E /* RecordsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF2249F8B650035F39E /* RecordsController.swift */; }; - 7A27ADF5249FD2F90035F39E /* FileManagerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */; }; 7A27ADF7249FEF690035F39E /* Recorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A27ADF6249FEF690035F39E /* Recorder.swift */; }; 7A2C96122C3B155B00AE46B5 /* NoteAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2C96112C3B155B00AE46B5 /* NoteAlertModifier.swift */; }; 7A2E11292CCE395300E5CA17 /* OptionalDatePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A2E11282CCE395300E5CA17 /* OptionalDatePicker.swift */; }; @@ -147,6 +146,8 @@ 7AABDE26253350C30041AFC6 /* RxSectionedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AABDE25253350C30041AFC6 /* RxSectionedDataSource.swift */; }; 7AB0EF812C5CC0FE00291EE6 /* SwiftLocationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB0EF802C5CC0FE00291EE6 /* SwiftLocationProtocol.swift */; }; 7AB4E42C2D397D8E0006D052 /* VehicleCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4E42B2D397D8E0006D052 /* VehicleCellView.swift */; }; + 7AB4E4332D3C21C00006D052 /* FileManagerExt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4E4322D3C21C00006D052 /* FileManagerExt.swift */; }; + 7AB4E4382D3D0C5C0006D052 /* VehiclesArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB4E4372D3D0C5C0006D052 /* VehiclesArchive.swift */; }; 7AB5871D2C42C1CF00FA7B66 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7AB5871C2C42C1CF00FA7B66 /* RealmSwift */; }; 7AB587322C42D38E00FA7B66 /* StorageServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB587312C42D38E00FA7B66 /* StorageServiceProtocol.swift */; }; 7AB587342C42D3FA00FA7B66 /* StorageService+Notes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AB587332C42D3FA00FA7B66 /* StorageService+Notes.swift */; }; @@ -309,7 +310,6 @@ 7A1E78F92CE9005C0004B740 /* ReportCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportCoordinator.swift; sourceTree = ""; }; 7A1E78FE2CE91A740004B740 /* Vehicle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vehicle.swift; sourceTree = ""; }; 7A27ADF2249F8B650035F39E /* RecordsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsController.swift; sourceTree = ""; }; - 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExt.swift; sourceTree = ""; }; 7A27ADF6249FEF690035F39E /* Recorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recorder.swift; sourceTree = ""; }; 7A27ADF824A09CAD0035F39E /* CocoaError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CocoaError.swift; sourceTree = ""; }; 7A2C96112C3B155B00AE46B5 /* NoteAlertModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteAlertModifier.swift; sourceTree = ""; }; @@ -419,6 +419,8 @@ 7AAE6AD224CDDF950023860B /* VehicleEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleEvent.swift; sourceTree = ""; }; 7AB0EF802C5CC0FE00291EE6 /* SwiftLocationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftLocationProtocol.swift; sourceTree = ""; }; 7AB4E42B2D397D8E0006D052 /* VehicleCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleCellView.swift; sourceTree = ""; }; + 7AB4E4322D3C21C00006D052 /* FileManagerExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerExt.swift; sourceTree = ""; }; + 7AB4E4372D3D0C5C0006D052 /* VehiclesArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehiclesArchive.swift; sourceTree = ""; }; 7AB562B9249C9E9B00473D53 /* VehicleRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleRegion.swift; sourceTree = ""; }; 7AB587222C42D27F00FA7B66 /* AutoCatTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AutoCatTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7AB587312C42D38E00FA7B66 /* StorageServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageServiceProtocol.swift; sourceTree = ""; }; @@ -659,6 +661,7 @@ 7A11474823FF2B2D00B424AF /* Response.swift */, 7A11474623FF2AA500B424AF /* User.swift */, 7AB562B9249C9E9B00473D53 /* VehicleRegion.swift */, + 7AB4E4372D3D0C5C0006D052 /* VehiclesArchive.swift */, ); path = Models; sourceTree = ""; @@ -738,7 +741,6 @@ 7A8AB76425A0DB8F00ECF2C1 /* BundleVersion.swift */, 7AE24C5E251F1B4E00758E39 /* Buttons.swift */, 7A3F07AA24360DC800E59687 /* Dated.swift */, - 7A27ADF4249FD2F90035F39E /* FileManagerExt.swift */, 7ADF6CA02512244400F237B2 /* MapExt.swift */, 7ADF6C92250B954900F237B2 /* Navigation.swift */, 7A8A2208248D10EC0073DFD9 /* ResizeImage.swift */, @@ -1061,6 +1063,7 @@ 7AF6D2292677C3950086EA64 /* Extensions */ = { isa = PBXGroup; children = ( + 7AB4E4322D3C21C00006D052 /* FileManagerExt.swift */, 7A27ADF824A09CAD0035F39E /* CocoaError.swift */, 7AE8424D26109F78002F6B31 /* Exportable.swift */, 7A1CF81529A42117007962DA /* Realm.swift */, @@ -1342,7 +1345,6 @@ 7AF860702CBAA24500954D2F /* NavigationLink.swift in Sources */, 7AB9FE262D08C2D7005DE374 /* EventsCoordinator.swift in Sources */, 7AB9FE282D08C2F4005DE374 /* EventsViewModel.swift in Sources */, - 7A27ADF5249FD2F90035F39E /* FileManagerExt.swift in Sources */, 7A4927D52CCE438600851C01 /* OptionalBinding.swift in Sources */, 7A17CE4A2A2E820300626A6E /* UIStackView.swift in Sources */, 7A1DC38E2517ED98002E9C99 /* BlockBarButtonItem.swift in Sources */, @@ -1485,6 +1487,7 @@ 7A60D24F2C5A9DA800D13F7B /* LocationServiceProtocol.swift in Sources */, 7A761C07267E8E7F0005F28F /* AnyEncodable.swift in Sources */, 7A64A2032C19DA1000284124 /* VehicleDto.swift in Sources */, + 7AB4E4332D3C21C00006D052 /* FileManagerExt.swift in Sources */, 7AB587322C42D38E00FA7B66 /* StorageServiceProtocol.swift in Sources */, 7A3E12D72C7B42B700EE710D /* UserDefaults+Settings.swift in Sources */, 7AA514E02D0B75B3001CAC50 /* StorageService+Events.swift in Sources */, @@ -1493,6 +1496,7 @@ 7A64A2202C19E93500284124 /* VehicleNoteDto.swift in Sources */, 7AF6D21A2677C1680086EA64 /* User.swift in Sources */, 7A60D2512C5A9E4200D13F7B /* GeocoderProtocol.swift in Sources */, + 7AB4E4382D3D0C5C0006D052 /* VehiclesArchive.swift in Sources */, 7A64A21C2C19E87B00284124 /* OsagoDto.swift in Sources */, 7AF6D21D2677C1680086EA64 /* Osago.swift in Sources */, 7A1CF81629A42117007962DA /* Realm.swift in Sources */, diff --git a/AutoCat/Screens/HistoryScreen/HistoryScreen.swift b/AutoCat/Screens/HistoryScreen/HistoryScreen.swift index c2923aa..2991e69 100644 --- a/AutoCat/Screens/HistoryScreen/HistoryScreen.swift +++ b/AutoCat/Screens/HistoryScreen/HistoryScreen.swift @@ -13,6 +13,7 @@ struct HistoryScreen: View { @State var viewModel: HistoryViewModel @State var filterSheetPresented = false + @State var exportSheetPresented = false var body: some View { List { @@ -24,14 +25,20 @@ struct HistoryScreen: View { } } } + .hud($viewModel.hud) .listStyle(.plain) .navigationTitle(String.localizedStringWithFormat(NSLocalizedString("vehicles found", comment: ""), - viewModel.vehicleSections.reduce(0, { $0 + $1.elements.count }))) + viewModel.vehiclesCount)) .searchable(text: $viewModel.searchText, prompt: "Search plate numbers") .autocorrectionDisabled() .textInputAutocapitalization(.never) .keyboardType(.asciiCapable) .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("", systemImage: "square.and.arrow.up") { + exportSheetPresented = true + } + } ToolbarItem(placement: .primaryAction) { Button("", systemImage: "line.horizontal.3.decrease") { filterSheetPresented = true @@ -46,6 +53,18 @@ struct HistoryScreen: View { } } } + .confirmationDialog("Export history as", isPresented: $exportSheetPresented, titleVisibility: .visible) { + + ShareLink(item: viewModel.vehiclesArchive, preview: SharePreview(VehiclesArchive.fileName)) { + Text("CSV table") + } + + if let dbUrl = viewModel.dbFileURL { + ShareLink(item: dbUrl) { + Text("Database file") + } + } + } } } diff --git a/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift b/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift index aa4d9ff..dcc2044 100644 --- a/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift +++ b/AutoCat/Screens/HistoryScreen/HistoryViewModel.swift @@ -11,11 +11,13 @@ import AutoCatCore @MainActor @Observable -final class HistoryViewModel { +final class HistoryViewModel: ACHudContainer { let apiService: ApiServiceProtocol let storageService: StorageServiceProtocol + var hud: ACHud? + var vehicles: [VehicleDto] = [] var vehiclesFiltered: [VehicleDto] = [] var vehicleSections: [DateSection] = [] @@ -30,12 +32,24 @@ final class HistoryViewModel { var filter: HistoryFilter = .all - init(apiService: ApiServiceProtocol, storageService: StorageServiceProtocol) { + var vehiclesArchive: VehiclesArchive { + VehiclesArchive(vehiles: vehiclesFiltered) + } + + var vehiclesCount: Int { + vehicleSections.reduce(0, { $0 + $1.elements.count }) + } + + var dbFileURL: URL? + + init(apiService: ApiServiceProtocol, + storageService: StorageServiceProtocol) { self.apiService = apiService self.storageService = storageService Task { await loadVehicles() } + Task { dbFileURL = await storageService.dbFileURL } } func loadVehicles() async { diff --git a/AutoCat/SwiftUI/VehicleCellView.swift b/AutoCat/SwiftUI/VehicleCellView.swift index 14cd700..ceb656f 100644 --- a/AutoCat/SwiftUI/VehicleCellView.swift +++ b/AutoCat/SwiftUI/VehicleCellView.swift @@ -23,8 +23,8 @@ struct VehicleCellView: View { Spacer(minLength: 0) - if vehicle.synchronized || vehicle.unrecognized { - Image(systemName: "exclamationmark.arrow.triangle") + if !vehicle.synchronized && !vehicle.unrecognized { + Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") .frame(width: 20, height: 20) .foregroundStyle(.orange) } diff --git a/AutoCat/Extensions/FileManagerExt.swift b/AutoCatCore/Extensions/FileManagerExt.swift similarity index 96% rename from AutoCat/Extensions/FileManagerExt.swift rename to AutoCatCore/Extensions/FileManagerExt.swift index 0d4f87a..177907a 100644 --- a/AutoCat/Extensions/FileManagerExt.swift +++ b/AutoCatCore/Extensions/FileManagerExt.swift @@ -1,6 +1,6 @@ import Foundation -extension FileManager { +public extension FileManager { func url(for file: String, in dir: String) throws -> URL { guard let docUrl = self.urls(for: .documentDirectory, in: .userDomainMask).first else { throw CocoaError(.fileReadNoSuchFile) diff --git a/AutoCatCore/Models/VehiclesArchive.swift b/AutoCatCore/Models/VehiclesArchive.swift new file mode 100644 index 0000000..b298e8f --- /dev/null +++ b/AutoCatCore/Models/VehiclesArchive.swift @@ -0,0 +1,66 @@ +// +// VehiclesArchive.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 19.01.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import SwiftUI + +public enum VehiclesArchiveError: LocalizedError { + + case filedCreateCsv + + public var errorDescription: String? { + switch self { + case .filedCreateCsv: "Failed to create csv data for vehicles" + } + } +} + +public final class VehiclesArchive { + + let vehicles: [VehicleDto] + + public init(vehiles: [VehicleDto]) { + + self.vehicles = vehiles + } + + func makeCsvString() throws -> String { + + var result = "" + let newLine: Character = "\r\n" + result.append(VehicleDto.csvHeader) + result.append(newLine) + + for vehicle in vehicles { + result.append(vehicle.csvLine) + result.append(newLine) + } + + return result + } +} + +extension VehiclesArchive: Transferable { + + public static var fileName: String { + "autocat.csv" + } + + public static var transferRepresentation: some TransferRepresentation { + + DataRepresentation(exportedContentType: .commaSeparatedText) { archive in + + let csvString = try archive.makeCsvString() + if let data = csvString.data(using: .utf8){ + return data + } else { + throw VehiclesArchiveError.filedCreateCsv + } + } + .suggestedFileName(fileName) + } +} diff --git a/AutoCatCore/Services/StorageService/StorageService.swift b/AutoCatCore/Services/StorageService/StorageService.swift index f7f9345..58738b1 100644 --- a/AutoCatCore/Services/StorageService/StorageService.swift +++ b/AutoCatCore/Services/StorageService/StorageService.swift @@ -37,6 +37,12 @@ public actor StorageService: StorageServiceProtocol { realm = try await Realm(configuration: config, actor: self) } + public var dbFileURL: URL? { + get async { + realm.configuration.fileURL + } + } + public func updateVehicleIfExists(dto: VehicleDto) async throws { guard realm.object(ofType: Vehicle.self, forPrimaryKey: dto.getNumber()) != nil else { diff --git a/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift b/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift index d0c93bd..dbc16d0 100644 --- a/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift +++ b/AutoCatCore/Services/StorageService/StorageServiceProtocol.swift @@ -7,10 +7,14 @@ // import Mockable +import Foundation @Mockable public protocol StorageServiceProtocol: Sendable { + // Generic + var dbFileURL: URL? { get async } + // Vehicles func loadVehicles() async -> [VehicleDto] func loadVehicle(number: String) async throws -> VehicleDto