diff --git a/.swiftpm/IIDadata.xctestplan b/.swiftpm/IIDadata.xctestplan new file mode 100644 index 0000000..0a6019a --- /dev/null +++ b/.swiftpm/IIDadata.xctestplan @@ -0,0 +1,34 @@ +{ + "configurations" : [ + { + "id" : "07C551C4-843E-4221-BA48-F07C2A0A0F21", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "environmentVariableEntries" : [ + { + "key" : "IIDadataAPIToken", + "value" : "" + } + ], + "targetForVariableExpansion" : { + "containerPath" : "container:", + "identifier" : "IIDadataTests", + "name" : "IIDadataTests" + } + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "IIDadataTests", + "name" : "IIDadataTests" + } + } + ], + "version" : 1 +} diff --git a/.swiftpm/IIDadataTests.xctestplan b/.swiftpm/IIDadataTests.xctestplan new file mode 100644 index 0000000..5978658 --- /dev/null +++ b/.swiftpm/IIDadataTests.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "F9030ED7-7FF5-4CF2-B852-33FE599D3767", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "IIDadataTests", + "name" : "IIDadataTests" + } + } + ], + "version" : 1 +} diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..9619a1d --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme new file mode 100644 index 0000000..0861079 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme similarity index 77% rename from Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme rename to .swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme index 4dfd744..79a22c2 100644 --- a/Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1600" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> + ReferencedContainer = "container:"> @@ -26,9 +27,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + ReferencedContainer = "container:"> diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme new file mode 100644 index 0000000..8e0638f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme b/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme new file mode 100644 index 0000000..e6a7a91 --- /dev/null +++ b/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-SPM.xcscheme b/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-SPM.xcscheme new file mode 100644 index 0000000..6e80183 --- /dev/null +++ b/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-SPM.xcscheme @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/IIDadata-Test.swiftpm/ContentView.swift b/Example/IIDadata-Test.swiftpm/ContentView.swift new file mode 100644 index 0000000..933ac3c --- /dev/null +++ b/Example/IIDadata-Test.swiftpm/ContentView.swift @@ -0,0 +1,284 @@ +import SwiftUI +import IIDadata +import IIDadataUI + +struct ExampleView: View { + // Properties + + let apiKey: String // = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" + + @StateObject private var dadata: DadataSuggestions + @State private var error: String? = nil + @State private var fio = "" + @State private var address = "Грибал" + + @State private var fioSuggestions: [FioSuggestion]? = [ + FioSuggestion( + "Иванов Иван Иванович", + unrestrictedValue: "Иванов Иван Иванович", + data: .init(surname: "Иванов", name: "Иван", patronymic: "Иванович", gender: .male, qc: nil) + )] + @State private var addressSuggestions: [AddressSuggestion]? = [ + AddressSuggestion( + value: "Санкт-Петербург", + data: .init(city: "Санкт-Петербург"), + unrestrictedValue: "Санкт-Петербург" + ), + ] + + @State private var isFioSuggestionsPresented = false + @State private var isAddressSuggestionsSuggestionsPresented = true + + // Lifecycle + + init() { + let apiKey = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" + self.apiKey = apiKey + _dadata = StateObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) + } + + // Content + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("**API key:** \(apiKey)").font(.subheadline) + if let error { + Text("**Error:** \(error)").font(.footnote) + .fontWeight(.medium).multilineTextAlignment(.center) + .foregroundStyle(.red) + } + Spacer() + TextField("Address", text: $address, prompt: Text("Enter address")) + .textFieldStyle(.roundedBorder) + .textContentType(.fullStreetAddress).tint(.blue) + .multilineTextAlignment(.leading) + .lineLimit(3) + .task(id: address, priority: .utility, getAddressSuggesttions) + .iidadataSuggestions( + apiKey: apiKey, + input: $address, + suggestions: $addressSuggestions, + isPresented: $isAddressSuggestionsSuggestionsPresented, + onSuggestionSelected: { + address = $0 + if addressSuggestions?.count == 1 { + isFioSuggestionsPresented = false + isAddressSuggestionsSuggestionsPresented = false + } + }) + TextField("FullName", text: $fio, prompt: Text("Enter FullName")) + .textFieldStyle(.roundedBorder) + .textContentType(.familyName).tint(.blue) + .multilineTextAlignment(.leading) + .lineLimit(3) + .task(id: fio, priority: .utility, getFioSuggesttions) + .iidadataSuggestions( + apiKey: apiKey, + input: $fio, + suggestions: $fioSuggestions, + isPresented: $isFioSuggestionsPresented, + onSuggestionSelected: { + fio = $0 + if fioSuggestions?.count == 1 { + isFioSuggestionsPresented = false + isAddressSuggestionsSuggestionsPresented = false + } + }) + Spacer() + } + .padding() + } + + @ViewBuilder + func SuggestionsList(_ suggestions: [String], value: Binding) -> some View { + if !suggestions.isEmpty { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(suggestions, id: \.self) { suggestion in + Button(suggestion) { + value.wrappedValue = suggestion + if suggestions.count == 1 { + isFioSuggestionsPresented = false + isAddressSuggestionsSuggestionsPresented = false + } + } + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: UIScreen.main.bounds.width - 44, alignment: .leading) + .tint(.secondary) + .multilineTextAlignment(.leading) + .safeAreaInset(edge: .top) { + Divider() + } + } + }.padding().fixedSize(horizontal: false, vertical: true) + } + } + } + + // Functions + + @Sendable @MainActor func getFioSuggesttions() async { + guard !fio.isEmpty else { return } + do { + error = nil + let suggestedFioResponce = try await dadata.suggestFio( + fio, + count: 10, + gender: .male, parts: [FioSuggestionQuery.Part.surname, .name, .patronymic] + ) + guard + !suggestedFioResponce.isEmpty + else { + throw DecodingError.dataCorrupted( + .init(codingPath: [], + debugDescription: "Suggested FIO Responce is Empty") + ) + } + fioSuggestions = suggestedFioResponce + isFioSuggestionsPresented = true + } catch { + isFioSuggestionsPresented = false + self.error = String(reflecting: error) + } + } + + @Sendable @MainActor func getAddressSuggesttions() async { + guard !address.isEmpty else { return } + do { + error = nil + let suggestedAddressResponce = try await dadata.suggestAddress( + address, + queryType: .address, + resultsCount: 10, + language: .ru, + upperScaleLimit: .city, + lowerScaleLimit: .flat, + trimRegionResult: true + ) + guard let addressSuggestions = suggestedAddressResponce.suggestions, + !addressSuggestions.isEmpty + else { + throw DecodingError.dataCorrupted( + .init( + codingPath: [], + debugDescription: "Suggested Address Responce is Empty or Nil" + ) + ) + } + self.addressSuggestions = addressSuggestions + isAddressSuggestionsSuggestionsPresented = true + } catch { + isAddressSuggestionsSuggestionsPresented = false + self.error = String(reflecting: error) + } + } +} + +#Preview { + ExampleView() +} + +//// MARK: - ContentView +// +///// A sample view demonstrating the usage of `IIDadataSuggestionsView`. +//struct ContentView: View { +// // Nested Types +// +// // MARK: - IIDadataViewModel +// +// /// The view model for managing address and FIO suggestions. +// class IIDadataViewModel: ObservableObject { +// // Properties +// +// @Published var address = "" +// @Published var fio = "" +// @Published var addressSuggestions: [AddressSuggestion]? +// @Published var fioSuggestions: [FioSuggestion]? +// +// private let dadata: DadataSuggestions +// +// // Lifecycle +// +// /// Initializes the `IIDadataViewModel` with the appropriate API key. +// /// +// /// The API key is fetched from the environment variables. +// init() { +// let apiKey = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" +// dadata = DadataSuggestions(apiKey: apiKey) +// } +// +// // Functions +// +// /// Fetches address suggestions based on the current address input. +// /// +// /// This function is called asynchronously and updates the `addressSuggestions` property. +// /// It performs a check to ensure the address input is not empty before fetching suggestions. +// @MainActor +// func getAddressSuggestions() async { +// guard !address.isEmpty else { return } +// do { +// addressSuggestions = try await dadata.suggestAddress( +// address, +// queryType: .address, +// resultsCount: 10, +// language: .ru +// ).suggestions +// } catch { +// print("Error fetching address suggestions: \(error)") +// } +// } +// +// /// Fetches FIO (Full Name) suggestions based on the current FIO input. +// /// +// /// This function is called asynchronously and updates the `fioSuggestions` property. +// /// It performs a check to ensure the FIO input is not empty before fetching suggestions. +// @MainActor +// func getFioSuggestions() async { +// guard !fio.isEmpty else { return } +// do { +// fioSuggestions = try await dadata.suggestFio( +// fio, +// count: 10, +// gender: .male, +// parts: [.surname, .name, .patronymic] +// ) +// } catch { +// print("Error fetching FIO suggestions: \(error)") +// } +// } +// } +// +// // Properties +// +// @StateObject private var viewModel = IIDadataViewModel() +// +// // Content +// +// var body: some View { +// VStack(spacing: 16) { +// IIDadataSuggestionsView( +// inputText: $viewModel.address, +// suggestions: $viewModel.addressSuggestions, +// placeholder: "Enter address", +// onSuggestionSelected: { _ in }, +// getSuggestions: viewModel.getAddressSuggestions +// ) +// IIDadataSuggestionsView( +// inputText: $viewModel.fio, +// suggestions: $viewModel.fioSuggestions, +// placeholder: "Enter Full Name", +// onSuggestionSelected: { _ in }, +// getSuggestions: viewModel.getFioSuggestions +// ) +// } +// .padding() +// } +//} +// +//struct ContentView_Previews: PreviewProvider { +// static var previews: some View { +// ContentView() +// } +//} diff --git a/Example/IIDadata-Test.swiftpm/MyApp.swift b/Example/IIDadata-Test.swiftpm/MyApp.swift new file mode 100644 index 0000000..480e02a --- /dev/null +++ b/Example/IIDadata-Test.swiftpm/MyApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ExampleView() + } + } +} diff --git a/Example/IIDadata-Test.swiftpm/Package.swift b/Example/IIDadata-Test.swiftpm/Package.swift new file mode 100644 index 0000000..ae59f70 --- /dev/null +++ b/Example/IIDadata-Test.swiftpm/Package.swift @@ -0,0 +1,62 @@ +// swift-tools-version: 6.0 + +// WARNING: +// This file is automatically generated. +// Do not edit it by hand because the contents will be replaced. + +import PackageDescription +import AppleProductTypes + +let package = Package( + name: "IIDadata SUI", + platforms: [ + .iOS("16.0") + ], + products: [ + .iOSApplication( + name: "IIDadata SUI", + targets: ["AppModule"], + bundleIdentifier: "spm.swiftui.IIDadata-Tests.IntegrationTest", + teamIdentifier: "UB936SP78M", + displayVersion: "1.0", + bundleVersion: "1", + appIcon: .placeholder(icon: .sparkle), + accentColor: .presetColor(.yellow), + supportedDeviceFamilies: [ + .pad, + .phone + ], + supportedInterfaceOrientations: [ + .portrait, + .landscapeRight, + .landscapeLeft, + .portraitUpsideDown(.when(deviceFamilies: [.pad])) + ], + capabilities: [ + .appTransportSecurity(configuration: .init( + exceptionDomains: [ + .init( + domainName: "suggestions.dadata.ru", + includesSubdomains: true, + exceptionAllowsInsecureHTTPLoads: true + ) + ] + )), + .outgoingNetworkConnections() + ], + appCategory: .developerTools + ) + ], + dependencies: [ + .package(path: "../..") + ], + targets: [ + .executableTarget( + name: "AppModule", + dependencies: [ + .product(name: "IIDadata", package: "iidadata") + ], + path: "." + ) + ] +) \ No newline at end of file diff --git a/Example/IIDadata.xcodeproj/project.pbxproj b/Example/IIDadata.xcodeproj/project.pbxproj index 165ae6b..f5a06dd 100644 --- a/Example/IIDadata.xcodeproj/project.pbxproj +++ b/Example/IIDadata.xcodeproj/project.pbxproj @@ -52,6 +52,7 @@ 7F635716033EB85D6E45087A /* Pods-IIDadata_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IIDadata_Tests.release.xcconfig"; path = "Target Support Files/Pods-IIDadata_Tests/Pods-IIDadata_Tests.release.xcconfig"; sourceTree = ""; }; BB65EEE2EDECA8BF67A58EF2 /* IIDadata.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = IIDadata.podspec; path = ../IIDadata.podspec; sourceTree = ""; }; E749FED1565D30A1C1AC21CB /* Pods_IIDadata_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_IIDadata_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F5AC60CC2C67A96800ECCF4B /* IIDadata-Test.swiftpm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = "IIDadata-Test.swiftpm"; sourceTree = ""; }; FA9B312D3F353E5C59D2145A /* Pods-IIDadata_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-IIDadata_Example.release.xcconfig"; path = "Target Support Files/Pods-IIDadata_Example/Pods-IIDadata_Example.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -78,6 +79,7 @@ 607FACC71AFB9204008FA782 = { isa = PBXGroup; children = ( + F5AC60CC2C67A96800ECCF4B /* IIDadata-Test.swiftpm */, 607FACF51AFB993E008FA782 /* Podspec Metadata */, 607FACD21AFB9204008FA782 /* Example for IIDadata */, 607FACE81AFB9204008FA782 /* Tests */, @@ -221,12 +223,12 @@ TargetAttributes = { 607FACCF1AFB9204008FA782 = { CreatedOnToolsVersion = 6.3.1; - DevelopmentTeam = 96ZSYGS638; + DevelopmentTeam = UB936SP78M; LastSwiftMigration = 1140; }; 607FACE41AFB9204008FA782 = { CreatedOnToolsVersion = 6.3.1; - DevelopmentTeam = 96ZSYGS638; + DevelopmentTeam = UB936SP78M; LastSwiftMigration = 1140; TestTargetID = 607FACCF1AFB9204008FA782; }; @@ -497,7 +499,7 @@ baseConfigurationReference = 27FEB4AC47CF1BA7638536C4 /* Pods-IIDadata_Example.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - DEVELOPMENT_TEAM = 96ZSYGS638; + DEVELOPMENT_TEAM = UB936SP78M; INFOPLIST_FILE = IIDadata/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; @@ -513,7 +515,7 @@ baseConfigurationReference = FA9B312D3F353E5C59D2145A /* Pods-IIDadata_Example.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - DEVELOPMENT_TEAM = 96ZSYGS638; + DEVELOPMENT_TEAM = UB936SP78M; INFOPLIST_FILE = IIDadata/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; MODULE_NAME = ExampleApp; @@ -528,7 +530,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 2ABC4B8C1F30EA9DF66C63C8 /* Pods-IIDadata_Tests.debug.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 96ZSYGS638; + DEVELOPMENT_TEAM = UB936SP78M; FRAMEWORK_SEARCH_PATHS = ( "$(PLATFORM_DIR)/Developer/Library/Frameworks", "$(inherited)", @@ -551,7 +553,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7F635716033EB85D6E45087A /* Pods-IIDadata_Tests.release.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = 96ZSYGS638; + DEVELOPMENT_TEAM = UB936SP78M; FRAMEWORK_SEARCH_PATHS = ( "$(PLATFORM_DIR)/Developer/Library/Frameworks", "$(inherited)", diff --git a/Example/IIDadata/Info.plist b/Example/IIDadata/Info.plist index 6c48029..e937298 100644 --- a/Example/IIDadata/Info.plist +++ b/Example/IIDadata/Info.plist @@ -22,6 +22,16 @@ 1 LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + suggestions.dadata.ru + + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/Example/Tests/Tests.swift b/Example/Tests/Tests.swift index 527fd05..fbc0681 100644 --- a/Example/Tests/Tests.swift +++ b/Example/Tests/Tests.swift @@ -1,18 +1,9 @@ +@testable import IIDadata import XCTest -import IIDadata -class Tests: XCTestCase { - - override func setUp() { - super.setUp() - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDown() { - // Put teardown code here. This method is called after the invocation of each test method in the class. - super.tearDown() - } - +final class Tests: XCTestCase { + // Computed Properties + // func testExample() { // // This is an example of a functional test case. // XCTAssert(true, "Pass") @@ -24,150 +15,186 @@ class Tests: XCTestCase { // // Put the code you want to measure the time of here. // } // } - private var apiToken: String { - // Switch this return to your <# API token #>. - DadataAPIConstants.token + private var apiToken: String { + // Switch this return to your <# API token #>. + DadataAPIConstants.token + } + + // Overridden Functions + + override func setUp() { + super.setUp() + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDown() { + // Put teardown code here. This method is called after the invocation of each test method in the class. + super.tearDown() + } + + // Functions + + // FIO Suggestion Tests + func testSuggestFIO_BasicQuery() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestFIO("Иванов") + XCTAssertGreaterThan(suggestions.count, 0) + } + + func testSuggestFIO_FilterByGender() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestFIO("Иванов", gender: .male) + XCTAssertGreaterThan(suggestions.count, 0) + XCTAssertEqual(suggestions.first?.gender, .male) + } + + func testSuggestFIO_FilterByParts() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestFIO("Иванов Иван", parts: [.surname, .firstname]) + XCTAssertGreaterThan(suggestions.count, 0) + XCTAssertEqual(suggestions.first?.surname, "Иванов") + XCTAssertEqual(suggestions.first?.firstname, "Иван") + } + + #warning("TODO: make separate test cases with assertions.") + func testAllCasesJustDoSomething() { + DadataSuggestions(apiKey: apiToken) + .suggestAddress( + "9120b43f-2fae-4838-a144-85e43c2bfb29", + queryType: .findByID, + resultsCount: 5, + language: nil, + constraints: ["{\"region\":\"Приморский\"}"], + regionPriority: nil, + upperScaleLimit: "street", + lowerScaleLimit: nil, + trimRegionResult: false + ) { try? $0.get().suggestions?.forEach { print($0) } } + + var constraint = AddressQueryConstraint() + constraint.region = "Приморский" + constraint.city = "Владивосток" + constraint.country_iso_code = "RU" + constraint.region_iso_code = "RU-PRI" + try! DadataSuggestions.shared( + apiKey: apiToken + ).suggestAddress( + "г Владивосток, Русский остров, поселок Аякс, д 1", + resultsCount: 1, + language: .ru, + constraints: [constraint], + regionPriority: nil, + upperScaleLimit: .street, + lowerScaleLimit: .house, + trimRegionResult: false + ) { try? $0.get().suggestions?.forEach { print("\(String(describing: $0.value)) \(String(describing: $0.data?.geoLat)) \(String(describing: $0.data?.geoLon))") } } + + try! DadataSuggestions.shared( + apiKey: apiToken + ).reverseGeocode(query: "43.026661, 131.8951698", + delimeter: ",", + resultsCount: 1, + language: "ru", + searchRadius: 100) { try? $0.get().suggestions?.forEach { print("\(String(describing: $0.value)) \(String(describing: $0.data?.geoLat)) \(String(describing: $0.data?.geoLon))") } } + + let q = AddressSuggestionQuery("9120b43f-2fae-4838-a144-85e43c2bfb29", ofType: .findByID) + q.resultsCount = 1 + q.language = .en + DadataSuggestions(apiKey: apiToken) + .suggestAddress(q) { try? $0.get().suggestions?.forEach { print($0) } } + + let dadata = try? DadataSuggestions + .shared( + apiKey: apiToken + ) + + dadata?.suggestAddressFromFIAS("Тверская обл, Пеновский р-н, деревня Москва") { print((try? $0.get().suggestions) ?? "Nothing") } + + dadata?.suggestAddressFromFIAS("Эрхирик") { print((try? $0.get().suggestions) ?? "Nothing") } + + dadata?.suggestAddress("Эрхирик трактовая 15", resultsCount: 1, constraints: [""]) { r in + let v = try? r.get() + if let s = v?.suggestions, s.count > 0 { + print("\(s[0].value!): LAT \(s[0].data!.geoLat!) @ LON \(s[0].data!.geoLon!)") + } } - - #warning("TODO: make separate test cases with assertions.") - func testAllCasesJustDoSomething(){ - DadataSuggestions(apiKey: apiToken) - .suggestAddress( - "9120b43f-2fae-4838-a144-85e43c2bfb29", - queryType: .findByID, - resultsCount: 5, - language: nil, - constraints: ["{\"region\":\"Приморский\"}"], - regionPriority: nil, - upperScaleLimit: "street", - lowerScaleLimit: nil, - trimRegionResult: false - ){ try? $0.get().suggestions?.forEach{ print($0) } } - - var constraint = AddressQueryConstraint() - constraint.region = "Приморский" - constraint.city = "Владивосток" - constraint.country_iso_code = "RU" - constraint.region_iso_code = "RU-PRI" - try! DadataSuggestions.shared( - apiKey: apiToken - ).suggestAddress( - "г Владивосток, Русский остров, поселок Аякс, д 1", - resultsCount: 1, - language: .ru, - constraints: [constraint], - regionPriority: nil, - upperScaleLimit: .street, - lowerScaleLimit: .house, - trimRegionResult: false - ){ try? $0.get().suggestions?.forEach{ print("\(String(describing: $0.value)) \(String(describing: $0.data?.geoLat)) \(String(describing: $0.data?.geoLon))") } } - - try! DadataSuggestions.shared( - apiKey: apiToken - ).reverseGeocode(query: "43.026661, 131.8951698", - delimeter: ",", - resultsCount: 1, - language:"ru", - searchRadius: 100){ try? $0.get().suggestions?.forEach{ print("\(String(describing: $0.value)) \(String(describing: $0.data?.geoLat)) \(String(describing: $0.data?.geoLon))") } } - - - - let q = AddressSuggestionQuery("9120b43f-2fae-4838-a144-85e43c2bfb29", ofType: .findByID) - q.resultsCount = 1 - q.language = .en - DadataSuggestions(apiKey: apiToken) - .suggestAddress(q){ try? $0.get().suggestions?.forEach{ print($0) } } - - let dadata = try? DadataSuggestions - .shared( - apiKey: apiToken - ) - - dadata?.suggestAddressFromFIAS("Тверская обл, Пеновский р-н, деревня Москва"){ print( (try? $0.get().suggestions) ?? "Nothing" ) } - - dadata?.suggestAddressFromFIAS("Эрхирик"){ print( (try? $0.get().suggestions) ?? "Nothing" ) } - - dadata?.suggestAddress("Эрхирик трактовая 15", resultsCount: 1, constraints: [""]){r in - let v = try? r.get() - if let s = v?.suggestions, s.count > 0{ - print("\(s[0].value!): LAT \(s[0].data!.geoLat!) @ LON \(s[0].data!.geoLon!)") - } - } - - dadata?.suggestByKLADRFIAS("9120b43f-2fae-4838-a144-85e43c2bfb29"){ print( (try? $0.get().suggestions) ?? "Nothing" ) } - - try? dadata?.reverseGeocode(query: "52.2620898, 104.3203629", - delimeter: ",", - resultsCount: 1, - language:"ru", - searchRadius: 100){ r in - let v = try? r.get() - if let s = v?.suggestions, s.count > 0{ - print("\(s[0].value!): LAT#\(s[0].data!.geoLat!) @ LON#\(s[0].data!.geoLon!)") - } - } - - dadata?.reverseGeocode(latitude: 51.5346, - longitude: 107.4937, - resultsCount: 1, - language: .ru, - searchRadius: 1000){ r in - let v = try? r.get() - if let s = v?.suggestions, s.count > 0{ - print("\(s[0].value!): LAT#\(s[0].data!.geoLat!) @ LON#\(s[0].data!.geoLon!)") - } - } - - - dadata?.suggestAddress( - "Пенза московская 1", - resultsCount: 1, + + dadata?.suggestByKLADRFIAS("9120b43f-2fae-4838-a144-85e43c2bfb29") { print((try? $0.get().suggestions) ?? "Nothing") } + + try? dadata?.reverseGeocode(query: "52.2620898, 104.3203629", + delimeter: ",", + resultsCount: 1, + language: "ru", + searchRadius: 100) + { r in + let v = try? r.get() + if let s = v?.suggestions, s.count > 0 { + print("\(s[0].value!): LAT#\(s[0].data!.geoLat!) @ LON#\(s[0].data!.geoLon!)") + } + } + + dadata?.reverseGeocode(latitude: 51.5346, + longitude: 107.4937, + resultsCount: 1, + language: .ru, + searchRadius: 1000) + { r in + let v = try? r.get() + if let s = v?.suggestions, s.count > 0 { + print("\(s[0].value!): LAT#\(s[0].data!.geoLat!) @ LON#\(s[0].data!.geoLon!)") + } + } + + dadata?.suggestAddress( + "Пенза московская 1", + resultsCount: 1, + language: "en", + completion: { r in + switch r { + case let .success(v): + print(v.suggestions?[0].value as Any) + try! dadata?.reverseGeocode( + query: "\(v.suggestions![0].data!.geoLat!), \(v.suggestions![0].data!.geoLon!)", language: "en", - completion: { r in - switch r{ - case .success(let v): - print(v.suggestions?[0].value as Any) - try! dadata?.reverseGeocode( - query: "\(v.suggestions![0].data!.geoLat!), \(v.suggestions![0].data!.geoLon!)", - language: "en", - searchRadius: 100){ r in - switch r { - case .success(let v): - print(v.suggestions?[0].unrestrictedValue as Any) - return - case .failure(let e): - print(e) - return - } - } - return - case .failure(let e): - print(e) - return - } - } - ) - - try! dadata?.reverseGeocode(query: "52.2620898, 104.3203629", - delimeter: ",", - resultsCount: 1, - language:"ru", - searchRadius: 100){ r in + searchRadius: 100 + ) { r in switch r { - case .success(let v): - print(v) - return - case .failure(let e): - print(e) - return + case let .success(v): + print(v.suggestions?[0].unrestrictedValue as Any) + return + case let .failure(e): + print(e) + return } + } + return + case let .failure(e): + print(e) + return } - - do{ - _ = try DadataSuggestions(apiKey: "some-garbage-token", checkWithTimeout: 15) - } catch let e { - print(e) - } + } + ) + + try! dadata?.reverseGeocode(query: "52.2620898, 104.3203629", + delimeter: ",", + resultsCount: 1, + language: "ru", + searchRadius: 100) + { r in + switch r { + case let .success(v): + print(v) + return + case let .failure(e): + print(e) + return + } + } + + do { + _ = try DadataSuggestions(apiKey: "some-garbage-token", checkWithTimeout: 15) + } catch let e { + print(e) } - + } } diff --git a/IIDadata/Example/ContentView.swift b/IIDadata/Example/ContentView.swift new file mode 100644 index 0000000..f7f20c3 --- /dev/null +++ b/IIDadata/Example/ContentView.swift @@ -0,0 +1,63 @@ +// +// ContentView.swift +// IIDadata +// +// Created by NSFuntik on 11.08.2024. +// + +import IIDadata +import IIDadataUI +import SwiftUI + +// MARK: - IIDadataDemo + +@available(iOS 15.0, *) +struct IIDadataDemo: View { + // Properties + + @State var text = "Миха" + @State var suggestions: [FioSuggestion]? { + willSet { + debugPrint(suggestions ?? [], separator: "\n ● ") + } + } + @State var isPresented = false + let apiKey: String // = ProcessInfo.processInfo.environment["IIDadataAPIToken"] + + // Lifecycle + + init() { + apiKey = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" + + _suggestions = State(initialValue: []) + } + + // Content + + var body: some View { + TextField("ФИО", text: $text, prompt: Text("ФИО")) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + .font(.body) + .withDadataSuggestions( + apiKey: apiKey, + input: $text, + suggestions: $suggestions, + textfieldHeight: 44 + ) { s in + debugPrint(s) + text = s.value + if suggestions?.count == 1 { + isPresented = false + } + } + } +} + +// MARK: - PreviewProvider +@available(iOS 15.0, *) +struct IIDadataDemo_Previews: PreviewProvider { + static var previews: some View { + IIDadataDemo() + } +} diff --git a/IIDadata/Sources/Constants.swift b/IIDadata/Sources/Constants.swift deleted file mode 100644 index 3a22ae7..0000000 --- a/IIDadata/Sources/Constants.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Constants.swift -// IIDadata -// -// Created by Yachin Ilya on 11.05.2020. -// - -import Foundation - -struct Constants { - static let suggestionsAPIURL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/" - static let addressEndpoint = AddressQueryType.address.rawValue - static let addressFIASOnlyEndpoint = AddressQueryType.fiasOnly.rawValue - static let addressByIDEndpoint = AddressQueryType.findByID.rawValue - static let revGeocodeEndpoint = "geolocate/address" - static let infoPlistTokenKey = "IIDadataAPIToken" -} - -///API endpoints for different request types. -public enum AddressQueryType: String { - case address = "suggest/address" - case fiasOnly = "suggest/fias" - case findByID = "findById/address" -} - -///Language of response. -public enum QueryResultLanguage: String, Encodable { - case ru = "ru" - case en = "en" -} diff --git a/IIDadata/Sources/DadataQueryProtocol.swift b/IIDadata/Sources/DadataQueryProtocol.swift deleted file mode 100644 index b77ebfb..0000000 --- a/IIDadata/Sources/DadataQueryProtocol.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// DadataQueryProtocol.swift -// IIDadata -// -// Created by Yachin Ilya on 12.05.2020. -// - -import Foundation - -protocol DadataQueryProtocol { - func queryEndpoint() -> String - func toJSON() throws -> Data -} - - diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift deleted file mode 100644 index ea11b8d..0000000 --- a/IIDadata/Sources/DadataSuggestions.swift +++ /dev/null @@ -1,350 +0,0 @@ -import Foundation - -///DadataSuggestions performs all the interactions with Dadata API. -public class DadataSuggestions { - private let apiKey: String - private var suggestionsAPIURL: URL - private static var sharedInstance: DadataSuggestions? - - ///New instance of DadataSuggestions. - /// - /// - ///Required API key is read from Info.plist. Each init creates new instance using same token. - ///If DadataSuggestions is used havily consider `DadataSuggestions.shared()` instead. - ///- Precondition: Token set with "IIDadataAPIToken" key in Info.plist. - ///- Throws: Call may throw if there isn't a value for key "IIDadataAPIToken" set in Info.plist. - public convenience init() throws { - let key = try DadataSuggestions.readAPIKeyFromPlist() - self.init(apiKey: key) - } - - ///This init checks connectivity once the class instance is set. - /// - ///This init should not be called on main thread as it may take up long time as it makes request to server in a blocking manner. - ///Throws if connection is impossible or request is timed out. - ///``` - ///DispatchQueue.global(qos: .background).async { - /// let dadata = try DadataSuggestions(apiKey: "<# Dadata API token #>", checkWithTimeout: 15) - ///} - ///``` - ///- Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. - ///- Parameter checkWithTimeout: Time in seconds to wait for response. - /// - ///- Throws: May throw on connectivity problems, missing or wrong API token, limits exeeded, wrong endpoint. - ///May throw if request is timed out. - public convenience init(apiKey: String, checkWithTimeout timeout: Int) throws { - self.init(apiKey: apiKey) - try checkAPIConnectivity(timeout: timeout) - } - - ///New instance of DadataSuggestions. - ///- Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. - public convenience required init(apiKey: String) { - self.init(apiKey: apiKey, url: Constants.suggestionsAPIURL) - } - - private init(apiKey: String, url: String) { - self.apiKey = apiKey - self.suggestionsAPIURL = URL(string: url)! - } - - ///Get shared instance of DadataSuggestions class. - /// - ///Call may throw if neither apiKey parameter is provided - ///nor a value for key "IIDadataAPIToken" is set in Info.plist - ///whenever shared instance weren't instantiated earlier. - ///If another apiKey provided new shared instance of DadataSuggestions recreated with the provided API token. - ///- Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. - public static func shared(apiKey: String? = nil) throws -> DadataSuggestions { - if let instance = sharedInstance, instance.apiKey == apiKey || apiKey == nil { return instance } - - - if let key = apiKey { sharedInstance = DadataSuggestions(apiKey: key); return sharedInstance! } - - let key = try readAPIKeyFromPlist() - sharedInstance = DadataSuggestions(apiKey: key) - return sharedInstance! - } - - private static func readAPIKeyFromPlist() throws -> String { - var dictionary: NSDictionary? - if let path = Bundle.main.path(forResource: "Info", ofType: "plist") { - dictionary = NSDictionary(contentsOfFile: path) - } - guard let key = dictionary?.value(forKey: Constants.infoPlistTokenKey) as? String else { - throw NSError(domain: "Dadata API key missing in Info.plist", code: 1, userInfo: nil ) - } - return key - } - - private func checkAPIConnectivity(timeout: Int) throws { - var request = createRequest(url:suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) - request.timeoutInterval = TimeInterval(timeout) - - let semaphore = DispatchSemaphore.init(value: 0) - var errorValue: Error? - - let session = URLSession.shared - session.dataTask(with: request){[weak self] data,response,error in - defer { semaphore.signal() } - if error != nil { errorValue = error; return } - if let response = (response as? HTTPURLResponse), (200...299 ~= response.statusCode) == false { - errorValue = self?.nonOKResponseToError(response: response, body: data) - return - } - }.resume() - - semaphore.wait() - - if let e = errorValue{ - throw e - } - } - - private func createRequest(url: URL)->URLRequest{ - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") - request.addValue("Token " + apiKey, forHTTPHeaderField: "Authorization") - return request - } - - private func nonOKResponseToError(response: HTTPURLResponse, body data: Data?)->Error{ - let code = response.statusCode - var info: [String: Any] = [:] - response.allHeaderFields.forEach{ if let k = $0.key as? String { info[k] = $0.value } } - if let data = data { - let object = try? JSONSerialization.jsonObject(with: data, options: []) as? [AnyHashable: Any] - object?.forEach{ if let k = $0.key as? String { info[k] = $0.value } } - } - return NSError(domain: "HTTP Status \(HTTPURLResponse.localizedString(forStatusCode: code))", code: code, userInfo: info) - } - - ///Basic address suggestions request with only rquired data. - /// - ///- Parameter query: Query string to send to API. String of a free-form e.g. address part. - ///- Parameter completion: Result handler. - ///- Parameter result: result of address suggestion query. - public func suggestAddress(_ query: String, completion: @escaping (_ result: Result)->Void){ - suggestAddress(AddressSuggestionQuery(query), completion: completion) - } - - ///Address suggestions request. - /// - ///Limitations, filters and constraints may be applied to query. - /// - ///- Parameter query: Query string to send to API. String of a free-form e.g. address part. - ///- Parameter queryType: Lets select whether the request type. There are 3 query types available: - ///`address` — standart address suggestion query; - ///`fiasOnly` — query to only search in FIAS database: less matches, state provided address data only; - ///`findByID` — takes KLADR or FIAS ID as a query parameter to lookup additional data. - ///- Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object - ///including latitude and longitude. `20` is a maximum value. - ///- Parameter language: Suggested results may be in Russian or English. - ///- Parameter constraints: List of `AddressQueryConstraint` objects to filter results. - ///- Parameter regionPriority: List of RegionPriority objects to prefer in lookup. - ///- Parameter upperScaleLimit: Bigger `ScaleLevel` object in pair of scale limits. - ///- Parameter lowerScaleLimit: Smaller `ScaleLevel` object in pair of scale limits. - ///- Parameter trimRegionResult: Remove region and city names from suggestion top level. - ///- Parameter completion: Result handler. - ///- Parameter result: result of address suggestion query. - public func suggestAddress(_ query: String, - queryType: AddressQueryType = .address, - resultsCount: Int? = 10, - language: QueryResultLanguage? = nil, - constraints: [AddressQueryConstraint]? = nil, - regionPriority: [RegionPriority]? = nil, - upperScaleLimit: ScaleLevel? = nil, - lowerScaleLimit: ScaleLevel? = nil, - trimRegionResult: Bool = false, - completion: @escaping (_ result: Result)->Void){ - - let suggestionQuery = AddressSuggestionQuery(query, ofType: queryType) - - suggestionQuery.resultsCount = resultsCount - suggestionQuery.language = language - suggestionQuery.constraints = constraints - suggestionQuery.regionPriority = regionPriority - suggestionQuery.upperScaleLimit = upperScaleLimit != nil ? ScaleBound(value: upperScaleLimit) : nil - suggestionQuery.lowerScaleLimit = upperScaleLimit != nil ? ScaleBound(value: lowerScaleLimit) : nil - suggestionQuery.trimRegionResult = trimRegionResult - - suggestAddress(suggestionQuery, completion: completion) - } - - ///Address suggestions request. - /// - ///Allows to pass most of arguments as a strings converting to internally used classes. - /// - ///- Parameter query: Query string to send to API. String of a free-form e.g. address part. - ///- Parameter queryType: Lets select whether the request type. There are 3 query types available: - ///`address` — standart address suggestion query; - ///`fiasOnly` — query to only search in FIAS database: less matches, state provided address data only; - ///`findByID` — takes KLADR or FIAS ID as a query parameter to lookup additional data. - ///- Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object - ///including latitude and longitude. `20` is a maximum value. - ///- Parameter language: Suggested results in "ru" — Russian or "en" — English. - ///- Parameter constraints: Literal JSON string formated according to - ///[Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=204669108). - ///- Parameter regionPriority: List of regions' KLADR IDs to prefer in lookup as shown in - ///[Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=285343795). - ///- Parameter upperScaleLimit: Bigger sized object in pair of scale limits. - ///- Parameter lowerScaleLimit: Smaller sized object in pair of scale limits. Both can take following values: - ///`country` — Страна, - ///`region` — Регион, - ///`area` — Район, - ///`city` — Город, - ///`settlement` — Населенный пункт, - ///`street` — Улица, - ///`house` — Дом, - ///`country` — Страна, - ///- Parameter trimRegionResult: Remove region and city names from suggestion top level. - ///- Parameter completion: Result handler. - ///- Parameter result: result of address suggestion query. - public func suggestAddress(_ query: String, - queryType: AddressQueryType = .address, - resultsCount: Int? = 10, - language: String? = nil, - constraints: [String]? = nil, - regionPriority: [String]? = nil, - upperScaleLimit: String? = nil, - lowerScaleLimit: String? = nil, - trimRegionResult: Bool = false, - completion: @escaping (_ result: Result)->Void){ - - let queryConstraints: [AddressQueryConstraint]? = constraints?.compactMap{ - if let data = $0.data(using: .utf8) { - return try? JSONDecoder().decode(AddressQueryConstraint.self, from: data ) - } - return nil - } - let prefferedRegions: [RegionPriority]? = regionPriority?.compactMap{ RegionPriority(kladr_id: $0) } - - suggestAddress(query, - queryType: queryType, - resultsCount: resultsCount, - language: QueryResultLanguage(rawValue: language ?? "ru"), - constraints: queryConstraints, - regionPriority: prefferedRegions, - upperScaleLimit: ScaleLevel(rawValue: upperScaleLimit ?? "*"), - lowerScaleLimit: ScaleLevel(rawValue: lowerScaleLimit ?? "*"), - trimRegionResult: trimRegionResult, - completion: completion) - } - - ///Basic address suggestions request to only search in FIAS database: less matches, state provided address data only. - /// - ///- Parameter query: Query string to send to API. String of a free-form e.g. address part. - ///- Parameter completion: Result handler. - ///- Parameter result: result of address suggestion query. - public func suggestAddressFromFIAS(_ query: String, completion: @escaping (_ result: Result)->Void){ - suggestAddress(AddressSuggestionQuery(query, ofType: .fiasOnly), completion: completion) - } - - ///Basic address suggestions request takes KLADR or FIAS ID as a query parameter to lookup additional data. - /// - ///- Parameter query: KLADR or FIAS ID. - ///- Parameter completion: Result handler. - ///- Parameter result: result of address suggestion query. - public func suggestByKLADRFIAS(_ query: String, completion: @escaping (_ result: Result)->Void){ - suggestAddress(AddressSuggestionQuery(query, ofType: .findByID), completion: completion) - } - - ///Address suggestion request with custom `AddressSuggestionQuery`. - /// - ///- Parameter query: Query object. - ///- Parameter completion: Result handler. - ///- Parameter result: result of address suggestion query. - public func suggestAddress(_ query: AddressSuggestionQuery, completion: @escaping (_ result: Result)->Void){ - fetchResponse(withQuery: query, completionHandler: completion) - } - - ///Reverse Geocode request with latitude and longitude as a single string. - /// - ///- Throws: May throw if query is malformed. - /// - ///- Parameter query: Latitude and longitude as a string. Should have single character separator. - ///- Parameter delimeter: Character to separate latitude and longitude. Defaults to '`,`' - ///- Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object - ///including latitude and longitude. `20` is a maximum value. - ///- Parameter language: Suggested results in "ru" — Russian or "en" — English. - ///- Parameter searchRadius: Radius to suggest objects nearest to coordinates point. - ///- Parameter completion: Result handler. - ///- Parameter result: result of reverse geocode query. - public func reverseGeocode(query: String, - delimeter: Character = ",", - resultsCount: Int? = 10, - language: String? = "ru", - searchRadius: Int? = nil, - completion: @escaping (_ result: Result)->Void) throws { - - let geoquery = try ReverseGeocodeQuery(query: query, delimeter: delimeter) - geoquery.resultsCount = resultsCount - geoquery.language = QueryResultLanguage(rawValue: language ?? "ru") - geoquery.searchRadius = searchRadius - - reverseGeocode(geoquery, completion: completion) - } - - ///Reverse Geocode request with latitude and longitude as a single string. - /// - ///- Parameter latitude: Latitude. - ///- Parameter longitude: Longitude. - ///- Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object - ///including latitude and longitude. `20` is a maximum value. - ///- Parameter language: Suggested results may be in Russian or English. - ///- Parameter searchRadius: Radius to suggest objects nearest to coordinates point. - ///- Parameter completion: Result handler. - ///- Parameter result: result of reverse geocode query. - public func reverseGeocode(latitude: Double, - longitude: Double, - resultsCount: Int? = 10, - language: QueryResultLanguage? = nil, - searchRadius: Int? = nil, - completion: @escaping (_ result: Result)->Void){ - let geoquery = ReverseGeocodeQuery(latitude: latitude, longitude: longitude) - geoquery.resultsCount = resultsCount - geoquery.language = language - geoquery.searchRadius = searchRadius - - fetchResponse(withQuery: geoquery, completionHandler: completion) - } - - ///Reverse geocode request with custom `ReverseGeocodeQuery`. - /// - ///- Parameter query: Query object. - ///- Parameter completion: Result handler. - ///- Parameter result: result of reverse geocode query. - public func reverseGeocode(_ query: ReverseGeocodeQuery, completion: @escaping (_ result: Result)->Void){ - fetchResponse(withQuery: query, completionHandler: completion) - } - - private func fetchResponse(withQuery query: DadataQueryProtocol, completionHandler completion: @escaping (Result)->Void) where T: Decodable { - var request = createRequest(url:suggestionsAPIURL.appendingPathComponent(query.queryEndpoint())) - request.httpBody = try? query.toJSON() - let session = URLSession.shared - session.dataTask(with: request){data,response,error in - - if let error = error { - completion(.failure(error)) - return - } - if let response = (response as? HTTPURLResponse), (200...299 ~= response.statusCode) == false { - completion(.failure(NSError(domain: "Dadata HTTP response", code: response.statusCode, userInfo: ["description": response.description]))) - return - } - guard let data = data else { - completion(.failure(NSError(domain: "Dadata HTTP response", code: -1, userInfo: ["description": "missing data in response"]))) - return - } - do { - let result = try JSONDecoder().decode(T.self, from: data) - completion(.success(result)) - return - } catch let e { - completion(.failure(e)) - return - } - }.resume() - } -} diff --git a/IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift b/IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift deleted file mode 100644 index 9b798db..0000000 --- a/IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// KeyedDecodingContainer+decodeJSONNumber.swift -// IIDadata -// -// Created by Yachin Ilya on 23.07.2020. -// - -import Foundation - -extension KeyedDecodingContainer { - /// Forces integers and floating point numbers to optional String type. - /// Helpful when JSON response may include inconsistency in number fields. - /// - parameter key: CodingKey of Decodable object CodingKeys to lookup. - func decodeJSONNumber(forKey key: CodingKey) -> String? { - if let v = try? decode(String.self, forKey: key as! K) { - return v - } - if let v = try? decode(Int.self, forKey: key as! K) { - return "\(v)" - } - if let v = try? decode(Double.self, forKey: key as! K) { - return "\(v)" - } - return nil - } -} diff --git a/IIDadata/Sources/Model/AddressSuggestionQuery.swift b/IIDadata/Sources/Model/AddressSuggestionQuery.swift deleted file mode 100644 index fbdb97c..0000000 --- a/IIDadata/Sources/Model/AddressSuggestionQuery.swift +++ /dev/null @@ -1,157 +0,0 @@ -// -// AddressSuggestionQuery.swift -// IIDadata -// -// Created by Yachin Ilya on 12.05.2020. -// - -import Foundation - -///AddressSuggestionQuery represents an serializable object used to perform certain queries. -public class AddressSuggestionQuery: Encodable, DadataQueryProtocol{ - let query: String - let queryType: AddressQueryType - public var resultsCount: Int? = 10 - public var language: QueryResultLanguage? - public var constraints: [AddressQueryConstraint]? - public var regionPriority: [RegionPriority]? - public var upperScaleLimit: ScaleBound? - public var lowerScaleLimit: ScaleBound? - public var trimRegionResult: Bool = false - - ///New instance of AddressSuggestionQuery defaulting to simple address suggestions request. - ///- Parameter query: Query string to be sent to API. - public convenience init(_ query: String){ - self.init(query, ofType: .address) - } - - ///New instance of AddressSuggestionQuery. - ///- Parameter query: Query string to be sent to API. - ///- Parameter ofType: Type of request to send to API. - ///It could be of type - ///`address` — standart address suggestion query; - ///`fiasOnly` — query to only search in FIAS database: less matches, state provided address data only; - ///`findByID` — takes KLADR or FIAS ID as a query parameter to lookup additional data. - public required init(_ query: String, ofType type: AddressQueryType){ - self.query = query - self.queryType = type - } - - enum CodingKeys: String, CodingKey { - case query - case resultsCount = "count" - case language - case constraints = "locations" - case regionPriority = "locations_boost" - case upperScaleLimit = "from_bound" - case lowerScaleLimit = "to_bound" - case trimRegionResult = "restrict_value" - } - - ///Serializes AddressSuggestionQuery to send over the wire. - func toJSON() throws -> Data { - if constraints?.isEmpty ?? false { constraints = nil } - if regionPriority?.isEmpty ?? false { regionPriority = nil } - if let upper = upperScaleLimit, - let lower = lowerScaleLimit, - upper.value == nil || lower.value == nil { - upperScaleLimit = nil - lowerScaleLimit = nil - } - return try JSONEncoder().encode(self) - } - - ///Returns an API endpoint for different request types: - ///`address` — "suggest/address" - ///`fiasOnly` — "suggest/fias" - ///`findByID` — "findById/address" - func queryEndpoint() -> String { return queryType.rawValue } -} - -///Levels of `from_bound` and `to_bound` according to -///[Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=285343795). -public enum ScaleLevel: String, Encodable { - case country = "country" - case region = "region" - case area = "area" - case city = "city" - case settlement = "settlement" - case street = "street" - case house = "house" -} - -///AddressQueryConstraint used to limit search results according to -///[Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=204669108). -public struct AddressQueryConstraint: Codable{ - public var region : String? - public var city : String? - public var street_type_full : String? - public var settlement_type_full : String? - public var city_district_type_full : String? - public var city_type_full : String? - public var area_type_full : String? - public var region_type_full : String? - public var country : String? - public var country_iso_code : String? - public var region_iso_code : String? - public var kladr_id : String? - public var region_fias_id : String? - public var area_fias_id : String? - public var city_fias_id : String? - public var settlement_fias_id : String? - public var street_fias_id : String? - - public init( - region: String? = nil, - city: String? = nil, - street_type_full: String? = nil, - settlement_type_full: String? = nil, - city_district_type_full: String? = nil, - city_type_full: String? = nil, - area_type_full: String? = nil, - region_type_full: String? = nil, - country: String? = nil, - country_iso_code: String? = nil, - region_iso_code: String? = nil, - kladr_id: String? = nil, - region_fias_id: String? = nil, - area_fias_id: String? = nil, - city_fias_id: String? = nil, - settlement_fias_id: String? = nil, - street_fias_id: String? = nil - ) { - self.region = region - self.city = city - self.street_type_full = street_type_full - self.settlement_type_full = settlement_type_full - self.city_district_type_full = city_district_type_full - self.city_type_full = city_type_full - self.area_type_full = area_type_full - self.region_type_full = region_type_full - self.country = country - self.country_iso_code = country_iso_code - self.region_iso_code = region_iso_code - self.kladr_id = kladr_id - self.region_fias_id = region_fias_id - self.area_fias_id = area_fias_id - self.city_fias_id = city_fias_id - self.settlement_fias_id = settlement_fias_id - self.street_fias_id = street_fias_id - } -} - -///Helps prioritize specified region in search results by KLADR ID. -public struct RegionPriority: Encodable{ - public var kladr_id: String? - - public init(kladr_id: String?){ self.kladr_id = kladr_id } -} - -///ScaleBound holds a value for `from_bound` and `to_bound` as a ScaleLevel. -///See -///[Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=285343795) for API reference. -public struct ScaleBound: Encodable{ - public var value: ScaleLevel? - - public init(value: ScaleLevel?){ self.value = value } -} diff --git a/IIDadata/Sources/Model/AddressSuggestionResponse.swift b/IIDadata/Sources/Model/AddressSuggestionResponse.swift deleted file mode 100644 index 4e6da1c..0000000 --- a/IIDadata/Sources/Model/AddressSuggestionResponse.swift +++ /dev/null @@ -1,363 +0,0 @@ -// -// Models Generated using http://www.jsoncafe.com/ -// Created on May 11, 2020 - -import Foundation - -///AddressSuggestionResponse represents a deserializable object used to hold API response. -public struct AddressSuggestionResponse : Decodable { - public let suggestions : [AddressSuggestions]? -} - -///Every single suggestion is represented as AddressSuggestions. -public struct AddressSuggestions : Decodable { - ///Address in short format. - public let value : String? - ///All the data returned in response to suggestion query. - public let data : AddressSuggestionData? - ///Address in long format with region. - public let unrestrictedValue : String? - - enum CodingKeys: String, CodingKey { - case value - case data - case unrestrictedValue = "unrestricted_value" - } -} - -///All the data returned in response to suggestion query. -public struct AddressSuggestionData : Decodable { - public let area : String? - public let areaFiasId : String? - public let areaKladrId : String? - public let areaType : String? - public let areaTypeFull : String? - public let areaWithType : String? - public let beltwayDistance : String? - public let beltwayHit : String? - public let block : String? - public let blockType : String? - public let blockTypeFull : String? - public let building : String? - public let buildingType : String? - public let cadastralNumber : String? - /// - `1` — subregion (district) center - /// - `2` — region center - /// - `3` — `1` and `2` combined - /// - `4` — main subregion (district) in region - /// - `0` — no status - public let capitalMarker : String? - public let city : String? - public let cityArea : String? - public let cityDistrict : String? - public let cityDistrictFiasId : String? - public let cityDistrictKladrId : String? - public let cityDistrictType : String? - public let cityDistrictTypeFull : String? - public let cityDistrictWithType : String? - public let cityFiasId : String? - public let cityKladrId : String? - public let cityType : String? - public let cityTypeFull : String? - public let cityWithType : String? - public let country : String? - public let countryIsoCode : String? - public let federalDistrict : String? - /// FIAS actuality - /// - `0` — actual - /// - `1–50` — renamed - /// - `51` — changed - /// - `99` — removed - public let fiasActualityState : String? - /// Structure of FIAS code (СС+РРР+ГГГ+ППП+СССС+УУУУ+ДДДД) - public let fiasCode : String? - public let fiasId : String? - /// FIAS address precision - /// - `0` — country - /// - `1` — region - /// - `3` — subregion (district of region) - /// - `4` — city - /// - `5` — city district - /// - `6` — locality, neighbourhood, settlement etc. - /// - `7` — street - /// - `8` — building - /// - `9` — flat / apartment - /// - `65` — city plan unit - /// - `-1` — empty or abroad - public let fiasLevel : String? - public let flat : String? - public let flatFiasId : String? - public let flatArea : String? - public let flatPrice : String? - public let flatType : String? - public let flatTypeFull : String? - public let geoLat : String? - public let geoLon : String? - public let geonameId : String? - public let historyValues : [String]? - public let house : String? - public let houseFiasId : String? - public let houseKladrId : String? - public let houseType : String? - public let houseTypeFull : String? - public let kladrId : String? - public let metro : [Metro]? - public let okato : String? - public let oktmo : String? - public let planningStructure : String? - public let planningStructureFiasId : String? - public let planningStructureKladrId : String? - public let planningStructureType : String? - public let planningStructureTypeFull : String? - public let planningStructureWithType : String? - public let postalBox : String? - public let postalCode : String? - public let qc : String? - public let qcComplete : String? - /// Coordinates precision - /// - `0` — precise - /// - `1` — nearest building - /// - `2` — nearest street - /// - `3` — city district, locality, neighbourhood, settlement etc. - /// - `4` — city - /// - `5` — failed to determine coordinates - public let qcGeo : String? - public let qcHouse : String? - public let region : String? - public let regionFiasId : String? - public let regionIsoCode : String? - public let regionKladrId : String? - public let regionType : String? - public let regionTypeFull : String? - public let regionWithType : String? - public let settlement : String? - public let settlementFiasId : String? - public let settlementKladrId : String? - public let settlementType : String? - public let settlementTypeFull : String? - public let settlementWithType : String? - public let source : String? - public let squareMeterPrice : String? - public let street : String? - public let streetFiasId : String? - public let streetKladrId : String? - public let streetType : String? - public let streetTypeFull : String? - public let streetWithType : String? - public let taxOffice : String? - public let taxOfficeLegal : String? - public let timezone : String? - public let unparsedParts : String? - - enum CodingKeys: String, CodingKey { - case area = "area" - case areaFiasId = "area_fias_id" - case areaKladrId = "area_kladr_id" - case areaType = "area_type" - case areaTypeFull = "area_type_full" - case areaWithType = "area_with_type" - case beltwayDistance = "beltway_distance" - case beltwayHit = "beltway_hit" - case block = "block" - case blockType = "block_type" - case blockTypeFull = "block_type_full" - case building = "building" - case buildingType = "building_type" - case cadastralNumber = "cadastral_number" - case capitalMarker = "capital_marker" - case city = "city" - case cityArea = "city_area" - case cityDistrict = "city_district" - case cityDistrictFiasId = "city_district_fias_id" - case cityDistrictKladrId = "city_district_kladr_id" - case cityDistrictType = "city_district_type" - case cityDistrictTypeFull = "city_district_type_full" - case cityDistrictWithType = "city_district_with_type" - case cityFiasId = "city_fias_id" - case cityKladrId = "city_kladr_id" - case cityType = "city_type" - case cityTypeFull = "city_type_full" - case cityWithType = "city_with_type" - case country = "country" - case countryIsoCode = "country_iso_code" - case federalDistrict = "federal_district" - case fiasActualityState = "fias_actuality_state" - case fiasCode = "fias_code" - case fiasId = "fias_id" - case fiasLevel = "fias_level" - case flat = "flat" - case flatFiasId = "flat_fias_id" - case flatArea = "flat_area" - case flatPrice = "flat_price" - case flatType = "flat_type" - case flatTypeFull = "flat_type_full" - case geoLat = "geo_lat" - case geoLon = "geo_lon" - case geonameId = "geoname_id" - case historyValues = "history_values" - case house = "house" - case houseFiasId = "house_fias_id" - case houseKladrId = "house_kladr_id" - case houseType = "house_type" - case houseTypeFull = "house_type_full" - case kladrId = "kladr_id" - case metro = "metro" - case okato = "okato" - case oktmo = "oktmo" - case planningStructure = "planning_structure" - case planningStructureFiasId = "planning_structure_fias_id" - case planningStructureKladrId = "planning_structure_kladr_id" - case planningStructureType = "planning_structure_type" - case planningStructureTypeFull = "planning_structure_type_full" - case planningStructureWithType = "planning_structure_with_type" - case postalBox = "postal_box" - case postalCode = "postal_code" - case qc = "qc" - case qcComplete = "qc_complete" - case qcGeo = "qc_geo" - case qcHouse = "qc_house" - case region = "region" - case regionFiasId = "region_fias_id" - case regionIsoCode = "region_iso_code" - case regionKladrId = "region_kladr_id" - case regionType = "region_type" - case regionTypeFull = "region_type_full" - case regionWithType = "region_with_type" - case settlement = "settlement" - case settlementFiasId = "settlement_fias_id" - case settlementKladrId = "settlement_kladr_id" - case settlementType = "settlement_type" - case settlementTypeFull = "settlement_type_full" - case settlementWithType = "settlement_with_type" - case source = "source" - case squareMeterPrice = "square_meter_price" - case street = "street" - case streetFiasId = "street_fias_id" - case streetKladrId = "street_kladr_id" - case streetType = "street_type" - case streetTypeFull = "street_type_full" - case streetWithType = "street_with_type" - case taxOffice = "tax_office" - case taxOfficeLegal = "tax_office_legal" - case timezone = "timezone" - case unparsedParts = "unparsed_parts" - } - - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - area = try values.decodeIfPresent(String.self, forKey: .area) - areaFiasId = try values.decodeIfPresent(String.self, forKey: .areaFiasId) - areaKladrId = try values.decodeIfPresent(String.self, forKey: .areaKladrId) - areaType = try values.decodeIfPresent(String.self, forKey: .areaType) - areaTypeFull = try values.decodeIfPresent(String.self, forKey: .areaTypeFull) - areaWithType = try values.decodeIfPresent(String.self, forKey: .areaWithType) - beltwayDistance = try values.decodeIfPresent(String.self, forKey: .beltwayDistance) - beltwayHit = try values.decodeIfPresent(String.self, forKey: .beltwayHit) - block = try values.decodeIfPresent(String.self, forKey: .block) - blockType = try values.decodeIfPresent(String.self, forKey: .blockType) - blockTypeFull = try values.decodeIfPresent(String.self, forKey: .blockTypeFull) - building = try values.decodeIfPresent(String.self, forKey: .building) - buildingType = try values.decodeIfPresent(String.self, forKey: .buildingType) - cadastralNumber = try values.decodeIfPresent(String.self, forKey: .cadastralNumber) - capitalMarker = try values.decodeIfPresent(String.self, forKey: .capitalMarker) - city = try values.decodeIfPresent(String.self, forKey: .city) - cityArea = try values.decodeIfPresent(String.self, forKey: .cityArea) - cityDistrict = try values.decodeIfPresent(String.self, forKey: .cityDistrict) - cityDistrictFiasId = try values.decodeIfPresent(String.self, forKey: .cityDistrictFiasId) - cityDistrictKladrId = try values.decodeIfPresent(String.self, forKey: .cityDistrictKladrId) - cityDistrictType = try values.decodeIfPresent(String.self, forKey: .cityDistrictType) - cityDistrictTypeFull = try values.decodeIfPresent(String.self, forKey: .cityDistrictTypeFull) - cityDistrictWithType = try values.decodeIfPresent(String.self, forKey: .cityDistrictWithType) - cityFiasId = try values.decodeIfPresent(String.self, forKey: .cityFiasId) - cityKladrId = try values.decodeIfPresent(String.self, forKey: .cityKladrId) - cityType = try values.decodeIfPresent(String.self, forKey: .cityType) - cityTypeFull = try values.decodeIfPresent(String.self, forKey: .cityTypeFull) - cityWithType = try values.decodeIfPresent(String.self, forKey: .cityWithType) - country = try values.decodeIfPresent(String.self, forKey: .country) - countryIsoCode = try values.decodeIfPresent(String.self, forKey: .countryIsoCode) - federalDistrict = try values.decodeIfPresent(String.self, forKey: .federalDistrict) - fiasActualityState = try values.decodeIfPresent(String.self, forKey: .fiasActualityState) - fiasCode = try values.decodeIfPresent(String.self, forKey: .fiasCode) - fiasId = try values.decodeIfPresent(String.self, forKey: .fiasId) - fiasLevel = try values.decodeIfPresent(String.self, forKey: .fiasLevel) - flat = try values.decodeIfPresent(String.self, forKey: .flat) - flatFiasId = try values.decodeIfPresent(String.self, forKey: .flatFiasId) - flatArea = try values.decodeIfPresent(String.self, forKey: .flatArea) - flatPrice = try values.decodeIfPresent(String.self, forKey: .flatPrice) - flatType = try values.decodeIfPresent(String.self, forKey: .flatType) - flatTypeFull = try values.decodeIfPresent(String.self, forKey: .flatTypeFull) - geoLat = try values.decodeIfPresent(String.self, forKey: .geoLat) - geoLon = try values.decodeIfPresent(String.self, forKey: .geoLon) - geonameId = try values.decodeIfPresent(String.self, forKey: .geonameId) - historyValues = try values.decodeIfPresent([String].self, forKey: .historyValues) - house = try values.decodeIfPresent(String.self, forKey: .house) - houseFiasId = try values.decodeIfPresent(String.self, forKey: .houseFiasId) - houseKladrId = try values.decodeIfPresent(String.self, forKey: .houseKladrId) - houseType = try values.decodeIfPresent(String.self, forKey: .houseType) - houseTypeFull = try values.decodeIfPresent(String.self, forKey: .houseTypeFull) - kladrId = try values.decodeIfPresent(String.self, forKey: .kladrId) - metro = try values.decodeIfPresent([Metro].self, forKey: .metro) - okato = try values.decodeIfPresent(String.self, forKey: .okato) - oktmo = try values.decodeIfPresent(String.self, forKey: .oktmo) - planningStructure = try values.decodeIfPresent(String.self, forKey: .planningStructure) - planningStructureFiasId = try values.decodeIfPresent(String.self, forKey: .planningStructureFiasId) - planningStructureKladrId = try values.decodeIfPresent(String.self, forKey: .planningStructureKladrId) - planningStructureType = try values.decodeIfPresent(String.self, forKey: .planningStructureType) - planningStructureTypeFull = try values.decodeIfPresent(String.self, forKey: .planningStructureTypeFull) - planningStructureWithType = try values.decodeIfPresent(String.self, forKey: .planningStructureWithType) - postalBox = try values.decodeIfPresent(String.self, forKey: .postalBox) - postalCode = try values.decodeIfPresent(String.self, forKey: .postalCode) - - qc = values.decodeJSONNumber(forKey: CodingKeys.qc) - qcComplete = values.decodeJSONNumber(forKey: CodingKeys.qcComplete) - qcGeo = values.decodeJSONNumber(forKey: CodingKeys.qcGeo) - qcHouse = values.decodeJSONNumber(forKey: CodingKeys.qcHouse) - - region = try values.decodeIfPresent(String.self, forKey: .region) - regionFiasId = try values.decodeIfPresent(String.self, forKey: .regionFiasId) - regionIsoCode = try values.decodeIfPresent(String.self, forKey: .regionIsoCode) - regionKladrId = try values.decodeIfPresent(String.self, forKey: .regionKladrId) - regionType = try values.decodeIfPresent(String.self, forKey: .regionType) - regionTypeFull = try values.decodeIfPresent(String.self, forKey: .regionTypeFull) - regionWithType = try values.decodeIfPresent(String.self, forKey: .regionWithType) - settlement = try values.decodeIfPresent(String.self, forKey: .settlement) - settlementFiasId = try values.decodeIfPresent(String.self, forKey: .settlementFiasId) - settlementKladrId = try values.decodeIfPresent(String.self, forKey: .settlementKladrId) - settlementType = try values.decodeIfPresent(String.self, forKey: .settlementType) - settlementTypeFull = try values.decodeIfPresent(String.self, forKey: .settlementTypeFull) - settlementWithType = try values.decodeIfPresent(String.self, forKey: .settlementWithType) - source = try values.decodeIfPresent(String.self, forKey: .source) - squareMeterPrice = try values.decodeIfPresent(String.self, forKey: .squareMeterPrice) - street = try values.decodeIfPresent(String.self, forKey: .street) - streetFiasId = try values.decodeIfPresent(String.self, forKey: .streetFiasId) - streetKladrId = try values.decodeIfPresent(String.self, forKey: .streetKladrId) - streetType = try values.decodeIfPresent(String.self, forKey: .streetType) - streetTypeFull = try values.decodeIfPresent(String.self, forKey: .streetTypeFull) - streetWithType = try values.decodeIfPresent(String.self, forKey: .streetWithType) - taxOffice = try values.decodeIfPresent(String.self, forKey: .taxOffice) - taxOfficeLegal = try values.decodeIfPresent(String.self, forKey: .taxOfficeLegal) - timezone = try values.decodeIfPresent(String.self, forKey: .timezone) - unparsedParts = try values.decodeIfPresent(String.self, forKey: .unparsedParts) - } -} - -/// Structure holding metro station name, name of a line and distance to suggested address. -/// If there aren't metro stations nearby or API token used not subscribed to "Maximal" package -/// `nil` is returned instead. -public struct Metro: Decodable{ - public let name : String? - public let line : String? - public let distance : String? - - enum CodingKeys: String, CodingKey { - case name, line, distance - } - - public init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - name = try values.decodeIfPresent(String.self, forKey: .name) - line = try values.decodeIfPresent(String.self, forKey: .line) - - distance = values.decodeJSONNumber(forKey: CodingKeys.distance) - } -} diff --git a/IIDadata/Sources/Model/ReverseGeocodeQuery.swift b/IIDadata/Sources/Model/ReverseGeocodeQuery.swift deleted file mode 100644 index 636e6d1..0000000 --- a/IIDadata/Sources/Model/ReverseGeocodeQuery.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// ReverseGeocodeQuery.swift -// IIDadata -// -// Created by Yachin Ilya on 12.05.2020. -// - -import Foundation - -///ReverseGeocodeQuery represents an serializable object used to perform reverse geocode queries. -public class ReverseGeocodeQuery: Encodable, DadataQueryProtocol{ - let latitude: Double - let longitude: Double - let endpoint: String - public var resultsCount: Int? = 10 - public var language: QueryResultLanguage? - public var searchRadius: Int? - - ///New instance of ReverseGeocodeQuery. - ///- Parameter query: Query should contain latitude and longitude of the point of interest. - ///- Parameter delimeter: Single character delimeter to separate latitude and longitude. - ///- Throws: May throw if parsing of latitude and longitude out of query fails. - public convenience init(query: String, delimeter: Character = ",") throws { - let splitStr = query.split(separator: delimeter) - - let latStr = String(splitStr[0]).trimmingCharacters(in: .whitespacesAndNewlines) - let lonStr = String(splitStr[1]).trimmingCharacters(in: .whitespacesAndNewlines) - - guard let latitude = Double(latStr), - let longitude = Double(lonStr) - else { - throw NSError(domain: "Dadata ReverseGeocodeQuery", - code: -1, - userInfo: ["description" : "Failed to parse coordinates from \(query) uding delimeter \(delimeter)"] - ) - } - - self.init(latitude: latitude, longitude: longitude) - } - - ///New instance of ReverseGeocodeQuery. - ///- Parameter latitude: Latitude of the point of interest. - ///- Parameter longitude: Longitude of the point of interest. - public required init(latitude: Double, longitude: Double){ - self.latitude = latitude - self.longitude = longitude - self.endpoint = Constants.revGeocodeEndpoint - } - - ///Serializes ReverseGeocodeQuery to send over the wire. - func toJSON() throws -> Data { - return try JSONEncoder().encode(self) - } - - ///Returns an API endpoint for reverse geocode query. - func queryEndpoint() -> String { return endpoint } - - enum CodingKeys: String, CodingKey { - case latitude = "lat" - case longitude = "lon" - case resultsCount = "count" - case language - case searchRadius = "radius_meters" - } -} diff --git a/IIDadata/Tests/IIDadataTests/IIDadataTests.swift b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift index 1f3c6c4..7df2951 100644 --- a/IIDadata/Tests/IIDadataTests/IIDadataTests.swift +++ b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift @@ -1,15 +1,108 @@ -import XCTest @testable import IIDadata +import XCTest final class IIDadataTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. -// XCTAssertEqual(IIDadata().text, "Hello, World!") + // Properties + +// let apiKey = "abadf779d0525bebb9e16b72a97eabf4f7143292" + + // Computed Properties + + // Environment Variable is read from Scheme Configuration + private var apiToken: String { + return ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" + } + + // Functions + + // Address Suggestion Tests + func testSuggestAddress_BasicQuery() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestAddress("Москва, Красная площадь") + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + } + + func testSuggestAddress_FilterByRegion() async throws { + var constraint = AddressQueryConstraint() + constraint.region = "Москва" + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestAddress( + "Москва, Красная площадь", + constraints: [constraint] + ) + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + XCTAssertEqual(suggestions.suggestions?.first?.data?.region, "Москва") + } + + func testSuggestAddress_FilterByCity() async throws { + var constraint = AddressQueryConstraint() + constraint.city = "Москва" + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestAddress( + "Москва, Красная площадь", + constraints: [constraint] + ) + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + XCTAssertEqual(suggestions.suggestions?.first?.data?.city, "Москва") + } + + func testSuggestAddress_FindByID() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestByKLADRFIAS("9120b43f-2fae-4838-a144-85e43c2bfb29") + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + } + + func testReverseGeocode_LatLon() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .reverseGeocode(latitude: 55.755826, longitude: 37.617300) + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + } + + func testReverseGeocode_LatLonString() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .reverseGeocode(query: "55.755826, 37.617300") + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + } + + func testReverseGeocode_WithRadius() async throws { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .reverseGeocode(latitude: 55.755826, longitude: 37.617300, searchRadius: 100) + XCTAssertGreaterThan(suggestions.suggestions?.count ?? 0, 0) + } + + // FIO Suggestion Tests + func testSuggestFIO_BasicQuery() async throws { + do { + let suggestions = try await DadataSuggestions(apiKey: apiToken).suggestFio("Иванов") + dump(suggestions, name: "FIO_BasicQuery ") + XCTAssertGreaterThan(suggestions.count, 0) + + } catch { + XCTFail(String(describing: error)) } + } - static var allTests = [ - ("testExample", testExample), - ] + func testSuggestFIO_FilterByGender() async throws { + do { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestFio("Иванов", gender: .male) + dump(suggestions, name: "FIO_FilterByGender ") + XCTAssertGreaterThan(suggestions.count, 0) + + } catch { + XCTFail(String(describing: error)) + } + } + + func testSuggestFIO_FilterByParts() async throws { + do { + let suggestions = try await DadataSuggestions(apiKey: apiToken) + .suggestFio("Иванов Иван", parts: [.surname, .name]) + dump(suggestions, name: "FIO_FilterByParts suggestions") + XCTAssertGreaterThan(suggestions.count, 0) + + } catch { + XCTFail(String(describing: error)) + } + } } diff --git a/Package.swift b/Package.swift index 9def5ab..25fdc2e 100644 --- a/Package.swift +++ b/Package.swift @@ -1,30 +1,56 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "IIDadata", - products: [ - // Products define the executables and libraries produced by a package, and make them visible to other packages. - .library( - name: "IIDadata", - targets: ["IIDadata"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), - ], - targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages which this package depends on. - .target( - name: "IIDadata", - dependencies: [], - path: "IIDadata/Sources/"), - .testTarget( - name: "IIDadataTests", - dependencies: ["IIDadata"], - path: "IIDadata/Tests/IIDadataTests/"), - ] + name: "IIDadata", + defaultLocalization: "ru", + platforms: [ + .iOS(.v14), + .macOS(.v11), + .tvOS(.v14), + .watchOS(.v7), + ], + products: [ + // Products define the executables and libraries produced by a package, and make them visible to other packages. + .library( + name: "IIDadata", + targets: ["IIDadata"] + ), + .library( + name: "IIDadataUI", + targets: ["IIDadata", "IIDadataUI"] + ), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages which this package depends on. + .executableTarget( + name: "Example", + dependencies: ["IIDadata", "IIDadataUI"], + path: "IIDadata/Example/" + ), + .target( + name: "IIDadata", + dependencies: [], + swiftSettings: [ + .define("SWIFT_PACKAGE"), + .unsafeFlags(["-enable-library-evolution"]), + ] + ), + .target( + name: "IIDadataUI", + dependencies: ["IIDadata"] + ), + .testTarget( + name: "IIDadataTests", + dependencies: ["IIDadata"], + path: "IIDadata/Tests/IIDadataTests/" + ), + ] ) diff --git a/IIDadata/Sources/.gitkeep b/Sources/IIDadata/.gitkeep similarity index 100% rename from IIDadata/Sources/.gitkeep rename to Sources/IIDadata/.gitkeep diff --git a/Sources/IIDadata/Constants.swift b/Sources/IIDadata/Constants.swift new file mode 100644 index 0000000..42eace3 --- /dev/null +++ b/Sources/IIDadata/Constants.swift @@ -0,0 +1,37 @@ +// +// Constants.swift +// IIDadata +// +// Created by Yachin Ilya on 11.05.2020. +// + +import Foundation + +// MARK: - Constants + +enum Constants { + static let suggestionsAPIURL = "http://suggestions.dadata.ru/suggestions/api/4_1/rs/" + static let fioSuggestionsAPIURL = "http://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/fio" + static let addressEndpoint = AddressQueryType.address.rawValue + static let addressFIASOnlyEndpoint = AddressQueryType.fiasOnly.rawValue + static let addressByIDEndpoint = AddressQueryType.findByID.rawValue + static let revGeocodeEndpoint = "geolocate/address" + static let infoPlistTokenKey = "IIDadataAPIToken" +} + +// MARK: - AddressQueryType + +/// API endpoints for different request types. +public enum AddressQueryType: String { + case address = "suggest/address" + case fiasOnly = "suggest/fias" + case findByID = "findById/address" +} + +// MARK: - QueryResultLanguage + +/// Language of response. +public enum QueryResultLanguage: String, Encodable { + case ru + case en +} diff --git a/Sources/IIDadata/DadataQueryProtocol.swift b/Sources/IIDadata/DadataQueryProtocol.swift new file mode 100644 index 0000000..0a9fd22 --- /dev/null +++ b/Sources/IIDadata/DadataQueryProtocol.swift @@ -0,0 +1,27 @@ +// +// DadataQueryProtocol.swift +// IIDadata +// +// Created by Yachin Ilya on 12.05.2020. +// + +import Foundation + +/// Conformance to this protocol requires implementing methods to return a query endpoint string and to convert the entity to JSON. +/// Protocol that defines the blueprint for generating a query endpoint and converting data to JSON. +protocol DadataQueryProtocol { + /// Generates and returns the endpoint string for the query. + /// + /// The implementation should provide the specific endpoint required for the Dadata API request. + /// + /// - Returns: A `String` representing the query endpoint. + func queryEndpoint() -> String + + /// Converts the implementing entity to JSON format. + /// If the object cannot be serialized, this method throws an error. + /// This method should provide a way to serialize the entity's data into a JSON `Data` object. + /// + /// - Returns: A `Data` object containing the JSON representation of the entity. + /// - Throws: An error if the serialization fails. + func toJSON() throws -> Data +} diff --git a/Sources/IIDadata/DadataSuggestions.swift b/Sources/IIDadata/DadataSuggestions.swift new file mode 100644 index 0000000..9f9db63 --- /dev/null +++ b/Sources/IIDadata/DadataSuggestions.swift @@ -0,0 +1,378 @@ +import protocol Combine.ObservableObject +import Foundation + +@available(iOS 14.0, *) +public actor DadataSuggestions: ObservableObject { + // Static Properties + + private static var sharedInstance: DadataSuggestions? + + // Properties + + /// API key for [Dadata](https://dadata.ru/profile/#info). + private let apiKey: String + + /// Base URL of suggestions API + private var suggestionsAPIURL: URL + + // Lifecycle + + /// New instance of DadataSuggestions. + /// + /// + /// Required API key is read from Info.plist. Each init creates new instance using same token. + /// If DadataSuggestions is used havily consider `DadataSuggestions.shared()` instead. + /// - Precondition: Token set with "IIDadataAPIToken" key in Info.plist. + /// - Throws: Call may throw if there isn't a value for key "IIDadataAPIToken" set in Info.plist. + public init() throws { + let key = try DadataSuggestions.readAPIKeyFromPlist() + self.init(apiKey: key) + Self.sharedInstance = self + } + + /// This init checks connectivity once the class instance is set. + /// + /// This init should not be called on main thread as it may take up long time as it makes request to server in a blocking manner. + /// Throws if connection is impossible or request is timed out. + /// ``` + /// DispatchQueue.global(qos: .background).async { + /// let dadata = try DadataSuggestions(apiKey: " ", checkWithTimeout: 15) + /// } + /// ``` + /// - Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. + /// - Parameter checkWithTimeout: Time in seconds to wait for response. + /// + /// - Throws: May throw on connectivity problems, missing or wrong API token, limits exeeded, wrong endpoint. + /// May throw if request is timed out. + public init(api: String /* , checkWithTimeout timeout: Int */ ) throws { + self.init(apiKey: api) + Task { try await checkAPIConnectivity() } + Self.sharedInstance = self + } + + /// New instance of DadataSuggestions. + /// - Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. + public /* required */ init(apiKey: String) { + self.init(apiKey: apiKey, url: Constants.suggestionsAPIURL) + Self.sharedInstance = self + } + + private init(apiKey: String, url: String) { + self.apiKey = apiKey + suggestionsAPIURL = URL(string: url)! + } + + // Static Functions + + /// Get shared instance of DadataSuggestions class. + /// + /// Call may throw if neither apiKey parameter is provided + /// nor a value for key "IIDadataAPIToken" is set in Info.plist + /// whenever shared instance weren't instantiated earlier. + /// If another apiKey provided new shared instance of DadataSuggestions recreated with the provided API token. + /// - Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. + public static func shared(apiKey: String? = nil) throws -> DadataSuggestions { + if let instance = sharedInstance, instance.apiKey == apiKey || apiKey == nil { return instance } + + if let key = apiKey { + sharedInstance = DadataSuggestions(apiKey: key) + return sharedInstance! + } + + let key = try readAPIKeyFromPlist() + sharedInstance = DadataSuggestions(apiKey: key) + return sharedInstance! + } + + private static func readAPIKeyFromPlist() throws -> String { + var dictionary: NSDictionary? + if let path = Bundle.main.path(forResource: "Info", ofType: "plist") { + dictionary = NSDictionary(contentsOfFile: path) + } + guard let key = dictionary?.value(forKey: Constants.infoPlistTokenKey) as? String else { + throw NSError(domain: "Dadata API key missing in Info.plist", code: 1, userInfo: nil) + } + return key + } + + // Functions + + /// Basic address suggestions request with only rquired data. + /// + /// - Parameter query: Query string to send to API. String of a free-form e.g. address part. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func suggestAddress(_ query: String) async throws -> AddressSuggestionResponse { + try await suggestAddress(AddressSuggestionQuery(query)) + } + + /// Address suggestions request. + /// + /// Limitations, filters and constraints may be applied to query. + /// + /// - Parameter query: Query string to send to API. String of a free-form e.g. address part. + /// - Parameter queryType: Lets select whether the request type. There are 3 query types available: + /// `address` — standart address suggestion query; + /// `fiasOnly` — query to only search in FIAS database: less matches, state provided address data only; + /// `findByID` — takes KLADR or FIAS ID as a query parameter to lookup additional data. + /// - Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object + /// including latitude and longitude. `20` is a maximum value. + /// - Parameter language: Suggested results may be in Russian or English. + /// - Parameter constraints: List of `AddressQueryConstraint` objects to filter results. + /// - Parameter regionPriority: List of RegionPriority objects to prefer in lookup. + /// - Parameter upperScaleLimit: Bigger `ScaleLevel` object in pair of scale limits. + /// - Parameter lowerScaleLimit: Smaller `ScaleLevel` object in pair of scale limits. + /// - Parameter trimRegionResult: Remove region and city names from suggestion top level. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + @Sendable public func suggestAddress( + _ query: String, + queryType: AddressQueryType = .address, + resultsCount: Int? = 10, + language: QueryResultLanguage? = nil, + constraints: [AddressQueryConstraint]? = nil, + regionPriority: [RegionPriority]? = nil, + upperScaleLimit: ScaleLevel? = nil, + lowerScaleLimit: ScaleLevel? = nil, + trimRegionResult: Bool = false + ) async throws -> AddressSuggestionResponse { + let suggestionQuery = AddressSuggestionQuery(query, ofType: queryType) + + suggestionQuery.resultsCount = resultsCount + suggestionQuery.language = language + suggestionQuery.constraints = constraints + suggestionQuery.regionPriority = regionPriority + suggestionQuery.upperScaleLimit = upperScaleLimit != nil ? ScaleBound(value: upperScaleLimit) : nil + suggestionQuery.lowerScaleLimit = lowerScaleLimit != nil ? ScaleBound(value: lowerScaleLimit) : nil + suggestionQuery.trimRegionResult = trimRegionResult + + return try await suggestAddress(suggestionQuery) + } + + /// Address suggestions request. + /// + /// Allows to pass most of arguments as a strings converting to internally used classes. + /// + /// - Parameter query: Query string to send to API. String of a free-form e.g. address part. + /// - Parameter queryType: Lets select whether the request type. There are 3 query types available: + /// `address` — standart address suggestion query; + /// `fiasOnly` — query to only search in FIAS database: less matches, state provided address data only; + /// `findByID` — takes KLADR or FIAS ID as a query parameter to lookup additional data. + /// - Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object + /// including latitude and longitude. `20` is a maximum value. + /// - Parameter language: Suggested results in "ru" — Russian or "en" — English. + /// - Parameter constraints: Literal JSON string formated according to + /// [Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=204669108). + /// - Parameter regionPriority: List of regions' KLADR IDs to prefer in lookup as shown in + /// [Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=285343795). + /// - Parameter upperScaleLimit: Bigger sized object in pair of scale limits. + /// - Parameter lowerScaleLimit: Smaller sized object in pair of scale limits. Both can take following values: + /// `country` — Страна, + /// `region` — Регион, + /// `area` — Район, + /// `city` — Город, + /// `settlement` — Населенный пункт, + /// `street` — Улица, + /// `house` — Дом, + /// `country` — Страна, + /// - Parameter trimRegionResult: Remove region and city names from suggestion top level. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + /// - Throws: ``DadataError`` if something went wrong. + public func suggestAddress( + _ query: String, + queryType: AddressQueryType = .address, + resultsCount: Int? = 10, + language: String? = nil, + constraints: [String]? = nil, + regionPriority: [String]? = nil, + upperScaleLimit: String? = nil, + lowerScaleLimit: String? = nil, + trimRegionResult: Bool = false + ) async throws -> AddressSuggestionResponse { + let queryConstraints: [AddressQueryConstraint]? = try constraints?.compactMap { + if let data = $0.data(using: .utf8) { + return try JSONDecoder().decode(AddressQueryConstraint.self, from: data) + } + return nil + } + let preferredRegions: [RegionPriority]? = regionPriority?.compactMap { RegionPriority(kladr_id: $0) } + + return try await suggestAddress( + query, + queryType: queryType, + resultsCount: resultsCount, + language: QueryResultLanguage(rawValue: language ?? "ru"), + constraints: queryConstraints, + regionPriority: preferredRegions, + upperScaleLimit: ScaleLevel(rawValue: upperScaleLimit ?? "*"), + lowerScaleLimit: ScaleLevel(rawValue: lowerScaleLimit ?? "*"), + trimRegionResult: trimRegionResult + ) + } + + /// Basic address suggestions request to only search in FIAS database: less matches, state provided address data only. + /// + /// - Parameter query: Query string to send to API. String of a free-form e.g. address part. + /// - Returns:``AddressSuggestionResponse`` - result: result of address suggestion query. + public func suggestAddressFromFIAS(_ query: String) async throws -> AddressSuggestionResponse { + try await suggestAddress(AddressSuggestionQuery(query, ofType: .fiasOnly)) + } + + /// Basic address suggestions request takes KLADR or FIAS ID as a query parameter to lookup additional data. + /// + /// - Parameter query: KLADR or FIAS ID. + /// - Returns:``AddressSuggestionResponse`` - result: result of address suggestion query. + public func suggestByKLADRFIAS(_ query: String) async throws -> AddressSuggestionResponse { + try await suggestAddress(AddressSuggestionQuery(query, ofType: .findByID)) + } + + /// Address suggestion request with custom `AddressSuggestionQuery`. + /// + /// - Parameter query: Query object. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func suggestAddress(_ query: AddressSuggestionQuery) async throws -> AddressSuggestionResponse { + try await fetchResponse(withQuery: query) + } + + /// Reverse Geocode request with latitude and longitude as a single string. + /// + /// - Throws: May throw if query is malformed. + /// + /// - Parameter query: Latitude and longitude as a string. Should have single character separator. + /// - Parameter delimeter: Character to separate latitude and longitude. Defaults to '`,`' + /// - Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object + /// including latitude and longitude. `20` is a maximum value. + /// - Parameter language: Suggested results in "ru" — Russian or "en" — English. + /// - Parameter searchRadius: Radius to suggest objects nearest to coordinates point. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func reverseGeocode( + query: String, + delimiter: Character = ",", + resultsCount: Int? = 10, + language: String? = "ru", + searchRadius: Int? = nil + ) async throws -> AddressSuggestionResponse { + let geoquery = try ReverseGeocodeQuery(query: query, delimeter: delimiter) + geoquery.resultsCount = resultsCount + geoquery.language = QueryResultLanguage(rawValue: language ?? "ru") + geoquery.searchRadius = searchRadius + + return try await reverseGeocode(geoquery) + } + + /// Reverse Geocode request with latitude and longitude as a single string. + /// + /// - Parameter latitude: Latitude. + /// - Parameter longitude: Longitude. + /// - Parameter resultsCount: How many suggestions to return. `1` provides more data on a single object + /// including latitude and longitude. `20` is a maximum value. + /// - Parameter language: Suggested results may be in Russian or English. + /// - Parameter searchRadius: Radius to suggest objects nearest to coordinates point. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func reverseGeocode( + latitude: Double, + longitude: Double, + resultsCount: Int? = 10, + language: QueryResultLanguage? = nil, + searchRadius: Int? = nil + ) async throws -> AddressSuggestionResponse { + let geoquery = ReverseGeocodeQuery(latitude: latitude, longitude: longitude) + geoquery.resultsCount = resultsCount + geoquery.language = language + geoquery.searchRadius = searchRadius + + return try await fetchResponse(withQuery: geoquery) + } + + /// Reverse geocode request with custom `ReverseGeocodeQuery`. + /// + /// - Parameter query: Query object. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func reverseGeocode(_ query: ReverseGeocodeQuery) async throws -> AddressSuggestionResponse { + try await fetchResponse(withQuery: query) + } + + /// Suggests a list of FIO (Family, Given, and Middle Names) based on the provided query. + /// + /// This asynchronous method fetches suggestions for FIO (Family, Given, and Middle Names) from a remote server. + /// + /// + /// - Parameter query: A string containing the name or partial name for which suggestions are required. + /// - Parameter count: The maximum number of suggestions to return. Defaults to 10 if not specified. + /// - Parameter gender: The `gender` of the person. Defaults to `nil` if not specified. + /// - Parameter parts: Indicates if the `fullname parts` should be returned separately. Defaults to `nil` if not specified. + /// - Returns: An array of `FioSuggestion` objects matching the query. + /// - Throws: An error if the request fails or the server returns an error. + /// + /// This method constructs a `FioSuggestionQuery` object with the given query and count, then fetches the response + /// using the `fetchResponse(withQuery:)` method. + public func suggestFio( + _ query: String, + count: Int = 10, + gender: Gender? = nil, + parts: [FioSuggestionQuery.Part]? = nil + ) async throws -> [FioSuggestion] { + let fioSuggestionQuery = FioSuggestionQuery( + query, + count: count, + parts: parts, + gender: gender + ) + debugPrint("FioSuggestionQuery: \n \(fioSuggestionQuery)") + let fioSuggestionResponse: FioSuggestionResponse = try await fetchResponse(withQuery: fioSuggestionQuery) + debugPrint(fioSuggestionResponse) + return fioSuggestionResponse.suggestions + } + + func checkAPIConnectivity() async throws { + let request = createRequest(url: suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200 ... 299).contains(httpResponse.statusCode) + else { + dump(response, name: "API Connectivity Response") + throw nonOKResponseToError(response: (response as? HTTPURLResponse) ?? .init(), body: data) + } + } + + // MARK: - Private + + private func createRequest(url: URL) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.addValue("Token " + apiKey, forHTTPHeaderField: "Authorization") + + dump(request, name: "Request \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "Unable to decode request body")") + return request + } + + private func nonOKResponseToError(response: HTTPURLResponse, body data: Data?) -> Error { + let code = response.statusCode + var info: [String: Any] = [:] + response.allHeaderFields.forEach { if let k = $0.key as? String { info[k] = $0.value } } + if let data = data { + let object = try? JSONSerialization.jsonObject(with: data, options: []) as? [AnyHashable: Any] + object?.forEach { if let k = $0.key as? String { info[k] = $0.value } } + } + return NSError(domain: "HTTP Status \(HTTPURLResponse.localizedString(forStatusCode: code))", code: code, userInfo: info) + } + + private func fetchResponse(withQuery query: DadataQueryProtocol) async throws -> T { + var request = createRequest(url: suggestionsAPIURL.appendingPathComponent(query.queryEndpoint())) + request.httpBody = try query.toJSON() + dump(String(data: request.httpBody ?? Data(), encoding: .utf8), name: "Request \(T.self)") + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NSError(domain: "Invalid response", code: 0, userInfo: nil) + } + + guard (200 ... 299).contains(httpResponse.statusCode) else { + throw NSError(domain: "HTTP Error", code: httpResponse.statusCode, userInfo: ["description": httpResponse.description]) + } + + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/Sources/IIDadata/Extension/KeyedDecodingContainer+decodeJSONNumber.swift b/Sources/IIDadata/Extension/KeyedDecodingContainer+decodeJSONNumber.swift new file mode 100644 index 0000000..558426e --- /dev/null +++ b/Sources/IIDadata/Extension/KeyedDecodingContainer+decodeJSONNumber.swift @@ -0,0 +1,26 @@ +// +// KeyedDecodingContainer+decodeJSONNumber.swift +// IIDadata +// +// Created by Yachin Ilya on 23.07.2020. +// + +import Foundation + +extension KeyedDecodingContainer { + /// Forces integers and floating point numbers to optional String type. + /// Helpful when JSON response may include inconsistency in number fields. + /// - parameter key: CodingKey of Decodable object CodingKeys to lookup. + func decodeJSONNumber(forKey key: CodingKey) -> String? { + if let v = try? decode(String.self, forKey: key as! K) { + return v + } + if let v = try? decode(Int.self, forKey: key as! K) { + return "\(v)" + } + if let v = try? decode(Double.self, forKey: key as! K) { + return "\(v)" + } + return nil + } +} diff --git a/Sources/IIDadata/Model/Address/AddressSuggestion.swift b/Sources/IIDadata/Model/Address/AddressSuggestion.swift new file mode 100644 index 0000000..ddaf1cd --- /dev/null +++ b/Sources/IIDadata/Model/Address/AddressSuggestion.swift @@ -0,0 +1,550 @@ +// +// AddressSuggestionData.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +// MARK: - AddressSuggestion + +/// Every single suggestion is represented as AddressSuggestions. +public struct AddressSuggestion: Suggestion { + public static func == (lhs: AddressSuggestion, rhs: AddressSuggestion) -> Bool { + lhs.value == rhs.value && lhs.unrestrictedValue == rhs.unrestrictedValue + } + + /// Address in short format. + public let value: String + /// All the data returned in response to suggestion query. + public let data: AddressData? + /// Address in long format with region. + public let unrestrictedValue: String? + + public init( + value: String, + data: AddressData?, + unrestrictedValue: String? + ) { + self.value = value + self.data = data + self.unrestrictedValue = unrestrictedValue + } +} + +// MARK: AddressSuggestion.AddressData + +public extension AddressSuggestion { + /// All the data returned in response to suggestion query. + + enum CodingKeys: String, CodingKey { + case value + case data + case unrestrictedValue = "unrestricted_value" + } + + struct AddressData: Decodable { + // Nested Types + + enum CodingKeys: String, CodingKey { + case area + case areaFiasId = "area_fias_id" + case areaKladrId = "area_kladr_id" + case areaType = "area_type" + case areaTypeFull = "area_type_full" + case areaWithType = "area_with_type" + case beltwayDistance = "beltway_distance" + case beltwayHit = "beltway_hit" + case block + case blockType = "block_type" + case blockTypeFull = "block_type_full" + case building + case buildingType = "building_type" + case cadastralNumber = "cadastral_number" + case capitalMarker = "capital_marker" + case city + case cityArea = "city_area" + case cityDistrict = "city_district" + case cityDistrictFiasId = "city_district_fias_id" + case cityDistrictKladrId = "city_district_kladr_id" + case cityDistrictType = "city_district_type" + case cityDistrictTypeFull = "city_district_type_full" + case cityDistrictWithType = "city_district_with_type" + case cityFiasId = "city_fias_id" + case cityKladrId = "city_kladr_id" + case cityType = "city_type" + case cityTypeFull = "city_type_full" + case cityWithType = "city_with_type" + case country + case countryIsoCode = "country_iso_code" + case federalDistrict = "federal_district" + case fiasActualityState = "fias_actuality_state" + case fiasCode = "fias_code" + case fiasId = "fias_id" + case fiasLevel = "fias_level" + case flat + case flatFiasId = "flat_fias_id" + case flatArea = "flat_area" + case flatPrice = "flat_price" + case flatType = "flat_type" + case flatTypeFull = "flat_type_full" + case geoLat = "geo_lat" + case geoLon = "geo_lon" + case geonameId = "geoname_id" + case historyValues = "history_values" + case house + case houseFiasId = "house_fias_id" + case houseKladrId = "house_kladr_id" + case houseType = "house_type" + case houseTypeFull = "house_type_full" + case kladrId = "kladr_id" + case metro + case okato + case oktmo + case planningStructure = "planning_structure" + case planningStructureFiasId = "planning_structure_fias_id" + case planningStructureKladrId = "planning_structure_kladr_id" + case planningStructureType = "planning_structure_type" + case planningStructureTypeFull = "planning_structure_type_full" + case planningStructureWithType = "planning_structure_with_type" + case postalBox = "postal_box" + case postalCode = "postal_code" + case qc + case qcComplete = "qc_complete" + case qcGeo = "qc_geo" + case qcHouse = "qc_house" + case region + case regionFiasId = "region_fias_id" + case regionIsoCode = "region_iso_code" + case regionKladrId = "region_kladr_id" + case regionType = "region_type" + case regionTypeFull = "region_type_full" + case regionWithType = "region_with_type" + case settlement + case settlementFiasId = "settlement_fias_id" + case settlementKladrId = "settlement_kladr_id" + case settlementType = "settlement_type" + case settlementTypeFull = "settlement_type_full" + case settlementWithType = "settlement_with_type" + case source + case squareMeterPrice = "square_meter_price" + case street + case streetFiasId = "street_fias_id" + case streetKladrId = "street_kladr_id" + case streetType = "street_type" + case streetTypeFull = "street_type_full" + case streetWithType = "street_with_type" + case taxOffice = "tax_office" + case taxOfficeLegal = "tax_office_legal" + case timezone + case unparsedParts = "unparsed_parts" + } + + // Properties + + public let area: String? + public let areaFiasId: String? + public let areaKladrId: String? + public let areaType: String? + public let areaTypeFull: String? + public let areaWithType: String? + public let beltwayDistance: String? + public let beltwayHit: String? + public let block: String? + public let blockType: String? + public let blockTypeFull: String? + public let building: String? + public let buildingType: String? + public let cadastralNumber: String? + /// - `1` — subregion (district) center + /// - `2` — region center + /// - `3` — `1` and `2` combined + /// - `4` — main subregion (district) in region + /// - `0` — no status + public let capitalMarker: String? + public let city: String? + public let cityArea: String? + public let cityDistrict: String? + public let cityDistrictFiasId: String? + public let cityDistrictKladrId: String? + public let cityDistrictType: String? + public let cityDistrictTypeFull: String? + public let cityDistrictWithType: String? + public let cityFiasId: String? + public let cityKladrId: String? + public let cityType: String? + public let cityTypeFull: String? + public let cityWithType: String? + public let country: String? + public let countryIsoCode: String? + public let federalDistrict: String? + /// FIAS actuality + /// - `0` — actual + /// - `1–50` — renamed + /// - `51` — changed + /// - `99` — removed + public let fiasActualityState: String? + /// Structure of FIAS code (СС+РРР+ГГГ+ППП+СССС+УУУУ+ДДДД) + public let fiasCode: String? + public let fiasId: String? + /// FIAS address precision + /// - `0` — country + /// - `1` — region + /// - `3` — subregion (district of region) + /// - `4` — city + /// - `5` — city district + /// - `6` — locality, neighbourhood, settlement etc. + /// - `7` — street + /// - `8` — building + /// - `9` — flat / apartment + /// - `65` — city plan unit + /// - `-1` — empty or abroad + public let fiasLevel: String? + public let flat: String? + public let flatFiasId: String? + public let flatArea: String? + public let flatPrice: String? + public let flatType: String? + public let flatTypeFull: String? + public let geoLat: String? + public let geoLon: String? + public let geonameId: String? + public let historyValues: [String]? + public let house: String? + public let houseFiasId: String? + public let houseKladrId: String? + public let houseType: String? + public let houseTypeFull: String? + public let kladrId: String? + public let metro: [Metro]? + public let okato: String? + public let oktmo: String? + public let planningStructure: String? + public let planningStructureFiasId: String? + public let planningStructureKladrId: String? + public let planningStructureType: String? + public let planningStructureTypeFull: String? + public let planningStructureWithType: String? + public let postalBox: String? + public let postalCode: String? + public let qc: String? + public let qcComplete: String? + /// Coordinates precision + /// - `0` — precise + /// - `1` — nearest building + /// - `2` — nearest street + /// - `3` — city district, locality, neighbourhood, settlement etc. + /// - `4` — city + /// - `5` — failed to determine coordinates + public let qcGeo: String? + public let qcHouse: String? + public let region: String? + public let regionFiasId: String? + public let regionIsoCode: String? + public let regionKladrId: String? + public let regionType: String? + public let regionTypeFull: String? + public let regionWithType: String? + public let settlement: String? + public let settlementFiasId: String? + public let settlementKladrId: String? + public let settlementType: String? + public let settlementTypeFull: String? + public let settlementWithType: String? + public let source: String? + public let squareMeterPrice: String? + public let street: String? + public let streetFiasId: String? + public let streetKladrId: String? + public let streetType: String? + public let streetTypeFull: String? + public let streetWithType: String? + public let taxOffice: String? + public let taxOfficeLegal: String? + public let timezone: String? + public let unparsedParts: String? + + // Lifecycle + public init( + area: String? = nil, + areaFiasId: String? = nil, + areaKladrId: String? = nil, + areaType: String? = nil, + areaTypeFull: String? = nil, + areaWithType: String? = nil, + beltwayDistance: String? = nil, + beltwayHit: String? = nil, + block: String? = nil, + blockType: String? = nil, + blockTypeFull: String? = nil, + building: String? = nil, + buildingType: String? = nil, + cadastralNumber: String? = nil, + capitalMarker: String? = nil, + city: String? = nil, + cityArea: String? = nil, + cityDistrict: String? = nil, + cityDistrictFiasId: String? = nil, + cityDistrictKladrId: String? = nil, + cityDistrictType: String? = nil, + cityDistrictTypeFull: String? = nil, + cityDistrictWithType: String? = nil, + cityFiasId: String? = nil, + cityKladrId: String? = nil, + cityType: String? = nil, + cityTypeFull: String? = nil, + cityWithType: String? = nil, + country: String? = nil, + countryIsoCode: String? = nil, + federalDistrict: String? = nil, + fiasActualityState: String? = nil, + fiasCode: String? = nil, + fiasId: String? = nil, + fiasLevel: String? = nil, + flat: String? = nil, + flatFiasId: String? = nil, + flatArea: String? = nil, + flatPrice: String? = nil, + flatType: String? = nil, + flatTypeFull: String? = nil, + geoLat: String? = nil, + geoLon: String? = nil, + geonameId: String? = nil, + historyValues: [String]? = nil, + house: String? = nil, + houseFiasId: String? = nil, + houseKladrId: String? = nil, + houseType: String? = nil, + houseTypeFull: String? = nil, + kladrId: String? = nil, + metro: [Metro]? = nil, + okato: String? = nil, + oktmo: String? = nil, + planningStructure: String? = nil, + planningStructureFiasId: String? = nil, + planningStructureKladrId: String? = nil, + planningStructureType: String? = nil, + planningStructureTypeFull: String? = nil, + planningStructureWithType: String? = nil, + postalBox: String? = nil, + postalCode: String? = nil, + qc: String? = nil, + qcComplete: String? = nil, + qcGeo: String? = nil, + qcHouse: String? = nil, + region: String? = nil, + regionFiasId: String? = nil, + regionIsoCode: String? = nil, + regionKladrId: String? = nil, + regionType: String? = nil, + regionTypeFull: String? = nil, + regionWithType: String? = nil, + settlement: String? = nil, + settlementFiasId: String? = nil, + settlementKladrId: String? = nil, + settlementType: String? = nil, + settlementTypeFull: String? = nil, + settlementWithType: String? = nil, + source: String? = nil, + squareMeterPrice: String? = nil, + street: String? = nil, + streetFiasId: String? = nil, + streetKladrId: String? = nil, + streetType: String? = nil, + streetTypeFull: String? = nil, + streetWithType: String? = nil, + taxOffice: String? = nil, + taxOfficeLegal: String? = nil, + timezone: String? = nil, + unparsedParts: String? = nil + ) { + self.area = area + self.areaFiasId = areaFiasId + self.areaKladrId = areaKladrId + self.areaType = areaType + self.areaTypeFull = areaTypeFull + self.areaWithType = areaWithType + self.beltwayDistance = beltwayDistance + self.beltwayHit = beltwayHit + self.block = block + self.blockType = blockType + self.blockTypeFull = blockTypeFull + self.building = building + self.buildingType = buildingType + self.cadastralNumber = cadastralNumber + self.capitalMarker = capitalMarker + self.city = city + self.cityArea = cityArea + self.cityDistrict = cityDistrict + self.cityDistrictFiasId = cityDistrictFiasId + self.cityDistrictKladrId = cityDistrictKladrId + self.cityDistrictType = cityDistrictType + self.cityDistrictTypeFull = cityDistrictTypeFull + self.cityDistrictWithType = cityDistrictWithType + self.cityFiasId = cityFiasId + self.cityKladrId = cityKladrId + self.cityType = cityType + self.cityTypeFull = cityTypeFull + self.cityWithType = cityWithType + self.country = country + self.countryIsoCode = countryIsoCode + self.federalDistrict = federalDistrict + self.fiasActualityState = fiasActualityState + self.fiasCode = fiasCode + self.fiasId = fiasId + self.fiasLevel = fiasLevel + self.flat = flat + self.flatFiasId = flatFiasId + self.flatArea = flatArea + self.flatPrice = flatPrice + self.flatType = flatType + self.flatTypeFull = flatTypeFull + self.geoLat = geoLat + self.geoLon = geoLon + self.geonameId = geonameId + self.historyValues = historyValues + self.house = house + self.houseFiasId = houseFiasId + self.houseKladrId = houseKladrId + self.houseType = houseType + self.houseTypeFull = houseTypeFull + self.kladrId = kladrId + self.metro = metro + self.okato = okato + self.oktmo = oktmo + self.planningStructure = planningStructure + self.planningStructureFiasId = planningStructureFiasId + self.planningStructureKladrId = planningStructureKladrId + self.planningStructureType = planningStructureType + self.planningStructureTypeFull = planningStructureTypeFull + self.planningStructureWithType = planningStructureWithType + self.postalBox = postalBox + self.postalCode = postalCode + self.qc = qc + self.qcComplete = qcComplete + self.qcGeo = qcGeo + self.qcHouse = qcHouse + self.region = region + self.regionFiasId = regionFiasId + self.regionIsoCode = regionIsoCode + self.regionKladrId = regionKladrId + self.regionType = regionType + self.regionTypeFull = regionTypeFull + self.regionWithType = regionWithType + self.settlement = settlement + self.settlementFiasId = settlementFiasId + self.settlementKladrId = settlementKladrId + self.settlementType = settlementType + self.settlementTypeFull = settlementTypeFull + self.settlementWithType = settlementWithType + self.source = source + self.squareMeterPrice = squareMeterPrice + self.street = street + self.streetFiasId = streetFiasId + self.streetKladrId = streetKladrId + self.streetType = streetType + self.streetTypeFull = streetTypeFull + self.streetWithType = streetWithType + self.taxOffice = taxOffice + self.taxOfficeLegal = taxOfficeLegal + self.timezone = timezone + self.unparsedParts = unparsedParts + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + area = try values.decodeIfPresent(String.self, forKey: .area) + areaFiasId = try values.decodeIfPresent(String.self, forKey: .areaFiasId) + areaKladrId = try values.decodeIfPresent(String.self, forKey: .areaKladrId) + areaType = try values.decodeIfPresent(String.self, forKey: .areaType) + areaTypeFull = try values.decodeIfPresent(String.self, forKey: .areaTypeFull) + areaWithType = try values.decodeIfPresent(String.self, forKey: .areaWithType) + beltwayDistance = try values.decodeIfPresent(String.self, forKey: .beltwayDistance) + beltwayHit = try values.decodeIfPresent(String.self, forKey: .beltwayHit) + block = try values.decodeIfPresent(String.self, forKey: .block) + blockType = try values.decodeIfPresent(String.self, forKey: .blockType) + blockTypeFull = try values.decodeIfPresent(String.self, forKey: .blockTypeFull) + building = try values.decodeIfPresent(String.self, forKey: .building) + buildingType = try values.decodeIfPresent(String.self, forKey: .buildingType) + cadastralNumber = try values.decodeIfPresent(String.self, forKey: .cadastralNumber) + capitalMarker = try values.decodeIfPresent(String.self, forKey: .capitalMarker) + city = try values.decodeIfPresent(String.self, forKey: .city) + cityArea = try values.decodeIfPresent(String.self, forKey: .cityArea) + cityDistrict = try values.decodeIfPresent(String.self, forKey: .cityDistrict) + cityDistrictFiasId = try values.decodeIfPresent(String.self, forKey: .cityDistrictFiasId) + cityDistrictKladrId = try values.decodeIfPresent(String.self, forKey: .cityDistrictKladrId) + cityDistrictType = try values.decodeIfPresent(String.self, forKey: .cityDistrictType) + cityDistrictTypeFull = try values.decodeIfPresent(String.self, forKey: .cityDistrictTypeFull) + cityDistrictWithType = try values.decodeIfPresent(String.self, forKey: .cityDistrictWithType) + cityFiasId = try values.decodeIfPresent(String.self, forKey: .cityFiasId) + cityKladrId = try values.decodeIfPresent(String.self, forKey: .cityKladrId) + cityType = try values.decodeIfPresent(String.self, forKey: .cityType) + cityTypeFull = try values.decodeIfPresent(String.self, forKey: .cityTypeFull) + cityWithType = try values.decodeIfPresent(String.self, forKey: .cityWithType) + country = try values.decodeIfPresent(String.self, forKey: .country) + countryIsoCode = try values.decodeIfPresent(String.self, forKey: .countryIsoCode) + federalDistrict = try values.decodeIfPresent(String.self, forKey: .federalDistrict) + fiasActualityState = try values.decodeIfPresent(String.self, forKey: .fiasActualityState) + fiasCode = try values.decodeIfPresent(String.self, forKey: .fiasCode) + fiasId = try values.decodeIfPresent(String.self, forKey: .fiasId) + fiasLevel = try values.decodeIfPresent(String.self, forKey: .fiasLevel) + flat = try values.decodeIfPresent(String.self, forKey: .flat) + flatFiasId = try values.decodeIfPresent(String.self, forKey: .flatFiasId) + flatArea = try values.decodeIfPresent(String.self, forKey: .flatArea) + flatPrice = try values.decodeIfPresent(String.self, forKey: .flatPrice) + flatType = try values.decodeIfPresent(String.self, forKey: .flatType) + flatTypeFull = try values.decodeIfPresent(String.self, forKey: .flatTypeFull) + geoLat = try values.decodeIfPresent(String.self, forKey: .geoLat) + geoLon = try values.decodeIfPresent(String.self, forKey: .geoLon) + geonameId = try values.decodeIfPresent(String.self, forKey: .geonameId) + historyValues = try values.decodeIfPresent([String].self, forKey: .historyValues) + house = try values.decodeIfPresent(String.self, forKey: .house) + houseFiasId = try values.decodeIfPresent(String.self, forKey: .houseFiasId) + houseKladrId = try values.decodeIfPresent(String.self, forKey: .houseKladrId) + houseType = try values.decodeIfPresent(String.self, forKey: .houseType) + houseTypeFull = try values.decodeIfPresent(String.self, forKey: .houseTypeFull) + kladrId = try values.decodeIfPresent(String.self, forKey: .kladrId) + metro = try values.decodeIfPresent([Metro].self, forKey: .metro) + okato = try values.decodeIfPresent(String.self, forKey: .okato) + oktmo = try values.decodeIfPresent(String.self, forKey: .oktmo) + planningStructure = try values.decodeIfPresent(String.self, forKey: .planningStructure) + planningStructureFiasId = try values.decodeIfPresent(String.self, forKey: .planningStructureFiasId) + planningStructureKladrId = try values.decodeIfPresent(String.self, forKey: .planningStructureKladrId) + planningStructureType = try values.decodeIfPresent(String.self, forKey: .planningStructureType) + planningStructureTypeFull = try values.decodeIfPresent(String.self, forKey: .planningStructureTypeFull) + planningStructureWithType = try values.decodeIfPresent(String.self, forKey: .planningStructureWithType) + postalBox = try values.decodeIfPresent(String.self, forKey: .postalBox) + postalCode = try values.decodeIfPresent(String.self, forKey: .postalCode) + + qc = values.decodeJSONNumber(forKey: CodingKeys.qc) + qcComplete = values.decodeJSONNumber(forKey: CodingKeys.qcComplete) + qcGeo = values.decodeJSONNumber(forKey: CodingKeys.qcGeo) + qcHouse = values.decodeJSONNumber(forKey: CodingKeys.qcHouse) + + region = try values.decodeIfPresent(String.self, forKey: .region) + regionFiasId = try values.decodeIfPresent(String.self, forKey: .regionFiasId) + regionIsoCode = try values.decodeIfPresent(String.self, forKey: .regionIsoCode) + regionKladrId = try values.decodeIfPresent(String.self, forKey: .regionKladrId) + regionType = try values.decodeIfPresent(String.self, forKey: .regionType) + regionTypeFull = try values.decodeIfPresent(String.self, forKey: .regionTypeFull) + regionWithType = try values.decodeIfPresent(String.self, forKey: .regionWithType) + settlement = try values.decodeIfPresent(String.self, forKey: .settlement) + settlementFiasId = try values.decodeIfPresent(String.self, forKey: .settlementFiasId) + settlementKladrId = try values.decodeIfPresent(String.self, forKey: .settlementKladrId) + settlementType = try values.decodeIfPresent(String.self, forKey: .settlementType) + settlementTypeFull = try values.decodeIfPresent(String.self, forKey: .settlementTypeFull) + settlementWithType = try values.decodeIfPresent(String.self, forKey: .settlementWithType) + source = try values.decodeIfPresent(String.self, forKey: .source) + squareMeterPrice = try values.decodeIfPresent(String.self, forKey: .squareMeterPrice) + street = try values.decodeIfPresent(String.self, forKey: .street) + streetFiasId = try values.decodeIfPresent(String.self, forKey: .streetFiasId) + streetKladrId = try values.decodeIfPresent(String.self, forKey: .streetKladrId) + streetType = try values.decodeIfPresent(String.self, forKey: .streetType) + streetTypeFull = try values.decodeIfPresent(String.self, forKey: .streetTypeFull) + streetWithType = try values.decodeIfPresent(String.self, forKey: .streetWithType) + taxOffice = try values.decodeIfPresent(String.self, forKey: .taxOffice) + taxOfficeLegal = try values.decodeIfPresent(String.self, forKey: .taxOfficeLegal) + timezone = try values.decodeIfPresent(String.self, forKey: .timezone) + unparsedParts = try values.decodeIfPresent(String.self, forKey: .unparsedParts) + } + } +} diff --git a/Sources/IIDadata/Model/Address/AddressSuggestionQuery.swift b/Sources/IIDadata/Model/Address/AddressSuggestionQuery.swift new file mode 100644 index 0000000..25e1f64 --- /dev/null +++ b/Sources/IIDadata/Model/Address/AddressSuggestionQuery.swift @@ -0,0 +1,191 @@ +// +// AddressSuggestionQuery.swift +// IIDadata +// +// Created by Yachin Ilya on 12.05.2020. +// + +import Foundation + +// MARK: - AddressSuggestionQuery + +/// `AddressSuggestionQuery` represents an serializable object used to perform certain queries. +public class AddressSuggestionQuery: Encodable, DadataQueryProtocol { + // Nested Types + + enum CodingKeys: String, CodingKey { + case query + case resultsCount = "count" + case language + case constraints = "locations" + case regionPriority = "locations_boost" + case upperScaleLimit = "from_bound" + case lowerScaleLimit = "to_bound" + case trimRegionResult = "restrict_value" + } + + // Properties + + public var resultsCount: Int? = 10 + public var language: QueryResultLanguage? + public var constraints: [AddressQueryConstraint]? + public var regionPriority: [RegionPriority]? + public var upperScaleLimit: ScaleBound? + public var lowerScaleLimit: ScaleBound? + public var trimRegionResult: Bool = false + + let query: String + let queryType: AddressQueryType + + // Lifecycle + + /// New instance of AddressSuggestionQuery defaulting to simple address suggestions request. + /// - Parameter query: Query string to be sent to API. + public convenience init(_ query: String) { + self.init(query, ofType: .address) + } + + /// New instance of AddressSuggestionQuery. + /// - Parameter query: Query string to be sent to API. + /// - Parameter ofType: Type of request to send to API. + /// It could be of type + /// `address` — standart address suggestion query; + /// `fiasOnly` — query to only search in FIAS database: less matches, state provided address data only; + /// `findByID` — takes KLADR or FIAS ID as a query parameter to lookup additional data. + public required init(_ query: String, ofType type: AddressQueryType) { + self.query = query + queryType = type + } + + // Functions + + /// Serializes AddressSuggestionQuery to send over the wire. + func toJSON() throws -> Data { + if constraints?.isEmpty ?? false { constraints = nil } + if regionPriority?.isEmpty ?? false { regionPriority = nil } + if let upper = upperScaleLimit, + let lower = lowerScaleLimit, + upper.value == nil || lower.value == nil + { + upperScaleLimit = nil + lowerScaleLimit = nil + } + return try JSONEncoder().encode(self) + } + + /// Returns an API endpoint for different request types: + /// `address` — "suggest/address" + /// `fiasOnly` — "suggest/fias" + /// `findByID` — "findById/address" + func queryEndpoint() -> String { return queryType.rawValue } +} + +// MARK: - ScaleLevel + +/// Levels of `from_bound` and `to_bound` according to +/// [Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=285343795). +public enum ScaleLevel: String, Encodable { + case country + case region + case area + case city + case settlement + case street + case house + case flat +} + +// MARK: - AddressQueryConstraint +/// AddressQueryConstraint used to limit search results according to +/// [Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=204669108). +@available(iOS 14.0, *) +public struct AddressQueryConstraint: Codable { + // Properties + + public var region: String? + public var city: String? + public var street_type_full: String? + public var settlement_type_full: String? + public var city_district_type_full: String? + public var city_type_full: String? + public var area_type_full: String? + public var region_type_full: String? + public var country: String? + public var country_iso_code: String? + public var region_iso_code: String? + public var kladr_id: String? + public var region_fias_id: String? + public var area_fias_id: String? + public var city_fias_id: String? + public var settlement_fias_id: String? + public var street_fias_id: String? + + // Lifecycle + + public init( + region: String? = nil, + city: String? = nil, + street_type_full: String? = nil, + settlement_type_full: String? = nil, + city_district_type_full: String? = nil, + city_type_full: String? = nil, + area_type_full: String? = nil, + region_type_full: String? = nil, + country: String? = nil, + country_iso_code: String? = nil, + region_iso_code: String? = nil, + kladr_id: String? = nil, + region_fias_id: String? = nil, + area_fias_id: String? = nil, + city_fias_id: String? = nil, + settlement_fias_id: String? = nil, + street_fias_id: String? = nil + ) { + self.region = region + self.city = city + self.street_type_full = street_type_full + self.settlement_type_full = settlement_type_full + self.city_district_type_full = city_district_type_full + self.city_type_full = city_type_full + self.area_type_full = area_type_full + self.region_type_full = region_type_full + self.country = country + self.country_iso_code = country_iso_code + self.region_iso_code = region_iso_code + self.kladr_id = kladr_id + self.region_fias_id = region_fias_id + self.area_fias_id = area_fias_id + self.city_fias_id = city_fias_id + self.settlement_fias_id = settlement_fias_id + self.street_fias_id = street_fias_id + } +} + +// MARK: - RegionPriority + +/// Helps prioritize specified region in search results by KLADR ID. +public struct RegionPriority: Encodable { + // Properties + + public var kladr_id: String? + + // Lifecycle + + public init(kladr_id: String?) { self.kladr_id = kladr_id } +} + +// MARK: - ScaleBound + +/// ScaleBound holds a value for `from_bound` and `to_bound` as a ScaleLevel. +/// See +/// [Dadata online API documentation](https://confluence.hflabs.ru/pages/viewpage.action?pageId=285343795) for API reference. +public struct ScaleBound: Encodable { + // Properties + + public var value: ScaleLevel? + + // Lifecycle + + public init(value: ScaleLevel?) { self.value = value } +} + diff --git a/Sources/IIDadata/Model/Address/AddressSuggestionResponse.swift b/Sources/IIDadata/Model/Address/AddressSuggestionResponse.swift new file mode 100644 index 0000000..5c3c188 --- /dev/null +++ b/Sources/IIDadata/Model/Address/AddressSuggestionResponse.swift @@ -0,0 +1,41 @@ +// +// Models Generated using http://www.jsoncafe.com/ +// Created on May 11, 2020 + +import Foundation + +// MARK: - AddressSuggestionResponse + +/// AddressSuggestionResponse represents a deserializable object used to hold API response. +public struct AddressSuggestionResponse: Decodable, Sendable { + public let suggestions: [AddressSuggestion]? +} + +// MARK: - Metro + +/// Structure holding metro station name, name of a line and distance to suggested address. +/// If there aren't metro stations nearby or API token used not subscribed to "Maximal" package +/// `nil` is returned instead. +public struct Metro: Decodable, Sendable { + // Nested Types + + enum CodingKeys: String, CodingKey { + case name, line, distance + } + + // Properties + + public let name: String? + public let line: String? + public let distance: String? + + // Lifecycle + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + name = try values.decodeIfPresent(String.self, forKey: .name) + line = try values.decodeIfPresent(String.self, forKey: .line) + + distance = values.decodeJSONNumber(forKey: CodingKeys.distance) + } +} diff --git a/Sources/IIDadata/Model/Address/ReverseGeocodeQuery.swift b/Sources/IIDadata/Model/Address/ReverseGeocodeQuery.swift new file mode 100644 index 0000000..ab55bdb --- /dev/null +++ b/Sources/IIDadata/Model/Address/ReverseGeocodeQuery.swift @@ -0,0 +1,75 @@ +// +// ReverseGeocodeQuery.swift +// IIDadata +// +// Created by Yachin Ilya on 12.05.2020. +// + +import Foundation + +// MARK: - ReverseGeocodeQuery + +/// ReverseGeocodeQuery represents an serializable object used to perform reverse geocode queries. +public class ReverseGeocodeQuery: Encodable, DadataQueryProtocol { + // Nested Types + + enum CodingKeys: String, CodingKey { + case latitude = "lat" + case longitude = "lon" + case resultsCount = "count" + case language + case searchRadius = "radius_meters" + } + + // Properties + + public var resultsCount: Int? = 10 + public var language: QueryResultLanguage? + public var searchRadius: Int? + + let latitude: Double + let longitude: Double + let endpoint: String + + // Lifecycle + + /// New instance of ReverseGeocodeQuery. + /// - Parameter query: Query should contain latitude and longitude of the point of interest. + /// - Parameter delimeter: Single character delimeter to separate latitude and longitude. + /// - Throws: May throw if parsing of latitude and longitude out of query fails. + public convenience init(query: String, delimeter: Character = ",") throws { + let splitStr = query.split(separator: delimeter) + + let latStr = String(splitStr[0]).trimmingCharacters(in: .whitespacesAndNewlines) + let lonStr = String(splitStr[1]).trimmingCharacters(in: .whitespacesAndNewlines) + + guard let latitude = Double(latStr), + let longitude = Double(lonStr) + else { + throw NSError(domain: "Dadata ReverseGeocodeQuery", + code: -1, + userInfo: ["description": "Failed to parse coordinates from \(query) using delimiter \(delimeter)"]) + } + + self.init(latitude: latitude, longitude: longitude) + } + + /// New instance of ReverseGeocodeQuery. + /// - Parameter latitude: Latitude of the point of interest. + /// - Parameter longitude: Longitude of the point of interest. + public required init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + endpoint = Constants.revGeocodeEndpoint + } + + // Functions + + /// Serializes ReverseGeocodeQuery to send over the wire. + func toJSON() throws -> Data { + return try JSONEncoder().encode(self) + } + + /// Returns an API endpoint for reverse geocode query. + func queryEndpoint() -> String { return endpoint } +} diff --git a/Sources/IIDadata/Model/Fio/FioData.swift b/Sources/IIDadata/Model/Fio/FioData.swift new file mode 100644 index 0000000..755c1ac --- /dev/null +++ b/Sources/IIDadata/Model/Fio/FioData.swift @@ -0,0 +1,136 @@ +// +// FioData.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +import Foundation + +public extension FioSuggestion { + // MARK: - FioData + + /// A structure representing detailed FIO data. + /// + /// - Parameters: + /// - surname: Фамилия + /// - name: Имя + /// - patronymic: Отчество + /// - gender: Пол + /// - qc: Код качества + /// - source: Не заполняется + struct FioData: Codable, Equatable, Hashable { + // Nested Types + + public enum CodingKeys: String, CodingKey { + case surname, name, patronymic, gender, qc + + // Computed Properties + + public var partQueryValue: String { + switch self { + case .surname: return "SURNAME" + case .name: return "NAME" + case .patronymic: return "PATRONYMIC" + default: return "" + } + } + } + + // Properties + + /// The surname (last name). + public let surname: String? + + /// The given name (first name). + public let name: String? + + /// The patronymic (middle name). + public let patronymic: String? + + /// The gender of the individual. + public let gender: Gender? + + /// The quality code. + /// + /// - `0`: если все части ФИО найдены в справочниках. + /// - `1`: если в ФИО есть часть не из справочника + /// - `null`: если нет части ФИО в справочниках. + public let qc: String? + + // Lifecycle + + /// Initializes a new FioData. + /// + /// - Parameters: + /// - surname: The surname (last name). + /// - name: The given name (first name). + /// - patronymic: The patronymic (middle name). + /// - gender: The gender of the individual. + /// - qc: The quality code. + public init( + surname: String?, + name: String?, + patronymic: String?, + gender: Gender? = nil, + qc: String? = nil + ) { + self.surname = surname + self.name = name + self.patronymic = patronymic + self.gender = gender + self.qc = qc + } + + // MARK: - Codable + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + surname = try container.decodeIfPresent(String.self, forKey: .surname) + name = try container.decodeIfPresent(String.self, forKey: .name) + patronymic = try container.decodeIfPresent(String.self, forKey: .patronymic) + gender = try Gender(rawValue: container.decodeIfPresent(Gender.RawValue.self, forKey: .gender) ?? Gender.unknown.rawValue) + qc = try container.decodeIfPresent(String.self, forKey: .qc) + } + + // Static Functions + + // MARK: - Comparable + + public static func < (lhs: FioData, rhs: FioData) -> Bool { + return (lhs.qc ?? "0") < (rhs.qc ?? "0") && lhs.qc != rhs.qc + } + + // Functions + + /// Returns the full name of the `FioData`. + public func fullName() -> String { + return [surname, name, patronymic].compactMap { $0 }.joined(separator: " ") + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(surname) + hasher.combine(name) + hasher.combine(patronymic) + hasher.combine(gender) + hasher.combine(qc) + } + + public subscript(_ key: CodingKeys) -> String? { + switch key { + case .surname: + return surname + case .name: + return name + case .patronymic: + return patronymic + case .gender: + return gender?.rawValue + case .qc: + return qc.map { String($0) } + } + } + } +} diff --git a/Sources/IIDadata/Model/Fio/FioSuggestion.swift b/Sources/IIDadata/Model/Fio/FioSuggestion.swift new file mode 100644 index 0000000..f388d98 --- /dev/null +++ b/Sources/IIDadata/Model/Fio/FioSuggestion.swift @@ -0,0 +1,76 @@ +// +// FioSuggestion.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +// MARK: - FioSuggestion + +/// Every single suggestion is represented as `FioSuggestion`. +/// - Parameters: +/// - value` : ФИО одной строкой +/// - unrestricted_value` : == value +/// - data: Detailed FIO data structure. +/// +/// +/// All the data returned in response to suggestion query. +/// +/// - SeeAlso: ``FioData`` +/// +public struct FioSuggestion: Encodable, Suggestion { + // Nested Types + + enum CodingKeys: String, CodingKey { + case value + case unrestrictedValue = "unrestricted_value" + case data + } + + // Properties + + /// Detailed FIO data. + public let data: FioData? + + /// The suggested FIO value. + public let value: String + + /// The unrestricted suggested FIO value. (` == value `) + public let unrestrictedValue: String? + + // Lifecycle + + /// Initializes a new FioSuggestion. + /// + /// - Parameters: + /// - value: The suggested FIO value. + /// - unrestrictedValue: The unrestricted suggested FIO value. + /// - data: Detailed FIO data. + public init( + _ value: String, + unrestrictedValue: String, + data: FioData + ) { + self.value = value + self.unrestrictedValue = unrestrictedValue + self.data = data + } + + public init(from decoder: Decoder) throws { + do { + let values = try decoder.container(keyedBy: CodingKeys.self) + value = try values.decode(String.self, forKey: .value) + unrestrictedValue = try values.decodeIfPresent(String.self, forKey: .unrestrictedValue) + data = try values.decodeIfPresent(FioData.self, forKey: .data) + } catch { + dump(error, name: "FioSuggestion Decoding Error") + throw error + } + } + + // Functions + + public subscript(_ key: FioData.CodingKeys) -> String? { + return data?[key] + } +} diff --git a/Sources/IIDadata/Model/Fio/FioSuggestionQuery.swift b/Sources/IIDadata/Model/Fio/FioSuggestionQuery.swift new file mode 100644 index 0000000..03f0e87 --- /dev/null +++ b/Sources/IIDadata/Model/Fio/FioSuggestionQuery.swift @@ -0,0 +1,148 @@ +// +// FioSuggestionQuery.swift +// IIDadata +// +// Created by NSFuntik on 07.08.2024. +// + +// MARK: - FIO Suggestion Query and Response Structures + +import Foundation + +// MARK: - FioSuggestionQuery + +/** Подсказки по ФИО (API) + # Parameters: + - query: да Запрос, для которого нужно получить подсказки + - count: Количество возвращаемых подсказок (по умолчанию — 10, максимум — 20). + - parts: Подсказки по части ФИО + - gender: Пол (UNKNOWN / MALE / FEMALE) (``Gender``) + + # Description: + Помогает человеку быстро ввести ФИО на веб-форме или в приложении. + + ## Что умеет: + - Подсказывает ФИО одной строкой или отдельно фамилию, имя, отчество. + - Исправляет клавиатурную раскладку («fynjy» → «Антон»). + - Определяет пол. + + ## Не умеет: + - ❌ Автоматически (без участия человека) обработать ФИО из базы или файла. + - ❌ Транслитерировать (Juliia Somova → Юлия Сомова). + - ❌ Склонять по падежам (кого? кому? кем?). + + ## Примечания: + Подсказки не подходят для автоматической обработки ФИО. Они предлагают варианты, но не гарантируют, что угадали правильно. Поэтому окончательное решение всегда должен принимать человек. + + */ +public struct FioSuggestionQuery: Codable, DadataQueryProtocol { + // Nested Types + + public enum Part: String, RawRepresentable, CodingKey, Codable { + case name = "NAME", patronymic = "PATRONYMIC", surname = "SURNAME" + + // Computed Properties + + public var rawValue: String { + switch self { + case .name: return "NAME" + case .patronymic: return "PATRONYMIC" + case .surname: return "SURNAME" + } + } + + // Lifecycle + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + self = Part(rawValue: value) ?? .name + } + + public init?(rawValue: String) { + switch rawValue.uppercased() { + case "NAME": self = .name + case "PATRONYMIC": self = .patronymic + case "SURNAME": self = .surname + default: return nil + } + } + + // Functions + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(rawValue) + } + } + + enum CodingKeys: String, CodingKey { + case query, count, parts, gender + } + + // Properties + + /// The search query string. + let query: String + + /// The number of suggestions to return (default is 10). + let count: IntegerLiteralType? + + /// Indicates if the name parts should be returned separately. Default is `false`. + let parts: [Part]? + + /// Requested gender for the suggested names. + let gender: Gender? + + // Lifecycle + + /// Initializes a new `FioSuggestionQuery`. + /// + /// - Parameters: + /// - query: The search query string. + /// - count: The number of suggestions to return (default is 10). + /// - parts: Indicates if the name parts should be returned separately (default is `false`). + /// - gender: Requested gender for the suggested names. + public init( + _ query: String, + count: Int? = 10, + parts: [Part]? = nil, + gender: Gender? = nil + ) { + self.query = query + self.count = count + self.parts = parts + self.gender = gender + } + + // Functions + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(query, forKey: .query) + try container.encodeIfPresent(count, forKey: .count) + try container.encodeIfPresent(gender?.codingKey, forKey: .gender) + if let parts = parts?.compactMap(\.rawValue), !parts.isEmpty { + try container.encodeIfPresent(parts, forKey: .parts) + } + } + + /// Returns the endpoint for the query. + /// + /// - Returns: The endpoint as a string. + func queryEndpoint() -> String { + return "suggest/fio" + } + + /// Encodes the query into a JSON data representation. + /// + /// - Returns: A `Data` object containing the JSON representation of the query. + /// - Throws: An error if the encoding fails. + func toJSON() throws -> Data { + try JSONEncoder().encode(self) + } +} diff --git a/Sources/IIDadata/Model/Fio/FioSuggestionResponse.swift b/Sources/IIDadata/Model/Fio/FioSuggestionResponse.swift new file mode 100644 index 0000000..40fe198 --- /dev/null +++ b/Sources/IIDadata/Model/Fio/FioSuggestionResponse.swift @@ -0,0 +1,83 @@ +// +// FioSuggestionResponse.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +import Foundation + +// MARK: - FioSuggestionResponse + +/// A structure representing the response of a FIO suggestion query. +public struct FioSuggestionResponse: Codable { + // Nested Types + + enum CodingKeys: String, CodingKey { + case suggestions + } + + // Properties + + /// An array of FIO suggestions. + let suggestions: [FioSuggestion] + + // Lifecycle + + /// Initializes a new FioSuggestionResponse. + /// + /// - Parameters: + /// - suggestions: An array of FIO suggestions. + public init(suggestions: [FioSuggestion]) { + self.suggestions = suggestions + } + + /// Initializes a new instance from the given decoder. + /// + /// - Parameter decoder: The decoder to read data from. + /// - Throws: An error if the decoding fails. + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + suggestions = try values.decode([FioSuggestion].self, forKey: .suggestions) + } + + // Functions + + /// Encodes the current instance into the given encoder. + /// + /// - Parameter encoder: The encoder to write data to. + /// - Throws: An error if the encoding fails. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(suggestions, forKey: .suggestions) + } + + /// Encodes the response into a JSON data representation. + /// + /// - Returns: A `Data` object containing the JSON representation of the response. + /// - Throws: An error if the encoding fails. + func toJSON() throws -> Data { + return try JSONEncoder().encode(self) + } + + /// Returns the endpoint for querying. + /// + /// - Returns: The endpoint as a string. + func queryEndpoint() -> String { + return "suggest/fio" + } + + /// Returns the type of the query. + /// + /// - Returns: The query type as a string. + func queryType() -> String { + return "fio" + } + + /// Returns the number of suggestions in the response. + /// + /// - Returns: The count of suggestions. + func resultsCount() -> Int { + return suggestions.count + } +} diff --git a/Sources/IIDadata/Model/Fio/Gender.swift b/Sources/IIDadata/Model/Fio/Gender.swift new file mode 100644 index 0000000..083f7ba --- /dev/null +++ b/Sources/IIDadata/Model/Fio/Gender.swift @@ -0,0 +1,85 @@ +// +// has.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +import Foundation +// MARK: - Gender + +/** + An enumeration representing gender with support for encoding and decoding, + equatability, and hashability. + The `Gender` enum has cases for male, female, and unknown genders, with + corresponding Russian strings for each case. + It also has an inner `CodingKeys` enumeration for mapping JSON keys to + enum cases and an initializer for decoding from a decoder. + + # Parameters: + - male: The case representing the `male` gender + - female: The case representing the `female` gender + - unknown: The case representing an `unknown` gender + + - SeeAlso: ``FioSuggestionResponse``, ``FioSuggestionQuery`` + + */ +public enum Gender: String, Codable, Equatable, Hashable, CaseIterable { + public static let allCases: [Gender] = [.male, .female] + /// The case representing the `male` gender with a Russian string value. + case male = "Мужской" + /// The case representing the `female` gender with a Russian string value. + case female = "Женский" + /// The case representing an `unknown` gender with a Russian string value. + case unknown = "–" + + // Nested Types + + /// An enumeration for defining the coding keys used for decoding. + public enum CodingKeys: String, CodingKey { + case male = "MALE" + case female = "FEMALE" + case unknown = "UNKNOWN" + } + + // Computed Properties + + /// The string value corresponding to the `Gender` case. + public var codingKey: String { + switch self { + case .male: return CodingKeys.male.rawValue + case .female: return CodingKeys.female.rawValue + case .unknown: return CodingKeys.unknown.rawValue + } + } + + // Lifecycle + + /// The `Gender` case corresponding to the given string value. + public init(rawValue: String) { + switch rawValue { + case "Мужской", "MALE": self = .male + case "Женский", "FEMALE": self = .female + default: self = .unknown + } + } + + /** + Initializes a `Gender` instance from the given decoder. + This initializer attempts to decode a string from the specified container + using the `unknown` coding key. Based on the decoded string, it sets + the appropriate `Gender` case. + + - Parameter decoder: The decoder to initialize the instance from. + - Throws: An error if decoding fails. + */ + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + switch try container.decode(String.self, forKey: .unknown) { + case "MALE": self = .male + case "FEMALE": self = .female + default: self = .unknown + } + } +} + diff --git a/Sources/IIDadata/Model/SuggestionProtocol.swift b/Sources/IIDadata/Model/SuggestionProtocol.swift new file mode 100644 index 0000000..f086b83 --- /dev/null +++ b/Sources/IIDadata/Model/SuggestionProtocol.swift @@ -0,0 +1,95 @@ +// +// Suggestion.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// +import SwiftUI + +// MARK: - Suggestion + +/** + Автодополнение при вводе («подсказки»)\ + Протокол, которыйпредставляет собой десериализуемый объект, используемый для хранения ответа [DaData ](https://dadata.ru/) API + + - Parameter value: текст подсказки одной строкой. + - Parameter unrestrictedValue: дополнительное поле для текста подсказки. + - Important: Поля не предназначены для автоматических интеграций, потому что их формат может со временем измениться + + > Пример как заполняются поля для разных справочников: + > - ``AddressSuggestion``: Помогает человеку быстро ввести корректный адрес на веб-форме или в приложении. + > - ``FioSuggestion``: Помогает человеку быстро ввести ФИО на веб-форме или в приложении + - SeeAlso: [DaData API documentation](https://dadata.ru/api/suggest/) + */ +public protocol Suggestion: Decodable, Equatable, Hashable, Identifiable, Sendable { + typealias Value = String + associatedtype Data: Decodable + var unrestrictedValue: Value? { get } + var value: Value { get } + var data: Data? { get } + var type: SuggestionType { get } +} + +public extension Suggestion where Self == FioSuggestion { + /// A computed property that returns the suggestion type for Fio suggestion, which is `.fio` + var type: SuggestionType { .fio } +} + +public extension Suggestion where Self == AddressSuggestion { + /// A computed property that returns the suggestion type for Address suggestion, which is `.address` + var type: SuggestionType { .address } +} + +public extension Suggestion { + /// Compares two Suggestion instances for equality based on their `value` property. + /// + /// - Parameters: + /// - lhs: The left-hand side `Suggestion` instance. + /// - rhs: The right-hand side `Suggestion` instance. + /// - Returns: A Boolean value indicating whether the two instances are equal. + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } + + /// Hashes the essential components of the `Suggestion` instance by combining the `value` property. + /// + /// - Parameter hasher: The hasher to use when combining the components of this instance. + func hash(into hasher: inout Hasher) { + hasher.combine(value) + } + + /// A computed property that returns the ID of the `Suggestion` instance, which is the `value` property. + var id: String { + value + } + + /// A computed property that returns a description of the `Suggestion` instance, which is the `value` property. + var description: String { + value + } +} + +// MARK: - SuggestionType + +/** + An enumeration representing the types of suggestions available. + - `address`: Suggestion for an address. + - `fio`: Suggestion for a full name (first name, last name, etc.). + */ +public enum SuggestionType { + case address + case fio +} + +// MARK: - IIDadataError + +/** + An enumeration representing possible errors that can occur while fetching suggestions. + - `noSuggestions`: Indicates that no suggestions were found. + */ +public enum IIDadataError: Error { + case noSuggestions + case invalidInput + case invalidResponse + case unknown(String) +} diff --git a/Sources/IIDadataUI/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift b/Sources/IIDadataUI/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift new file mode 100644 index 0000000..be36bc4 --- /dev/null +++ b/Sources/IIDadataUI/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift @@ -0,0 +1,90 @@ +// +// DadataSuggestionsKey.swift +// IIDadata +// +// Created by NSFuntik on 13.08.2024. +// +import IIDadata +import SwiftUI + +// MARK: - View Extension + +public extension View { + /// A view modifier to display suggestions for the given input using `Dadata` API. + /// + /// This extension provides an easy way to apply the `IIDadataSuggestsPopover` view modifier to any `View`. + /// + /// - Parameters: + /// - apiKey: The API key for the `Dadata` API. + /// - text: A binding to the input text. + /// - suggestions: A binding to the list of suggestions. + /// - onSuggestionSelected: A closure to handle the selection of a suggestion. + /// + /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. + @available(iOS 15.0, *) @ViewBuilder + func withDadataSuggestions( + isPresented: Binding = .constant(true), + apiKey: String, + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) -> some View { + modifier( + IIDadataSuggestsPopover( + apiKey: apiKey, + input: text, + suggestions: suggestions, + textfieldHeight: textfieldHeight, + onSuggestionSelected: onSuggestionSelected + ) + ).environment(\.dadataSuggestions, try? DadataSuggestions.shared(apiKey: apiKey)) + } + + /// A view modifier to display suggestions for the given input using `Dadata` API. + /// + /// This extension provides an easy way to apply the `IIDadataSuggestsPopover` view modifier to any `View`. + /// + /// - Parameters: + /// - apiKey: The API key for the `Dadata` API. + /// - text: A binding to the input text. + /// - suggestions: A binding to the list of suggestions. + /// - textfieldHeight: The height of the text field. + /// - onSuggestionSelected: A closure to handle the selection of a suggestion. + /// + /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. + @available(iOS 15.0, *) @ViewBuilder + func withDadataSuggestions( + isPresented: Binding = .constant(true), + dadata: DadataSuggestions, + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) -> some View { + modifier( + IIDadataSuggestsPopover( + input: text, + suggestions: suggestions, + textfieldHeight: textfieldHeight, + onSuggestionSelected: onSuggestionSelected + ) + ).environment(\.dadataSuggestions, dadata) + } +} + +extension EnvironmentValues { + /// The current `DadataSuggestions` instance. + @available(iOS 15.0, *) + var dadataSuggestions: DadataSuggestions? { + get { self[DadataSuggestionsKey.self] } + set { self[DadataSuggestionsKey.self] = newValue } + } +} + +// MARK: - DadataSuggestionsKey + +/// An environment key that provides access to the current `DadataSuggestions` instance. +struct DadataSuggestionsKey: EnvironmentKey { + static let defaultValue: DadataSuggestions? = nil +} diff --git a/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift b/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift new file mode 100644 index 0000000..b2b2389 --- /dev/null +++ b/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -0,0 +1,435 @@ +// +// IIDadataSuggestionsView.swift +// IIDadata +// +// Created by NSFuntik on 11.08.2024. +// +import SwiftUI +import IIDadata + +// MARK: - IIDadataSuggestsPopover + +/// A view modifier that provides a text field with suggestions as the user types. +/// +/// The `IIDadataSuggestable` fetches suggestions for a `TextField`'s input and displays a list of suggestions, obtained asynchronously. +/// When a suggestion is selected, it triggers an action `onSuggestionSelected`. +/// +/// - Parameters: +/// - apiKey: The API key for the `Dadata` API. +/// - text: A binding to the input text. +/// - suggestions: A binding to the list of suggestions. +/// - onSuggestionSelected: A closure to handle the selection of a suggestion. +/// +/// - Returns: A `View Modifier` that provides a text field with suggestions as the user types. +/// +/// - Note: The `getSuggestions` function is called asynchronously and updates the `suggestions` property. +/// +/// - SeeAlso: ``DadataSuggestions`` +@available(iOS 15.0, *) +public struct IIDadataSuggestsPopover: ViewModifier { + /// The action to perform when a suggestion is selected. + /// - Parameter isPresentingPopover: An input parameter that indicates whether the popover is presented. + public typealias OnSuggestionSelected = (S) -> Void + + /// The DadataSuggestions instance for fetching suggestions. + @Environment(\.dadataSuggestions) private var dadata + + // Properties + + /// The action to perform when a suggestion is selected. + public var onSuggestionSelected: OnSuggestionSelected + + /// The TextField input text. + @Binding var text: String + /// The list of suggestions. + @Binding var suggestions: [S]? + + let textfieldHeight: CGFloat + + /// A binding to a boolean value that indicates whether the popover is presented. + @Binding var isPopoverPresented: Bool + + private let viewID = UUID().uuidString + + /// The error message. + @State private var error: String? = nil + + @Namespace private var nsPopover + + @FocusState private var isFocused: Bool + + // Computed Properties + + var idealHeight: CGFloat { + let suggestions = Double(self.suggestions?.endIndex ?? 1) + return textfieldHeight + + (UIFont.preferredFont(forTextStyle: .callout).lineHeight.scaled(by: suggestions)) + } + + var maxHeight: CGFloat { + idealHeight + 111 + } + + // Lifecycle + + /// Initializes a new instance of a custom view with input text binding, an instance of `DadataSuggestions`, + /// suggestions binding, placeholder text, and a closure to handle suggestion selection. + /// + /// - Parameters: + /// - text: A binding to a text input. + /// - dadata: An instance of `DadataSuggestions` for fetching suggestions. + /// - suggestions: A binding to an optional array of suggestions of type `[T]`. + /// - placeholder: A string placeholder text. + /// - isPopoverPresented: A binding to a boolean value that indicates whether the popover is presented. + /// - onSuggestionSelected: A closure that gets executed when a suggestion is selected. + public init( + isPresented: Binding = .constant(true), + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) where S: Suggestion { + _isPopoverPresented = isPresented + _text = text + _suggestions = suggestions + self.textfieldHeight = textfieldHeight + self.onSuggestionSelected = onSuggestionSelected + guard (try? DadataSuggestions.shared()) != nil else { + debugPrint("DaData service shared instanvce didn't configurated properly") + return + } + } + + /// Initializes a new instance of a custom view with an API key, input text binding, + /// suggestions binding, and a closure to handle suggestion selection. + /// + /// - Parameters: + /// - apiKey: A string containing the API key for `DadataSuggestions`. + /// - text: A binding to a text input. + /// - suggestions: A binding to an optional array of suggestions of type `[T]`. + /// - isPresented: A binding to a boolean value that indicates whether the popover is presented. + /// - onSuggestionSelected: A closure that gets executed when a suggestion is selected. + public init( + isPresented: Binding = .constant(true), + apiKey _: String, + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) where S: Suggestion { + _isPopoverPresented = isPresented + _text = text + _suggestions = suggestions + self.textfieldHeight = textfieldHeight + 6 + self.onSuggestionSelected = onSuggestionSelected + } + + // Content + + @ViewBuilder public func body(content: Content) -> some View { + content + .coordinateSpace(name: nsPopover) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .layoutPriority(1) + .zIndex(1) + .compositingGroup() + .padding(.bottom, isPopoverPresented && !(suggestions?.isEmpty ?? true) ? idealHeight : 0) + .background { + GeometryReader { geometry in + Color.clear + .matchedGeometryEffect( + id: viewID, + in: nsPopover, + properties: .frame, + anchor: .top, + isSource: true + ) + .frame(height: idealHeight) + .offset(y: textfieldHeight / 2) + } + .overlay(alignment: .bottom) { + if let suggestions, !suggestions.isEmpty, isPopoverPresented { + popover() + .compositingGroup() + .matchedGeometryEffect( + id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: false + ) + .opacity(suggestions.isEmpty == true ? 0 : 1) + } + } + } + .onChange(of: text, perform: getSuggestions(for:)) + .task(id: text) { + isPopoverPresented = true + getSuggestions(for: text) + } + .focused($isFocused).animation(.interactiveSpring, value: isFocused) + .onChange(of: isFocused) { isPopoverPresented = $0 } + .animation(.spring, value: isPopoverPresented) + .animation(.smooth, value: suggestions) + } + + @ViewBuilder func popover() -> some View { + if isPopoverPresented, let suggestions = suggestions?.compactMap(\.self), !suggestions.isEmpty { + SuggestionsPopover(for: text, with: suggestions, height: textfieldHeight) { suggestion in + text = suggestion.value + onSuggestionSelected(suggestion) + + isPopoverPresented = suggestions.count != 1 + } + .background(.bar, in: .rect(cornerRadius: 10)) + .frame( + minHeight: suggestions.endIndex > 1 ? 111 : 0, + idealHeight: idealHeight, + maxHeight: maxHeight, + alignment: .bottom + ) + .tag(viewID, includeOptional: false) + .edgesIgnoringSafeArea(.all).ignoresSafeArea(.all) + .transition(.offset(x: 0, y: -idealHeight).animation(.spring)) + } + } + + // Functions + + /// Fetches suggestions based on the input text. + /// + @MainActor private func getSuggestions(for input: String) { + Task { await getAsyncSuggestions(for: input) } + } + + /// Fetches suggestions based on the input text. + /// + @MainActor @Sendable private func getAsyncSuggestions(for input: String) async { + // let input = text + + guard !input.isEmpty else { + error = "Error fetching suggestions: \(IIDadataError.invalidInput)" + return + } + + do { + switch S.self { + case is AddressSuggestion.Type: + debugPrint("AddressSuggestion – Fetching address suggestions for: \(input)") + + let addressSuggestions = try await getAddressSuggestions(for: input) as! [S] + suggestions = addressSuggestions + + case is FioSuggestion.Type: + debugPrint("FioSuggestion – Fetching Fio suggestions for: \(input)") + + let fioSuggestions = try await getFioSuggestions() as! [S] + suggestions = fioSuggestions + + default: + debugPrint("Unknown Suggestion – Fetching suggestions for: \(input)") + throw IIDadataError.unknown(String(describing: S.self)) + } + } catch { + self.error = "Error fetching suggestions: \(error)" + suggestions = nil + } + } +} + +@available(iOS 15.0, *) +extension IIDadataSuggestsPopover { + /// Fetches suggestions based on the input text. + /// + /// This asynchronous method fetches address or FIO (Full name) suggestions + /// based on the type of the first element in the `suggestions` array. + /// Fetches `FIO` (`Full Name`) suggestions based on the current FIO input. + /// It performs a check to ensure the FIO input is not empty before fetching suggestions. + /// + /// - Throws: ``IIDadataError`` if an error occurs while fetching suggestions. + func getFioSuggestions() async throws(IIDadata.IIDadataError) + -> [FioSuggestion] /* where S == IIDadata.FioSuggestion */ + { + guard !text.isEmpty else { + suggestions = nil + throw IIDadataError.invalidInput + } + do { + let suggestions = try await DadataSuggestions.shared().suggestFio( + text, + count: 10, + gender: .male + ) + dump(suggestions, name: "FIO Suggestion for: \(text)") + return suggestions as [FioSuggestion] + } catch let error as IIDadataError { + self.error = "Error fetching FIO suggestions: \(error)" + throw error + } catch { + self.error = "Error fetching FIO suggestions: \(error)" + throw IIDadataError.unknown(error.localizedDescription) + } + } +} + +@available(iOS 15.0, *) +extension IIDadataSuggestsPopover { + /// Fetches suggestions based on the input text. + /// + /// This asynchronous method fetches address or FIO (Full name) suggestions + /// based on the type of the first element in the `suggestions` array. + /// If the text input is empty, it sets `suggestions` to `nil`. + /// + /// This function is called asynchronously and updates the `addressSuggestions` property. + /// It performs a check to ensure the address input is not empty before fetching suggestions. + /// - Throws: ``IIDadataError`` – an error object that indicates the type of error that occurred during the fetch process. + /// - Returns: An array of `AddressSuggestion` objects representing the fetched suggestions. + /// - SeeAlso: ``DadataSuggestions`` + func getAddressSuggestions(for text: String) async throws(IIDadata.IIDadataError) + -> [AddressSuggestion] { + guard !text.isEmpty else { + suggestions = nil + throw IIDadataError.invalidInput + } + do { + guard + let suggestions = try await DadataSuggestions.shared().suggestAddress( + text, + queryType: .address, + resultsCount: 10, + language: .ru + ).suggestions else { + throw IIDadataError.noSuggestions + } + + dump(suggestions, name: "Address Suggestion for: \(text)") + return suggestions as [AddressSuggestion] + + } catch let error as IIDadataError { + self.error = "Error fetching Address Suggestions: \(error)" + throw error + } catch { + self.error = "Error fetching Address Suggestions: \(error)" + throw IIDadataError.unknown(error.localizedDescription) + } + } +} + +// MARK: - Previews + +#if DEBUG + + // MARK: - ContentView + + /// A sample view demonstrating the usage of `IIDadataSuggestionsView`. + @available(iOS 15.0, *) + struct ContentView: View { + // Static Computed Properties + + static var addressMock: AddressSuggestion { + .init( + value: "г. Санкт-Петербург, улица Грибалёвой, 7 к4 с1, кв. \(Int.random(in: 1 ... 333))", + data: .init(), + unrestrictedValue: + "г. Санкт-Петербург, улица Грибалёвой, 7 к4 с1, кв. \(Int.random(in: 1 ... 333))" + ) + } + + // Properties + + @State var address = "Грибал" + @State var fio = "Михайл" + @State var addressSuggestions: [AddressSuggestion]? = [ + addressMock, addressMock, addressMock, addressMock, addressMock, addressMock, + ] + @State var fioSuggestions: [FioSuggestion]? = nil + @State var error: String? + + @StateObject private var dadata: DadataSuggestions + + @FocusState private var isAddressFocused: Bool + @FocusState private var isFIOFocused: Bool + + @State private var isAddressSuggestionsPresented = true + @State private var isFIOSuggestionsPresented = true + + // Lifecycle + + /// Initializes the `IIDadataViewModel` with the appropriate API key. + /// + /// The API key is fetched from the environment variables. + init() { + let apiKey = ProcessInfo.processInfo.environment["IIDadataAPIkey"] ?? "" + _dadata = StateObject(wrappedValue: try! DadataSuggestions.shared(apiKey: apiKey)) + } + + // Content + + var body: some View { + ScrollView { + VStack(spacing: 44) { + Spacer() + TextField( + "Enter address", + text: $address + ) + + .font(.body) + .textFieldStyle(.roundedBorder) + .tint(.blue).frame(height: 44) + .withDadataSuggestions( + isPresented: .constant(true), + dadata: dadata, + input: $address, + suggestions: $addressSuggestions, + textfieldHeight: 44, + onSuggestionSelected: { + address = $0.value + if let index = addressSuggestions?.firstIndex(where: { $0.value == address }) { + addressSuggestions?.remove(at: index) + } else { + addressSuggestions = nil + } + } + ) + Spacer() + TextField( + "Enter Full Name", + text: $fio + ) + .font(.body) + .textFieldStyle(.roundedBorder) + .tint(.blue).background(.white).frame(height: 56).clipped() + .withDadataSuggestions( + isPresented: .constant(true), + dadata: dadata, + input: $fio, + suggestions: $fioSuggestions, + textfieldHeight: 56, + onSuggestionSelected: { + fio = $0.value.appending(" ") + } + ) + + Spacer() + } + .padding() + } + .onAppear { + dump(dadata) + } + .background( + .conicGradient( + colors: [Color.pink, .accentColor, .teal, .purple, .brown, .mint, .indigo], + center: .center, angle: .degrees(.pi) + ) + ) + .ignoresSafeArea() + } + } + + @available(iOS 15.0, *) + struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } + } + +#endif diff --git a/Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift b/Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift new file mode 100644 index 0000000..ae0f2f7 --- /dev/null +++ b/Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift @@ -0,0 +1,162 @@ +// +// SuggestionsPopover.swift +// IIDadata +// +// Created by NSFuntik on 13.08.2024. +// + +import IIDadata +import SwiftUI + +// MARK: - IIDadataSuggestsPopover.SuggestionsPopover + +@available(iOS 15.0, *) +public extension IIDadataSuggestsPopover { + // MARK: - SuggestionsPopover + + /// A view that displays a list of suggestions. + /// + /// The `SuggestionsPopover` displays suggestions in a scrollable list and allows the user to select one. + /// + /// - Parameters: + /// - suggestions: An array of `Suggestion.Value` to be displayed. + /// - height: The height of `TextField`'s container for offseting the `popover`. + /// - inputText: `TextField's` input. + /// - onSelect: A closure that gets executed when a suggestion is selected. + /// - Returns: A `View` that displays a list of suggestions. + @available(iOS 15.0, *) + struct SuggestionsPopover: View { + // Properties + + var suggestions: [S] + let onSelect: (S) -> Void + let textfieldHeight: CGFloat + @Namespace var nsNamespace + let maxWidth: CGFloat = UIScreen.main.bounds.width - 44 + + @State private var cachedInput: String = "" + @State private var cachedResults: [AttributedString] = [] + private var inputText: String + + // Computed Properties + + /// The ideal height for the suggestions popover based on the number of suggestions. + var idealHeight: CGFloat { + let suggestions = self.suggestions.count + return textfieldHeight + (44.0 * CGFloat(suggestions > 1 ? suggestions : 1)) + } + + /// The maximum height for the suggestions popover. + var maxHeight: CGFloat { + idealHeight + 111 + } + + /// An array of highlighted suggestions based on the input text. + /// + /// This computed property caches the input text and its corresponding highlighted suggestions. + /// If the input text has not changed, it returns the cached results. Otherwise, it updates the cache and returns new results. + private var highlightedSuggestions: [AttributedString] { + if inputText == cachedInput { + return cachedResults + } + let newResults = suggestions.map { suggestion in + highlight(text: suggestion.value, match: inputText) + } + cachedInput = inputText + cachedResults = newResults + return newResults + } + + // Lifecycle + + /// Creates a new `SuggestionsPopover`. + /// + /// - Parameters: + /// - suggestions: An array of `Suggestion.Value` to be displayed. + /// - height: The height of `TextField`'s container for offseting the `popover`. + /// - inputText: `TextField's` input. + /// - onSelect: A closure that gets executed when a suggestion is selected. + init( + for inputText: String, + with suggestions: [S], + height textfieldHeight: CGFloat, + onSelect: @escaping (S) -> Void + ) { + self.inputText = inputText + self.suggestions = suggestions + self.onSelect = onSelect + self.textfieldHeight = textfieldHeight + UITableView.appearance().backgroundColor = .clear + UIScrollView.appearance().backgroundColor = .clear + } + + // Content + + public var body: some View { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(suggestions, id: \.self, content: SelectedSuggestionView(_:)) + } + .padding(8) + .listStyle(.inset) + } + .frame( + minWidth: 100, + idealWidth: UIScreen.main.bounds.width - 32, + maxWidth: UIScreen.main.bounds.width - 16, + minHeight: suggestions.endIndex > 1 ? 111 : 0, + idealHeight: idealHeight, + maxHeight: maxHeight, + alignment: .center + ).ignoresSafeArea(.all).edgesIgnoringSafeArea(.all) + .animation(.interactiveSpring, value: suggestions) + } + + @ViewBuilder + func SelectedSuggestionView(_ suggestion: S) -> some View { + VStack(alignment: .leading, spacing: 5) { + ZStack(alignment: .leading, content: { + Button(action: { + onSelect(suggestion) + }) { + Text(highlight(text: suggestion.value, match: inputText)) + .font( + .system(.callout, design: .rounded).weight(.light) + ) + .lineLimit(1).allowsTightening(true) + .truncationMode(.head) + .foregroundStyle(.foreground) + .frame(minWidth: 166, maxWidth: maxWidth, alignment: .leading) + } + .buttonStyle(.borderless) + }) + .accentColor(.accentColor) + Divider() + }.clipped(antialiased: true) + } + + // Functions + + /// Highlights the matching text within the provided text. + /// + /// This function takes the input text and highlights the portion that matches the provided match string. + /// The matching part of the text will have a different font weight. + /// + /// - Parameters: + /// - text: The text to be highlighted. + /// - match: The string to match and highlight within the text. + /// + /// - Returns: An `AttributedString` with the matched portion highlighted. + private func highlight(text: String, match: String) -> AttributedString { + var attributedString = AttributedString(text) + if let stringRange = text.range(of: match, options: .caseInsensitive) { + // Convert the String.Index range to an AttributedString.Index range + if let range = Range(stringRange, in: attributedString) { + attributedString[range].font = .callout.weight(.medium) + attributedString[range].foregroundColor = .primary + } + } + return attributedString + } + } +}