From edbcdca2f127b410396f216b317f68fc1adb723d Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Wed, 7 Aug 2024 15:40:04 +0300 Subject: [PATCH 01/16] SwiftFormat --- IIDadata/Sources/Constants.swift | 34 +- IIDadata/Sources/DadataQueryProtocol.swift | 6 +- IIDadata/Sources/DadataSuggestions.swift | 693 ++++++++--------- ...edDecodingContainer+decodeJSONNumber.swift | 28 +- .../Model/AddressSuggestionQuery.swift | 306 ++++---- .../Model/AddressSuggestionResponse.swift | 718 +++++++++--------- .../Sources/Model/ReverseGeocodeQuery.swift | 116 +-- 7 files changed, 990 insertions(+), 911 deletions(-) diff --git a/IIDadata/Sources/Constants.swift b/IIDadata/Sources/Constants.swift index 3a22ae7..6ad9779 100644 --- a/IIDadata/Sources/Constants.swift +++ b/IIDadata/Sources/Constants.swift @@ -7,24 +7,30 @@ 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" +// MARK: - Constants + +enum 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. +// MARK: - AddressQueryType + +/// API endpoints for different request types. public enum AddressQueryType: String { - case address = "suggest/address" - case fiasOnly = "suggest/fias" - case findByID = "findById/address" + case address = "suggest/address" + case fiasOnly = "suggest/fias" + case findByID = "findById/address" } -///Language of response. +// MARK: - QueryResultLanguage + +/// Language of response. public enum QueryResultLanguage: String, Encodable { - case ru = "ru" - case en = "en" + case ru + case en } diff --git a/IIDadata/Sources/DadataQueryProtocol.swift b/IIDadata/Sources/DadataQueryProtocol.swift index b77ebfb..04295c8 100644 --- a/IIDadata/Sources/DadataQueryProtocol.swift +++ b/IIDadata/Sources/DadataQueryProtocol.swift @@ -8,8 +8,6 @@ import Foundation protocol DadataQueryProtocol { - func queryEndpoint() -> String - func toJSON() throws -> Data + func queryEndpoint() -> String + func toJSON() throws -> Data } - - diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index ea11b8d..f1e18a6 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -1,350 +1,361 @@ import Foundation -///DadataSuggestions performs all the interactions with Dadata API. +/// 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) + // Static Properties + + private static var sharedInstance: DadataSuggestions? + + // Properties + + private let apiKey: String + 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 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 required convenience init(apiKey: String) { + self.init(apiKey: apiKey, url: Constants.suggestionsAPIURL) + } + + 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) } - - ///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) + 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) } - - ///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) + 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. + /// - 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 } - - ///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) + 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 checkAPIConnectivity(timeout: Int) throws { + var request = createRequest(url: suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) + request.timeoutInterval = TimeInterval(timeout) + + let semaphore = DispatchSemaphore(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 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() + } + + 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) + } + + 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 index 9b798db..558426e 100644 --- a/IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift +++ b/IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift @@ -8,19 +8,19 @@ 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 + /// 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 index fbdb97c..3b6e95c 100644 --- a/IIDadata/Sources/Model/AddressSuggestionQuery.swift +++ b/IIDadata/Sources/Model/AddressSuggestionQuery.swift @@ -7,151 +7,183 @@ 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) +// 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 } - - ///Returns an API endpoint for different request types: - ///`address` — "suggest/address" - ///`fiasOnly` — "suggest/fias" - ///`findByID` — "findById/address" - func queryEndpoint() -> String { return queryType.rawValue } + 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). +// 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 = "country" - case region = "region" - case area = "area" - case city = "city" - case settlement = "settlement" - case street = "street" - case house = "house" + case country + case region + case area + case city + case settlement + case street + case 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 - } +// MARK: - AddressQueryConstraint + +/// 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 { + // 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 + } } -///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 } +// 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 } } -///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 } +// 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/IIDadata/Sources/Model/AddressSuggestionResponse.swift b/IIDadata/Sources/Model/AddressSuggestionResponse.swift index 4e6da1c..2289051 100644 --- a/IIDadata/Sources/Model/AddressSuggestionResponse.swift +++ b/IIDadata/Sources/Model/AddressSuggestionResponse.swift @@ -4,360 +4,384 @@ import Foundation -///AddressSuggestionResponse represents a deserializable object used to hold API response. -public struct AddressSuggestionResponse : Decodable { - public let suggestions : [AddressSuggestions]? +// MARK: - AddressSuggestionResponse + +/// 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" - } +// MARK: - AddressSuggestions + +/// Every single suggestion is represented as AddressSuggestions. +public struct AddressSuggestions: Decodable { + // Nested Types + + enum CodingKeys: String, CodingKey { + case value + case data + case unrestrictedValue = "unrestricted_value" + } + + // Properties + + /// 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? } -///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) - } +// MARK: - AddressSuggestionData + +/// All the data returned in response to suggestion query. +public struct AddressSuggestionData: 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(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) + } } +// 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{ - 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) - } +public struct Metro: Decodable { + // 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/IIDadata/Sources/Model/ReverseGeocodeQuery.swift b/IIDadata/Sources/Model/ReverseGeocodeQuery.swift index 636e6d1..55b921d 100644 --- a/IIDadata/Sources/Model/ReverseGeocodeQuery.swift +++ b/IIDadata/Sources/Model/ReverseGeocodeQuery.swift @@ -7,59 +7,67 @@ 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" +/// 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) 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 + 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 } } From 8e357615c5eb5267e4c7b3b3abce192d7f0f9e26 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Wed, 7 Aug 2024 16:00:58 +0300 Subject: [PATCH 02/16] async/await --- IIDadata/Sources/DadataSuggestions.swift | 208 ++++++++---------- .../Model/AddressSuggestionQuery.swift | 2 +- .../Sources/Model/ReverseGeocodeQuery.swift | 4 +- Package.swift | 5 +- 4 files changed, 96 insertions(+), 123 deletions(-) diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index f1e18a6..1b8c70c 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -1,6 +1,5 @@ import Foundation -/// DadataSuggestions performs all the interactions with Dadata API. public class DadataSuggestions { // Static Properties @@ -41,7 +40,7 @@ public class DadataSuggestions { /// May throw if request is timed out. public convenience init(apiKey: String, checkWithTimeout timeout: Int) throws { self.init(apiKey: apiKey) - try checkAPIConnectivity(timeout: timeout) + Task { try await checkAPIConnectivity(timeout: timeout) } } /// New instance of DadataSuggestions. @@ -67,7 +66,10 @@ public class DadataSuggestions { 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! } + if let key = apiKey { + sharedInstance = DadataSuggestions(apiKey: key) + return sharedInstance! + } let key = try readAPIKeyFromPlist() sharedInstance = DadataSuggestions(apiKey: key) @@ -90,10 +92,9 @@ public class DadataSuggestions { /// 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) + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func suggestAddress(_ query: String) async throws -> AddressSuggestionResponse { + try await suggestAddress(AddressSuggestionQuery(query)) } /// Address suggestions request. @@ -113,19 +114,18 @@ public class DadataSuggestions { /// - 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) - { + /// - Returns:``AddressSuggestionResponse`` - 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 + ) async throws -> AddressSuggestionResponse { let suggestionQuery = AddressSuggestionQuery(query, ofType: queryType) suggestionQuery.resultsCount = resultsCount @@ -133,10 +133,10 @@ public class DadataSuggestions { suggestionQuery.constraints = constraints suggestionQuery.regionPriority = regionPriority suggestionQuery.upperScaleLimit = upperScaleLimit != nil ? ScaleBound(value: upperScaleLimit) : nil - suggestionQuery.lowerScaleLimit = upperScaleLimit != nil ? ScaleBound(value: lowerScaleLimit) : nil + suggestionQuery.lowerScaleLimit = lowerScaleLimit != nil ? ScaleBound(value: lowerScaleLimit) : nil suggestionQuery.trimRegionResult = trimRegionResult - suggestAddress(suggestionQuery, completion: completion) + return try await suggestAddress(suggestionQuery) } /// Address suggestions request. @@ -166,64 +166,60 @@ public class DadataSuggestions { /// `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 { + /// - 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 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) + 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. - /// - 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) + /// - 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. - /// - 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) + /// - 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. - /// - 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) + /// - 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. @@ -236,21 +232,19 @@ public class DadataSuggestions { /// 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. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. public func reverseGeocode(query: String, - delimeter: Character = ",", + delimiter: Character = ",", resultsCount: Int? = 10, language: String? = "ru", - searchRadius: Int? = nil, - completion: @escaping (_ result: Result) -> Void) throws + searchRadius: Int? = nil) async throws -> AddressSuggestionResponse { - let geoquery = try ReverseGeocodeQuery(query: query, delimeter: delimeter) + let geoquery = try ReverseGeocodeQuery(query: query, delimeter: delimiter) geoquery.resultsCount = resultsCount geoquery.language = QueryResultLanguage(rawValue: language ?? "ru") geoquery.searchRadius = searchRadius - reverseGeocode(geoquery, completion: completion) + return try await reverseGeocode(geoquery) } /// Reverse Geocode request with latitude and longitude as a single string. @@ -261,53 +255,39 @@ public class DadataSuggestions { /// 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. + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. public func reverseGeocode(latitude: Double, longitude: Double, resultsCount: Int? = 10, language: QueryResultLanguage? = nil, - searchRadius: Int? = nil, - completion: @escaping (_ result: Result) -> Void) + searchRadius: Int? = nil) async throws -> AddressSuggestionResponse { let geoquery = ReverseGeocodeQuery(latitude: latitude, longitude: longitude) geoquery.resultsCount = resultsCount geoquery.language = language geoquery.searchRadius = searchRadius - fetchResponse(withQuery: geoquery, completionHandler: completion) + return try await fetchResponse(withQuery: geoquery) } /// 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) + /// - Returns:``AddressSuggestionResponse`` - result of address suggestion query. + public func reverseGeocode(_ query: ReverseGeocodeQuery) async throws -> AddressSuggestionResponse { + try await fetchResponse(withQuery: query) } - private func checkAPIConnectivity(timeout: Int) throws { + private func checkAPIConnectivity(timeout: Int) async throws { var request = createRequest(url: suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) request.timeoutInterval = TimeInterval(timeout) + let (data, response) = try await URLSession.shared.data(for: request) - let semaphore = DispatchSemaphore(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 + guard let httpResponse = response as? HTTPURLResponse, + (200 ... 299).contains(httpResponse.statusCode) + else { + throw nonOKResponseToError(response: (response as? HTTPURLResponse) ?? .init(), body: data) +// NSError(domain: "HTTP Error", code: (response as? HTTPURLResponse)?.statusCode ?? 0, userInfo: nil) } } @@ -330,32 +310,20 @@ public class DadataSuggestions { return NSError(domain: "HTTP Status \(HTTPURLResponse.localizedString(forStatusCode: code))", code: code, userInfo: info) } - private func fetchResponse(withQuery query: DadataQueryProtocol, completionHandler completion: @escaping (Result) -> Void) where T: Decodable { + private func fetchResponse(withQuery query: DadataQueryProtocol) async throws -> T { 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 + request.httpBody = try query.toJSON() - 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() + 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/IIDadata/Sources/Model/AddressSuggestionQuery.swift b/IIDadata/Sources/Model/AddressSuggestionQuery.swift index 3b6e95c..dc6f7b0 100644 --- a/IIDadata/Sources/Model/AddressSuggestionQuery.swift +++ b/IIDadata/Sources/Model/AddressSuggestionQuery.swift @@ -9,7 +9,7 @@ import Foundation // MARK: - AddressSuggestionQuery -/// AddressSuggestionQuery represents an serializable object used to perform certain queries. +/// `AddressSuggestionQuery` represents an serializable object used to perform certain queries. public class AddressSuggestionQuery: Encodable, DadataQueryProtocol { // Nested Types diff --git a/IIDadata/Sources/Model/ReverseGeocodeQuery.swift b/IIDadata/Sources/Model/ReverseGeocodeQuery.swift index 55b921d..ab55bdb 100644 --- a/IIDadata/Sources/Model/ReverseGeocodeQuery.swift +++ b/IIDadata/Sources/Model/ReverseGeocodeQuery.swift @@ -7,6 +7,8 @@ import Foundation +// MARK: - ReverseGeocodeQuery + /// ReverseGeocodeQuery represents an serializable object used to perform reverse geocode queries. public class ReverseGeocodeQuery: Encodable, DadataQueryProtocol { // Nested Types @@ -46,7 +48,7 @@ public class ReverseGeocodeQuery: Encodable, DadataQueryProtocol { else { throw NSError(domain: "Dadata ReverseGeocodeQuery", code: -1, - userInfo: ["description": "Failed to parse coordinates from \(query) uding delimeter \(delimeter)"]) + userInfo: ["description": "Failed to parse coordinates from \(query) using delimiter \(delimeter)"]) } self.init(latitude: latitude, longitude: longitude) diff --git a/Package.swift b/Package.swift index 9def5ab..66071ef 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,13 @@ -// 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", + 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( From d5ffec4592007c2d886b88fa55467b098d604fcd Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Wed, 7 Aug 2024 17:59:23 +0300 Subject: [PATCH 03/16] Addid FIO suggections --- IIDadata/Sources/DadataSuggestions.swift | 17 ++ .../Model/AddressSuggestionQuery.swift | 1 + .../Sources/Model/FioSuggestionQuery.swift | 237 ++++++++++++++++++ 3 files changed, 255 insertions(+) create mode 100644 IIDadata/Sources/Model/FioSuggestionQuery.swift diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index 1b8c70c..3993371 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -89,6 +89,23 @@ public class DadataSuggestions { // Functions + /// 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. + /// + /// - Parameters: + /// - query: A string containing the name or partial name for which suggestions are required. + /// - count: The maximum number of suggestions to return. Defaults to 10 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) async throws -> [FioSuggestion] { + let suggestionQuery = FioSuggestionQuery(query, count: count, parts: nil, gender: nil) + return try await fetchResponse(withQuery: suggestionQuery) + } + /// 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. diff --git a/IIDadata/Sources/Model/AddressSuggestionQuery.swift b/IIDadata/Sources/Model/AddressSuggestionQuery.swift index dc6f7b0..eb9cc64 100644 --- a/IIDadata/Sources/Model/AddressSuggestionQuery.swift +++ b/IIDadata/Sources/Model/AddressSuggestionQuery.swift @@ -187,3 +187,4 @@ public struct ScaleBound: Encodable { public init(value: ScaleLevel?) { self.value = value } } + diff --git a/IIDadata/Sources/Model/FioSuggestionQuery.swift b/IIDadata/Sources/Model/FioSuggestionQuery.swift new file mode 100644 index 0000000..13e6fe3 --- /dev/null +++ b/IIDadata/Sources/Model/FioSuggestionQuery.swift @@ -0,0 +1,237 @@ +// +// FioSuggestionQuery.swift +// IIDadata +// +// Created by NSFuntik on 07.08.2024. +// + +// MARK: - FIO Suggestion Query and Response Structures + +import Foundation + +// MARK: - FioSuggestionQuery + +/// A structure representing a query for suggesting FIO (Full Name) data. +public struct FioSuggestionQuery: Codable, DadataQueryProtocol { + // Nested Types + + 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: Int? + + /// Indicates if the name parts should be returned separately. Default is `false`. + let parts: Bool? + + /// Requested gender for the suggested names. + let gender: String? + + // 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: Bool? = false, gender: String? = nil) { + self.query = query + self.count = count + self.parts = parts + self.gender = gender + } + + // Functions + + /// 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 { + return try JSONEncoder().encode(self) + } +} + +// 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 + } +} + +// MARK: - FioSuggestion + +/// A structure representing a single FIO suggestion. +public struct FioSuggestion: Codable { + // Nested Types + + enum CodingKeys: String, CodingKey { + case value + case unrestrictedValue = "unrestricted_value" + case data + } + + // Properties + + /// The suggested FIO value. + let value: String + + /// The unrestricted suggested FIO value. + let unrestrictedValue: String + + /// Detailed FIO data. + let data: FioData + + // 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 + } +} + +// MARK: - FioData + +/// A structure representing detailed FIO data. +public struct FioData: Codable { + // Nested Types + + enum CodingKeys: String, CodingKey { + case surname, name, patronymic, gender, qc + } + + // Properties + + /// The surname (last name). + let surname: String? + + /// The given name (first name). + let name: String? + + /// The patronymic (middle name). + let patronymic: String? + + /// The gender of the individual. + let gender: String? + + /// The quality code. + 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: String?, + qc: String? + ) { + self.surname = surname + self.name = name + self.patronymic = patronymic + self.gender = gender + self.qc = qc + } +} From 19b2d72fecda983a0a8a05b0eeb1c7f1af74cd3f Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Thu, 8 Aug 2024 05:46:27 +0300 Subject: [PATCH 04/16] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20iO?= =?UTF-8?q?S:=201.=20=D0=92=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B5=20"=D0=90?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=BE=D0=B3=D0=B8"=20=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=82=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B0=20=D0=B4=D0=BE=D0=BB=D0=B6=D0=B5=D0=BD=20=D0=B1?= =?UTF-8?q?=D1=8B=D1=82=D1=8C=20=D0=B7=D0=B5=D0=BB=D0=B5=D0=BD=D1=8B=D0=B9?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20(=D0=BF=D0=BE=D1=81=D0=BC?= =?UTF-8?q?=D0=BE=D1=82=D1=80=D0=B5=D1=82=D1=8C=20=D0=B2=20=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=D0=BC=D0=B5);=202.=20=D0=92=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=20"=D0=92=D1=8B=20=D0=BD=D0=B5=D0=B4=D0=B0=D0=B2=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=D0=BB=D0=B8"=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=B0=20=D0=B4=D0=BE=D0=BB=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B1=D1=8B=D1=82=D1=8C=20=D0=B7=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=D0=B9=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=20(?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=D1=82=D1=8C=20?= =?UTF-8?q?=D0=B2=20=D1=84=D0=B8=D0=B3=D0=BC=D0=B5);=203.=20=D0=A1=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B0=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D0=BD=D0=BE=20=D1=84=D0=B8=D0=B3=D0=BC=D0=B5;=204.=20=D0=90?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=D0=B0=20=D0=B0=D0=BF=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=BF=D0=BE=20=D1=84=D0=B8=D0=B3=D0=BC=D0=B5?= =?UTF-8?q?.=20=D0=92=D0=BE=D0=BF=D1=80=D0=BE=D1=81=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D1=80=D1=83=D0=B4?= =?UTF-8?q?=D0=BE=D0=B5=D0=BC=D0=BA=D0=BE=20=D1=81=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BF=D0=BE=20=D1=84=D0=B8=D0=B3=D0=BC=D0=B5=20?= =?UTF-8?q?(=D0=B2=20=D0=B0=D0=BD=D0=B4=D1=80=D0=BE=D0=B8=D0=B4=D0=B5=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BA=D0=B0=D0=BA=20=D0=BD=D0=B0=D0=B4=D0=BE);=205.?= =?UTF-8?q?=20=D0=92=20=D0=BC=D0=BE=D0=B8=D1=85=20=D0=B7=D0=B0=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=D1=85=20=D0=BF=D1=80=D0=B8=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=85=D0=BE=D0=B4=D0=B5=20=D0=B2=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D1=82=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=20=D0=B2=D0=BD=D0=B8=D0=B7=D1=83=20=D0=B3=D0=B4?= =?UTF-8?q?=D0=B5=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B0=D1=8E?= =?UTF-8?q?=D1=82=D1=81=D1=8F=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D1=8B=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=82=20=D0=B8=D0=BD=D1=84=D1=8B=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB-=D0=B2=D1=83=20(=D1=81=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D1=88=D1=82=D1=83=D0=BA)=20=D0=B8=20?= =?UTF-8?q?=D0=B5=D1=89=D0=B5=20=D0=B4=D0=BE=D0=BF=20=D0=B8=D0=BD=D1=84?= =?UTF-8?q?=D1=8B;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..3cd049d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.quickSuggestions": false +} \ No newline at end of file From 51690b32229a518ddf72edaf0a2dd6d8a8f28ee7 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Thu, 8 Aug 2024 22:56:21 +0300 Subject: [PATCH 05/16] tests && fixes --- .swiftpm/IIDadata.xctestplan | 24 ++ .swiftpm/IIDadataTests.xctestplan | 24 ++ .../xcshareddata/xcschemes/IIDadata.xcscheme | 84 +++++ .../xcschemes/IIDadataTests.xcscheme | 59 ++++ Example/IIDadata/Info.plist | 2 + Example/Tests/Tests.swift | 333 ++++++++++-------- IIDadata/Sources/Constants.swift | 5 +- IIDadata/Sources/DadataSuggestions.swift | 121 ++++--- .../Sources/Model/FioSuggestionQuery.swift | 267 +++++++++++++- .../Tests/IIDadataTests/IIDadataTests.swift | 113 +++++- 10 files changed, 805 insertions(+), 227 deletions(-) create mode 100644 .swiftpm/IIDadata.xctestplan create mode 100644 .swiftpm/IIDadataTests.xctestplan create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme diff --git a/.swiftpm/IIDadata.xctestplan b/.swiftpm/IIDadata.xctestplan new file mode 100644 index 0000000..b360a77 --- /dev/null +++ b/.swiftpm/IIDadata.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "07C551C4-843E-4221-BA48-F07C2A0A0F21", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "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/IIDadata.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme new file mode 100644 index 0000000..7ccab5f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme new file mode 100644 index 0000000..f4b4255 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/IIDadata/Info.plist b/Example/IIDadata/Info.plist index 6c48029..ca41b81 100644 --- a/Example/IIDadata/Info.plist +++ b/Example/IIDadata/Info.plist @@ -26,6 +26,8 @@ LaunchScreen UIMainStoryboardFile Main + NSAppTransportSecurity + UIRequiredDeviceCapabilities armv7 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/Sources/Constants.swift b/IIDadata/Sources/Constants.swift index 6ad9779..b87df43 100644 --- a/IIDadata/Sources/Constants.swift +++ b/IIDadata/Sources/Constants.swift @@ -10,7 +10,10 @@ import Foundation // MARK: - Constants enum Constants { - static let suggestionsAPIURL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/" + 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 fioEndpoint = "suggest/fio" static let addressEndpoint = AddressQueryType.address.rawValue static let addressFIASOnlyEndpoint = AddressQueryType.fiasOnly.rawValue static let addressByIDEndpoint = AddressQueryType.findByID.rawValue diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index 3993371..4f58fa7 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -30,7 +30,7 @@ public class DadataSuggestions { /// 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) + /// let dadata = try DadataSuggestions(apiKey: " ", checkWithTimeout: 15) /// } /// ``` /// - Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. @@ -38,9 +38,9 @@ public class DadataSuggestions { /// /// - 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) - Task { try await checkAPIConnectivity(timeout: timeout) } + public convenience init(api: String /* , checkWithTimeout timeout: Int */ ) throws { + self.init(apiKey: api) + Task { try await checkAPIConnectivity() } } /// New instance of DadataSuggestions. @@ -89,23 +89,6 @@ public class DadataSuggestions { // Functions - /// 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. - /// - /// - Parameters: - /// - query: A string containing the name or partial name for which suggestions are required. - /// - count: The maximum number of suggestions to return. Defaults to 10 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) async throws -> [FioSuggestion] { - let suggestionQuery = FioSuggestionQuery(query, count: count, parts: nil, gender: nil) - return try await fetchResponse(withQuery: suggestionQuery) - } - /// 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. @@ -204,15 +187,17 @@ public class DadataSuggestions { } 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) + 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. @@ -250,12 +235,13 @@ public class DadataSuggestions { /// - 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 - { + 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") @@ -273,12 +259,13 @@ public class DadataSuggestions { /// - 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 - { + 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 @@ -295,9 +282,51 @@ public class DadataSuggestions { try await fetchResponse(withQuery: query) } - private func checkAPIConnectivity(timeout: Int) async throws { - var request = createRequest(url: suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) - request.timeoutInterval = TimeInterval(timeout) + /// 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. + /// + /// - Parameters: + /// - query: A string containing the name or partial name for which suggestions are required. + /// - count: The maximum number of suggestions to return. Defaults to 10 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. + func suggestFio( + _ query: String, + count: Int = 10, + gender: Gender? = nil, + parts: [FioSuggestionQuery.Part]? = nil + ) async throws -> [FioSuggestion] { + var request = URLRequest(url: URL(string: Constants.fioSuggestionsAPIURL)!) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: "Accept") + request.addValue("Token " + apiKey, forHTTPHeaderField: "Authorization") + + + + let fioSuggestionQuery = FioSuggestionQuery(query, count: count, parts: parts, gender: gender) + + dump(String(data: try JSONEncoder().encode(fioSuggestionQuery), encoding: .utf8) ?? "Unable to decode request body", name: "fioSuggestionQuery") + let jsonData = try JSONEncoder().encode(fioSuggestionQuery) + request.httpBody = jsonData + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { + throw NSError(domain: "HTTP Error", code: (response as? HTTPURLResponse)?.statusCode ?? 0, userInfo: nil) + } + + let fioSuggestions = try JSONDecoder().decode(FioSuggestionResponse.self, from: data).suggestions + return fioSuggestions + } + + func checkAPIConnectivity( /* timeout: Int */ ) async throws { + let request = createRequest(url: suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) +// request.timeoutInterval = TimeInterval(timeout) let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, @@ -330,7 +359,7 @@ public class DadataSuggestions { private func fetchResponse(withQuery query: DadataQueryProtocol) async throws -> T { var request = createRequest(url: suggestionsAPIURL.appendingPathComponent(query.queryEndpoint())) request.httpBody = try query.toJSON() - + dump(request.httpBody, name: "Request \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "Unable to decode request body")") let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { diff --git a/IIDadata/Sources/Model/FioSuggestionQuery.swift b/IIDadata/Sources/Model/FioSuggestionQuery.swift index 13e6fe3..b059f3f 100644 --- a/IIDadata/Sources/Model/FioSuggestionQuery.swift +++ b/IIDadata/Sources/Model/FioSuggestionQuery.swift @@ -11,10 +11,75 @@ import Foundation // MARK: - FioSuggestionQuery -/// A structure representing a query for suggesting FIO (Full Name) data. +/** Подсказки по ФИО (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 } @@ -25,13 +90,13 @@ public struct FioSuggestionQuery: Codable, DadataQueryProtocol { let query: String /// The number of suggestions to return (default is 10). - let count: Int? + let count: IntegerLiteralType? /// Indicates if the name parts should be returned separately. Default is `false`. - let parts: Bool? + let parts: [Part]? /// Requested gender for the suggested names. - let gender: String? + let gender: Gender? // Lifecycle @@ -42,7 +107,7 @@ public struct FioSuggestionQuery: Codable, DadataQueryProtocol { /// - 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: Bool? = false, gender: String? = nil) { + public init(_ query: String, count: Int? = 10, parts: [Part]? = nil, gender: Gender? = nil) { self.query = query self.count = count self.parts = parts @@ -51,11 +116,21 @@ public struct FioSuggestionQuery: Codable, DadataQueryProtocol { // 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" + return "suggest/fio" } /// Encodes the query into a JSON data representation. @@ -63,7 +138,7 @@ public struct FioSuggestionQuery: Codable, DadataQueryProtocol { /// - Returns: A `Data` object containing the JSON representation of the query. /// - Throws: An error if the encoding fails. func toJSON() throws -> Data { - return try JSONEncoder().encode(self) + try JSONEncoder().encode(self) } } @@ -124,7 +199,7 @@ public struct FioSuggestionResponse: Codable { /// /// - Returns: The endpoint as a string. func queryEndpoint() -> String { - return "/suggest/fio" + return "suggest/fio" } /// Returns the type of the query. @@ -151,20 +226,20 @@ public struct FioSuggestion: Codable { enum CodingKeys: String, CodingKey { case value case unrestrictedValue = "unrestricted_value" - case data + case fio = "data" } // Properties + /// Detailed FIO data. + public let fio: FioData + /// The suggested FIO value. let value: String /// The unrestricted suggested FIO value. let unrestrictedValue: String - /// Detailed FIO data. - let data: FioData - // Lifecycle /// Initializes a new FioSuggestion. @@ -180,18 +255,121 @@ public struct FioSuggestion: Codable { ) { self.value = value self.unrestrictedValue = unrestrictedValue - self.data = data + fio = data + } + + // Functions + + public subscript(_ key: FioData.CodingKeys) -> String? { + return fio[key] + } +} + +// 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 { + /// 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 + } } } // MARK: - FioData /// A structure representing detailed FIO data. -public struct FioData: Codable { +/// +/// - Parameters: +/// - value` : ФИОодной строкой +/// - unrestricted_value` : value +/// - surname: Фамилия +/// - name: Имя +/// - patronymic: Отчество +/// - gender: Пол +/// - qc: Код качества +/// - source: Не заполняется +public struct FioData: Codable, Equatable, Hashable { // Nested Types - enum CodingKeys: String, CodingKey { + 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 @@ -206,9 +384,13 @@ public struct FioData: Codable { let patronymic: String? /// The gender of the individual. - let gender: String? + let gender: Gender? /// The quality code. + /// + /// - `0`: если все части ФИО найдены в справочниках. + /// - `1`: если в ФИО есть часть не из справочника + /// - `null`: если нет части ФИО в справочниках. let qc: String? // Lifecycle @@ -225,7 +407,7 @@ public struct FioData: Codable { surname: String?, name: String?, patronymic: String?, - gender: String?, + gender: Gender?, qc: String? ) { self.surname = surname @@ -234,4 +416,55 @@ public struct FioData: Codable { 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/IIDadata/Tests/IIDadataTests/IIDadataTests.swift b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift index 1f3c6c4..ec20647 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 DadataSuggestionsTests: XCTestCase { + // Properties + +// let apiKey = "abadf779d0525bebb9e16b72a97eabf4f7143292" + + // Computed Properties + + private var apiToken: String { + // Replace with your actual Dadata API token + return "abadf779d0525bebb9e16b72a97eabf4f7143292" + } + + // 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, "Москва") + } -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!") + 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)) + } + } } From 19a1e649925fd2b5e937cc8eecdc54e7cf1b775a Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Fri, 9 Aug 2024 02:51:35 +0300 Subject: [PATCH 06/16] Suggestion Protocol implemented DocC added Test expanded --- .swiftpm/IIDadata.xctestplan | 12 +- .../xcschemes/IIDadata 1.xcscheme | 76 ++++ .../xcshareddata/xcschemes/IIDadata.xcscheme | 16 + IIDadata/Sources/DadataSuggestions.swift | 39 +- .../Sources/Model/AddressSuggestion.swift | 361 ++++++++++++++++++ .../Model/AddressSuggestionResponse.swift | 348 +---------------- IIDadata/Sources/Model/FioData.swift | 136 +++++++ IIDadata/Sources/Model/FioSuggestion.swift | 66 ++++ .../Sources/Model/FioSuggestionQuery.swift | 334 +--------------- .../Sources/Model/FioSuggestionResponse.swift | 83 ++++ IIDadata/Sources/Model/Gender.swift | 85 +++++ .../Sources/Model/SuggestionProtocol.swift | 28 ++ .../Tests/IIDadataTests/IIDadataTests.swift | 4 +- 13 files changed, 885 insertions(+), 703 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme create mode 100644 IIDadata/Sources/Model/AddressSuggestion.swift create mode 100644 IIDadata/Sources/Model/FioData.swift create mode 100644 IIDadata/Sources/Model/FioSuggestion.swift create mode 100644 IIDadata/Sources/Model/FioSuggestionResponse.swift create mode 100644 IIDadata/Sources/Model/Gender.swift create mode 100644 IIDadata/Sources/Model/SuggestionProtocol.swift diff --git a/.swiftpm/IIDadata.xctestplan b/.swiftpm/IIDadata.xctestplan index b360a77..0a6019a 100644 --- a/.swiftpm/IIDadata.xctestplan +++ b/.swiftpm/IIDadata.xctestplan @@ -9,7 +9,17 @@ } ], "defaultOptions" : { - + "environmentVariableEntries" : [ + { + "key" : "IIDadataAPIToken", + "value" : "" + } + ], + "targetForVariableExpansion" : { + "containerPath" : "container:", + "identifier" : "IIDadataTests", + "name" : "IIDadataTests" + } }, "testTargets" : [ { diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme new file mode 100644 index 0000000..7f7028e --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme index 7ccab5f..f26ca1e 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme @@ -57,6 +57,22 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + + + + + [FioSuggestion] { - var request = URLRequest(url: URL(string: Constants.fioSuggestionsAPIURL)!) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.addValue("application/json", forHTTPHeaderField: "Accept") - request.addValue("Token " + apiKey, forHTTPHeaderField: "Authorization") - - - let fioSuggestionQuery = FioSuggestionQuery(query, count: count, parts: parts, gender: gender) - dump(String(data: try JSONEncoder().encode(fioSuggestionQuery), encoding: .utf8) ?? "Unable to decode request body", name: "fioSuggestionQuery") - let jsonData = try JSONEncoder().encode(fioSuggestionQuery) - request.httpBody = jsonData + let fioSuggestionQuery = FioSuggestionQuery(query, count: count, parts: parts, gender: gender) - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, (200 ... 299).contains(httpResponse.statusCode) else { - throw NSError(domain: "HTTP Error", code: (response as? HTTPURLResponse)?.statusCode ?? 0, userInfo: nil) - } - - let fioSuggestions = try JSONDecoder().decode(FioSuggestionResponse.self, from: data).suggestions - return fioSuggestions + let fioSuggestionResponse: FioSuggestionResponse = try await fetchResponse(withQuery: fioSuggestionQuery) + return fioSuggestionResponse.suggestions } func checkAPIConnectivity( /* timeout: Int */ ) async throws { @@ -340,8 +326,11 @@ public class DadataSuggestions { 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("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 } @@ -359,7 +348,7 @@ public class DadataSuggestions { private func fetchResponse(withQuery query: DadataQueryProtocol) async throws -> T { var request = createRequest(url: suggestionsAPIURL.appendingPathComponent(query.queryEndpoint())) request.httpBody = try query.toJSON() - dump(request.httpBody, name: "Request \(String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "Unable to decode request body")") + 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 { diff --git a/IIDadata/Sources/Model/AddressSuggestion.swift b/IIDadata/Sources/Model/AddressSuggestion.swift new file mode 100644 index 0000000..8dab94e --- /dev/null +++ b/IIDadata/Sources/Model/AddressSuggestion.swift @@ -0,0 +1,361 @@ +// +// AddressSuggestionData.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +// MARK: - AddressSuggestion + +/// Every single suggestion is represented as AddressSuggestions. +public struct AddressSuggestion: Suggestion { + /// 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(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/IIDadata/Sources/Model/AddressSuggestionResponse.swift b/IIDadata/Sources/Model/AddressSuggestionResponse.swift index 2289051..665c7a1 100644 --- a/IIDadata/Sources/Model/AddressSuggestionResponse.swift +++ b/IIDadata/Sources/Model/AddressSuggestionResponse.swift @@ -8,353 +8,7 @@ import Foundation /// AddressSuggestionResponse represents a deserializable object used to hold API response. public struct AddressSuggestionResponse: Decodable { - public let suggestions: [AddressSuggestions]? -} - -// MARK: - AddressSuggestions - -/// Every single suggestion is represented as AddressSuggestions. -public struct AddressSuggestions: Decodable { - // Nested Types - - enum CodingKeys: String, CodingKey { - case value - case data - case unrestrictedValue = "unrestricted_value" - } - - // Properties - - /// 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? -} - -// MARK: - AddressSuggestionData - -/// All the data returned in response to suggestion query. -public struct AddressSuggestionData: 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(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) - } + public let suggestions: [AddressSuggestion]? } // MARK: - Metro diff --git a/IIDadata/Sources/Model/FioData.swift b/IIDadata/Sources/Model/FioData.swift new file mode 100644 index 0000000..242cf53 --- /dev/null +++ b/IIDadata/Sources/Model/FioData.swift @@ -0,0 +1,136 @@ +// +// FioData.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +public extension FioSuggestion { + // MARK: - FioData + + /// A structure representing detailed FIO data. + /// + /// - Parameters: + /// - value` : ФИОодной строкой + /// - unrestricted_value` : value + /// - 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?, + qc: String? + ) { + 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/IIDadata/Sources/Model/FioSuggestion.swift b/IIDadata/Sources/Model/FioSuggestion.swift new file mode 100644 index 0000000..f7b91aa --- /dev/null +++ b/IIDadata/Sources/Model/FioSuggestion.swift @@ -0,0 +1,66 @@ +// +// FioSuggestion.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +// MARK: - FioSuggestion + +/// A structure representing a single FIO suggestion. +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. + 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.decodeIfPresent(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/IIDadata/Sources/Model/FioSuggestionQuery.swift b/IIDadata/Sources/Model/FioSuggestionQuery.swift index b059f3f..03f0e87 100644 --- a/IIDadata/Sources/Model/FioSuggestionQuery.swift +++ b/IIDadata/Sources/Model/FioSuggestionQuery.swift @@ -107,7 +107,12 @@ public struct FioSuggestionQuery: Codable, DadataQueryProtocol { /// - 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) { + public init( + _ query: String, + count: Int? = 10, + parts: [Part]? = nil, + gender: Gender? = nil + ) { self.query = query self.count = count self.parts = parts @@ -141,330 +146,3 @@ public struct FioSuggestionQuery: Codable, DadataQueryProtocol { try JSONEncoder().encode(self) } } - -// 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 - } -} - -// MARK: - FioSuggestion - -/// A structure representing a single FIO suggestion. -public struct FioSuggestion: Codable { - // Nested Types - - enum CodingKeys: String, CodingKey { - case value - case unrestrictedValue = "unrestricted_value" - case fio = "data" - } - - // Properties - - /// Detailed FIO data. - public let fio: FioData - - /// The suggested FIO value. - let value: String - - /// The unrestricted suggested FIO value. - 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 - fio = data - } - - // Functions - - public subscript(_ key: FioData.CodingKeys) -> String? { - return fio[key] - } -} - -// 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 { - /// 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 - } - } -} - -// MARK: - FioData - -/// A structure representing detailed FIO data. -/// -/// - Parameters: -/// - value` : ФИОодной строкой -/// - unrestricted_value` : value -/// - surname: Фамилия -/// - name: Имя -/// - patronymic: Отчество -/// - gender: Пол -/// - qc: Код качества -/// - source: Не заполняется -public 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). - let surname: String? - - /// The given name (first name). - let name: String? - - /// The patronymic (middle name). - let patronymic: String? - - /// The gender of the individual. - let gender: Gender? - - /// The quality code. - /// - /// - `0`: если все части ФИО найдены в справочниках. - /// - `1`: если в ФИО есть часть не из справочника - /// - `null`: если нет части ФИО в справочниках. - 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?, - qc: String? - ) { - 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/IIDadata/Sources/Model/FioSuggestionResponse.swift b/IIDadata/Sources/Model/FioSuggestionResponse.swift new file mode 100644 index 0000000..40fe198 --- /dev/null +++ b/IIDadata/Sources/Model/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/IIDadata/Sources/Model/Gender.swift b/IIDadata/Sources/Model/Gender.swift new file mode 100644 index 0000000..9ec8c38 --- /dev/null +++ b/IIDadata/Sources/Model/Gender.swift @@ -0,0 +1,85 @@ +// +// has.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + + +// 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/IIDadata/Sources/Model/SuggestionProtocol.swift b/IIDadata/Sources/Model/SuggestionProtocol.swift new file mode 100644 index 0000000..291de95 --- /dev/null +++ b/IIDadata/Sources/Model/SuggestionProtocol.swift @@ -0,0 +1,28 @@ +// +// Suggestion.swift +// IIDadata +// +// Created by NSFuntik on 09.08.2024. +// + +// 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 { + associatedtype SuggestionData: Decodable + var unrestrictedValue: String? { get } + var value: String? { get } + var data: SuggestionData? { get } +} diff --git a/IIDadata/Tests/IIDadataTests/IIDadataTests.swift b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift index ec20647..1770853 100644 --- a/IIDadata/Tests/IIDadataTests/IIDadataTests.swift +++ b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift @@ -8,9 +8,9 @@ final class DadataSuggestionsTests: XCTestCase { // Computed Properties + // Environment Variable is read from Scheme Configuration private var apiToken: String { - // Replace with your actual Dadata API token - return "abadf779d0525bebb9e16b72a97eabf4f7143292" + return ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" } // Functions From 1aeb7ebdee23ad2b190d093e88e328eef253da23 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Fri, 9 Aug 2024 08:41:10 +0300 Subject: [PATCH 07/16] Fix --- IIDadata/Sources/DadataSuggestions.swift | 1 + .../Sources/Model/AddressSuggestion.swift | 8 +++-- .../Model/AddressSuggestionQuery.swift | 1 + IIDadata/Sources/Model/FioSuggestion.swift | 4 +-- .../Sources/Model/SuggestionProtocol.swift | 29 +++++++++++++++---- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index e4ffab0..ea70d7b 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -307,6 +307,7 @@ public class DadataSuggestions { let fioSuggestionQuery = FioSuggestionQuery(query, count: count, parts: parts, gender: gender) let fioSuggestionResponse: FioSuggestionResponse = try await fetchResponse(withQuery: fioSuggestionQuery) + debugPrint(fioSuggestionResponse) return fioSuggestionResponse.suggestions } diff --git a/IIDadata/Sources/Model/AddressSuggestion.swift b/IIDadata/Sources/Model/AddressSuggestion.swift index 8dab94e..72db1a4 100644 --- a/IIDadata/Sources/Model/AddressSuggestion.swift +++ b/IIDadata/Sources/Model/AddressSuggestion.swift @@ -9,15 +9,19 @@ /// 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? + 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?, + value: String, data: AddressData?, unrestrictedValue: String? ) { diff --git a/IIDadata/Sources/Model/AddressSuggestionQuery.swift b/IIDadata/Sources/Model/AddressSuggestionQuery.swift index eb9cc64..832c010 100644 --- a/IIDadata/Sources/Model/AddressSuggestionQuery.swift +++ b/IIDadata/Sources/Model/AddressSuggestionQuery.swift @@ -92,6 +92,7 @@ public enum ScaleLevel: String, Encodable { case settlement case street case house + case flat } // MARK: - AddressQueryConstraint diff --git a/IIDadata/Sources/Model/FioSuggestion.swift b/IIDadata/Sources/Model/FioSuggestion.swift index f7b91aa..74abc07 100644 --- a/IIDadata/Sources/Model/FioSuggestion.swift +++ b/IIDadata/Sources/Model/FioSuggestion.swift @@ -23,7 +23,7 @@ public struct FioSuggestion: Encodable, Suggestion { public let data: FioData? /// The suggested FIO value. - public let value: String? + public let value: String /// The unrestricted suggested FIO value. public let unrestrictedValue: String? @@ -49,7 +49,7 @@ public struct FioSuggestion: Encodable, Suggestion { public init(from decoder: Decoder) throws { do { let values = try decoder.container(keyedBy: CodingKeys.self) - value = try values.decodeIfPresent(String.self, forKey: .value) + 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 { diff --git a/IIDadata/Sources/Model/SuggestionProtocol.swift b/IIDadata/Sources/Model/SuggestionProtocol.swift index 291de95..b984dd1 100644 --- a/IIDadata/Sources/Model/SuggestionProtocol.swift +++ b/IIDadata/Sources/Model/SuggestionProtocol.swift @@ -4,7 +4,7 @@ // // Created by NSFuntik on 09.08.2024. // - +import SwiftUI // MARK: - Suggestion /** @@ -20,9 +20,28 @@ > - ``FioSuggestion``: Помогает человеку быстро ввести ФИО на веб-форме или в приложении - SeeAlso: [DaData API documentation](https://dadata.ru/api/suggest/) */ -public protocol Suggestion: Decodable { - associatedtype SuggestionData: Decodable +public protocol Suggestion: Decodable, Equatable, Hashable, Identifiable { + associatedtype `Data`: Decodable var unrestrictedValue: String? { get } - var value: String? { get } - var data: SuggestionData? { get } + var value: String { get } + var data: `Data`? { get } +} + +extension Suggestion { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.value == rhs.value + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(value) + } + + public var id: String { + value + } + + public var description: String { + value + } + } From af72f95518ef821ad8f56e2a579d0f66308b7fd9 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov Date: Sat, 10 Aug 2024 17:24:43 +0300 Subject: [PATCH 08/16] Test PackagePlayground added. --- Example/IIDadata.xcodeproj/project.pbxproj | 2 + .../xcschemes/IIDadata SUI.xcscheme | 78 +++++++++++++++++++ .../IntegrationTest.swiftpm/ContentView.swift | 12 +++ Example/IntegrationTest.swiftpm/MyApp.swift | 10 +++ Example/IntegrationTest.swiftpm/Package.swift | 49 ++++++++++++ IIDadata/Sources/DadataQueryProtocol.swift | 14 ++++ IIDadata/Sources/DadataSuggestions.swift | 25 ++++-- .../{ => Address}/AddressSuggestion.swift | 0 .../AddressSuggestionQuery.swift | 0 .../AddressSuggestionResponse.swift | 0 .../{ => Address}/ReverseGeocodeQuery.swift | 0 .../Sources/Model/{ => Fio}/FioData.swift | 4 +- .../Model/{ => Fio}/FioSuggestion.swift | 14 +++- .../Model/{ => Fio}/FioSuggestionQuery.swift | 0 .../{ => Fio}/FioSuggestionResponse.swift | 0 IIDadata/Sources/Model/{ => Fio}/Gender.swift | 2 +- .../Tests/IIDadataTests/IIDadataTests.swift | 2 +- 17 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme create mode 100644 Example/IntegrationTest.swiftpm/ContentView.swift create mode 100644 Example/IntegrationTest.swiftpm/MyApp.swift create mode 100644 Example/IntegrationTest.swiftpm/Package.swift rename IIDadata/Sources/Model/{ => Address}/AddressSuggestion.swift (100%) rename IIDadata/Sources/Model/{ => Address}/AddressSuggestionQuery.swift (100%) rename IIDadata/Sources/Model/{ => Address}/AddressSuggestionResponse.swift (100%) rename IIDadata/Sources/Model/{ => Address}/ReverseGeocodeQuery.swift (100%) rename IIDadata/Sources/Model/{ => Fio}/FioData.swift (97%) rename IIDadata/Sources/Model/{ => Fio}/FioSuggestion.swift (79%) rename IIDadata/Sources/Model/{ => Fio}/FioSuggestionQuery.swift (100%) rename IIDadata/Sources/Model/{ => Fio}/FioSuggestionResponse.swift (100%) rename IIDadata/Sources/Model/{ => Fio}/Gender.swift (99%) diff --git a/Example/IIDadata.xcodeproj/project.pbxproj b/Example/IIDadata.xcodeproj/project.pbxproj index 165ae6b..7583b99 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 /* IntegrationTest.swiftpm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = IntegrationTest.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 /* IntegrationTest.swiftpm */, 607FACF51AFB993E008FA782 /* Podspec Metadata */, 607FACD21AFB9204008FA782 /* Example for IIDadata */, 607FACE81AFB9204008FA782 /* Tests */, diff --git a/Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme b/Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme new file mode 100644 index 0000000..b8c2108 --- /dev/null +++ b/Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/IntegrationTest.swiftpm/ContentView.swift b/Example/IntegrationTest.swiftpm/ContentView.swift new file mode 100644 index 0000000..92574d0 --- /dev/null +++ b/Example/IntegrationTest.swiftpm/ContentView.swift @@ -0,0 +1,12 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundColor(.accentColor) + Text("Hello, world!") + } + } +} diff --git a/Example/IntegrationTest.swiftpm/MyApp.swift b/Example/IntegrationTest.swiftpm/MyApp.swift new file mode 100644 index 0000000..7cb2ea6 --- /dev/null +++ b/Example/IntegrationTest.swiftpm/MyApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Example/IntegrationTest.swiftpm/Package.swift b/Example/IntegrationTest.swiftpm/Package.swift new file mode 100644 index 0000000..402e4d1 --- /dev/null +++ b/Example/IntegrationTest.swiftpm/Package.swift @@ -0,0 +1,49 @@ +// 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: .calculator), + accentColor: .presetColor(.yellow), + supportedDeviceFamilies: [ + .pad, + .phone + ], + supportedInterfaceOrientations: [ + .portrait, + .landscapeRight, + .landscapeLeft, + .portraitUpsideDown(.when(deviceFamilies: [.pad])) + ] + ) + ], + dependencies: [ + .package(path: "../..") + ], + targets: [ + .executableTarget( + name: "AppModule", + dependencies: [ + .product(name: "IIDadata", package: "iidadata") + ], + path: "." + ) + ] +) \ No newline at end of file diff --git a/IIDadata/Sources/DadataQueryProtocol.swift b/IIDadata/Sources/DadataQueryProtocol.swift index 04295c8..0a9fd22 100644 --- a/IIDadata/Sources/DadataQueryProtocol.swift +++ b/IIDadata/Sources/DadataQueryProtocol.swift @@ -7,7 +7,21 @@ 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/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index ea70d7b..201c295 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -1,5 +1,6 @@ import Foundation +@available(iOS 14.0, *) public class DadataSuggestions { // Static Properties @@ -7,7 +8,10 @@ public class 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 @@ -302,28 +306,33 @@ public class DadataSuggestions { gender: Gender? = nil, parts: [FioSuggestionQuery.Part]? = nil ) async throws -> [FioSuggestion] { - - - let fioSuggestionQuery = FioSuggestionQuery(query, count: count, parts: parts, gender: gender) - + 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( /* timeout: Int */ ) async throws { + func checkAPIConnectivity() async throws { let request = createRequest(url: suggestionsAPIURL.appendingPathComponent(Constants.addressEndpoint)) -// request.timeoutInterval = TimeInterval(timeout) + 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) -// NSError(domain: "HTTP Error", code: (response as? HTTPURLResponse)?.statusCode ?? 0, userInfo: nil) } } + // MARK: - Private + private func createRequest(url: URL) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = "POST" @@ -349,7 +358,7 @@ public class DadataSuggestions { 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)") + 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 { diff --git a/IIDadata/Sources/Model/AddressSuggestion.swift b/IIDadata/Sources/Model/Address/AddressSuggestion.swift similarity index 100% rename from IIDadata/Sources/Model/AddressSuggestion.swift rename to IIDadata/Sources/Model/Address/AddressSuggestion.swift diff --git a/IIDadata/Sources/Model/AddressSuggestionQuery.swift b/IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift similarity index 100% rename from IIDadata/Sources/Model/AddressSuggestionQuery.swift rename to IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift diff --git a/IIDadata/Sources/Model/AddressSuggestionResponse.swift b/IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift similarity index 100% rename from IIDadata/Sources/Model/AddressSuggestionResponse.swift rename to IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift diff --git a/IIDadata/Sources/Model/ReverseGeocodeQuery.swift b/IIDadata/Sources/Model/Address/ReverseGeocodeQuery.swift similarity index 100% rename from IIDadata/Sources/Model/ReverseGeocodeQuery.swift rename to IIDadata/Sources/Model/Address/ReverseGeocodeQuery.swift diff --git a/IIDadata/Sources/Model/FioData.swift b/IIDadata/Sources/Model/Fio/FioData.swift similarity index 97% rename from IIDadata/Sources/Model/FioData.swift rename to IIDadata/Sources/Model/Fio/FioData.swift index 242cf53..5b3319f 100644 --- a/IIDadata/Sources/Model/FioData.swift +++ b/IIDadata/Sources/Model/Fio/FioData.swift @@ -5,14 +5,14 @@ // Created by NSFuntik on 09.08.2024. // +import Foundation + public extension FioSuggestion { // MARK: - FioData /// A structure representing detailed FIO data. /// /// - Parameters: - /// - value` : ФИОодной строкой - /// - unrestricted_value` : value /// - surname: Фамилия /// - name: Имя /// - patronymic: Отчество diff --git a/IIDadata/Sources/Model/FioSuggestion.swift b/IIDadata/Sources/Model/Fio/FioSuggestion.swift similarity index 79% rename from IIDadata/Sources/Model/FioSuggestion.swift rename to IIDadata/Sources/Model/Fio/FioSuggestion.swift index 74abc07..f388d98 100644 --- a/IIDadata/Sources/Model/FioSuggestion.swift +++ b/IIDadata/Sources/Model/Fio/FioSuggestion.swift @@ -7,7 +7,17 @@ // MARK: - FioSuggestion -/// A structure representing a single FIO suggestion. +/// 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 @@ -25,7 +35,7 @@ public struct FioSuggestion: Encodable, Suggestion { /// The suggested FIO value. public let value: String - /// The unrestricted suggested FIO value. + /// The unrestricted suggested FIO value. (` == value `) public let unrestrictedValue: String? // Lifecycle diff --git a/IIDadata/Sources/Model/FioSuggestionQuery.swift b/IIDadata/Sources/Model/Fio/FioSuggestionQuery.swift similarity index 100% rename from IIDadata/Sources/Model/FioSuggestionQuery.swift rename to IIDadata/Sources/Model/Fio/FioSuggestionQuery.swift diff --git a/IIDadata/Sources/Model/FioSuggestionResponse.swift b/IIDadata/Sources/Model/Fio/FioSuggestionResponse.swift similarity index 100% rename from IIDadata/Sources/Model/FioSuggestionResponse.swift rename to IIDadata/Sources/Model/Fio/FioSuggestionResponse.swift diff --git a/IIDadata/Sources/Model/Gender.swift b/IIDadata/Sources/Model/Fio/Gender.swift similarity index 99% rename from IIDadata/Sources/Model/Gender.swift rename to IIDadata/Sources/Model/Fio/Gender.swift index 9ec8c38..083f7ba 100644 --- a/IIDadata/Sources/Model/Gender.swift +++ b/IIDadata/Sources/Model/Fio/Gender.swift @@ -5,7 +5,7 @@ // Created by NSFuntik on 09.08.2024. // - +import Foundation // MARK: - Gender /** diff --git a/IIDadata/Tests/IIDadataTests/IIDadataTests.swift b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift index 1770853..7df2951 100644 --- a/IIDadata/Tests/IIDadataTests/IIDadataTests.swift +++ b/IIDadata/Tests/IIDadataTests/IIDadataTests.swift @@ -1,7 +1,7 @@ @testable import IIDadata import XCTest -final class DadataSuggestionsTests: XCTestCase { +final class IIDadataTests: XCTestCase { // Properties // let apiKey = "abadf779d0525bebb9e16b72a97eabf4f7143292" From e3bad2a2b4e8822a4be2b51886659dd80a2264ca Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov <39944472+NSFuntik@users.noreply.github.com> Date: Sun, 11 Aug 2024 19:39:29 +0300 Subject: [PATCH 09/16] + IIDadataSuggestions Popover View Modifier: + Test project --- .../xcschemes/IIDadata 1.xcscheme | 11 +- .../xcshareddata/xcschemes/IIDadata.xcscheme | 7 - .../xcschemes/IIDadataTests.xcscheme | 64 ++- .../xcschemes/IIDadata SUI.xcscheme | 27 +- .../xcschemes/IIDadata-SPM.xcscheme | 113 ++++++ .../IIDadata-Test.swiftpm/ContentView.swift | 284 +++++++++++++ Example/IIDadata-Test.swiftpm/MyApp.swift | 10 + .../Package.swift | 17 +- Example/IIDadata.xcodeproj/project.pbxproj | 16 +- Example/IIDadata/Info.plist | 12 +- .../IntegrationTest.swiftpm/ContentView.swift | 12 - Example/IntegrationTest.swiftpm/MyApp.swift | 10 - .../xcshareddata/xcschemes/IIDadata.xcscheme | 67 ---- IIDadata/Sources/Constants.swift | 2 - IIDadata/Sources/DadataSuggestions.swift | 12 +- .../Model/Address/AddressSuggestion.swift | 185 +++++++++ .../Address/AddressSuggestionQuery.swift | 2 +- .../Address/AddressSuggestionResponse.swift | 4 +- IIDadata/Sources/Model/Fio/FioData.swift | 4 +- .../Sources/Model/SuggestionProtocol.swift | 41 +- .../Suggestions Popover/FloatingPopover.swift | 300 ++++++++++++++ .../IIDadataSuggestionsPopover.swift | 377 ++++++++++++++++++ Package.swift | 66 +-- 23 files changed, 1455 insertions(+), 188 deletions(-) rename Example/{IntegrationTest.swiftpm => IIDadata-Test.swiftpm}/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme (77%) create mode 100644 Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-SPM.xcscheme create mode 100644 Example/IIDadata-Test.swiftpm/ContentView.swift create mode 100644 Example/IIDadata-Test.swiftpm/MyApp.swift rename Example/{IntegrationTest.swiftpm => IIDadata-Test.swiftpm}/Package.swift (68%) delete mode 100644 Example/IntegrationTest.swiftpm/ContentView.swift delete mode 100644 Example/IntegrationTest.swiftpm/MyApp.swift delete mode 100644 Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme create mode 100644 IIDadata/Suggestions Popover/FloatingPopover.swift create mode 100644 IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme index 7f7028e..79a22c2 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> - - - - - - - - + + + + + + + + + + - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> @@ -41,6 +66,22 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + + + + + + + + + diff --git a/Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme b/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme similarity index 77% rename from Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme rename to Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme index b8c2108..e6a7a91 100644 --- a/Example/IntegrationTest.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme +++ b/Example/IIDadata-Test.swiftpm/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata SUI.xcscheme @@ -15,10 +15,10 @@ buildForAnalyzing = "YES"> + BlueprintIdentifier = "IIDadata" + BuildableName = "IIDadata" + BlueprintName = "IIDadata" + ReferencedContainer = "container:../.."> @@ -44,9 +44,9 @@ runnableDebuggingMode = "0"> @@ -57,16 +57,15 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES"> - + + BlueprintIdentifier = "IIDadata" + BuildableName = "IIDadata" + BlueprintName = "IIDadata" + ReferencedContainer = "container:../.."> - + 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/IntegrationTest.swiftpm/Package.swift b/Example/IIDadata-Test.swiftpm/Package.swift similarity index 68% rename from Example/IntegrationTest.swiftpm/Package.swift rename to Example/IIDadata-Test.swiftpm/Package.swift index 402e4d1..ae59f70 100644 --- a/Example/IntegrationTest.swiftpm/Package.swift +++ b/Example/IIDadata-Test.swiftpm/Package.swift @@ -20,7 +20,7 @@ let package = Package( teamIdentifier: "UB936SP78M", displayVersion: "1.0", bundleVersion: "1", - appIcon: .placeholder(icon: .calculator), + appIcon: .placeholder(icon: .sparkle), accentColor: .presetColor(.yellow), supportedDeviceFamilies: [ .pad, @@ -31,7 +31,20 @@ let package = Package( .landscapeRight, .landscapeLeft, .portraitUpsideDown(.when(deviceFamilies: [.pad])) - ] + ], + capabilities: [ + .appTransportSecurity(configuration: .init( + exceptionDomains: [ + .init( + domainName: "suggestions.dadata.ru", + includesSubdomains: true, + exceptionAllowsInsecureHTTPLoads: true + ) + ] + )), + .outgoingNetworkConnections() + ], + appCategory: .developerTools ) ], dependencies: [ diff --git a/Example/IIDadata.xcodeproj/project.pbxproj b/Example/IIDadata.xcodeproj/project.pbxproj index 7583b99..f5a06dd 100644 --- a/Example/IIDadata.xcodeproj/project.pbxproj +++ b/Example/IIDadata.xcodeproj/project.pbxproj @@ -52,7 +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 /* IntegrationTest.swiftpm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = IntegrationTest.swiftpm; sourceTree = ""; }; + 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 */ @@ -79,7 +79,7 @@ 607FACC71AFB9204008FA782 = { isa = PBXGroup; children = ( - F5AC60CC2C67A96800ECCF4B /* IntegrationTest.swiftpm */, + F5AC60CC2C67A96800ECCF4B /* IIDadata-Test.swiftpm */, 607FACF51AFB993E008FA782 /* Podspec Metadata */, 607FACD21AFB9204008FA782 /* Example for IIDadata */, 607FACE81AFB9204008FA782 /* Tests */, @@ -223,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; }; @@ -499,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; @@ -515,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; @@ -530,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)", @@ -553,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 ca41b81..e937298 100644 --- a/Example/IIDadata/Info.plist +++ b/Example/IIDadata/Info.plist @@ -22,12 +22,20 @@ 1 LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + suggestions.dadata.ru + + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile Main - NSAppTransportSecurity - UIRequiredDeviceCapabilities armv7 diff --git a/Example/IntegrationTest.swiftpm/ContentView.swift b/Example/IntegrationTest.swiftpm/ContentView.swift deleted file mode 100644 index 92574d0..0000000 --- a/Example/IntegrationTest.swiftpm/ContentView.swift +++ /dev/null @@ -1,12 +0,0 @@ -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundColor(.accentColor) - Text("Hello, world!") - } - } -} diff --git a/Example/IntegrationTest.swiftpm/MyApp.swift b/Example/IntegrationTest.swiftpm/MyApp.swift deleted file mode 100644 index 7cb2ea6..0000000 --- a/Example/IntegrationTest.swiftpm/MyApp.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -@main -struct MyApp: App { - var body: some Scene { - WindowGroup { - ContentView() - } - } -} diff --git a/Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme b/Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme deleted file mode 100644 index 4dfd744..0000000 --- a/Example/Pods/Pods.xcodeproj/xcshareddata/xcschemes/IIDadata.xcscheme +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/IIDadata/Sources/Constants.swift b/IIDadata/Sources/Constants.swift index b87df43..42eace3 100644 --- a/IIDadata/Sources/Constants.swift +++ b/IIDadata/Sources/Constants.swift @@ -12,8 +12,6 @@ import Foundation 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 fioEndpoint = "suggest/fio" static let addressEndpoint = AddressQueryType.address.rawValue static let addressFIASOnlyEndpoint = AddressQueryType.fiasOnly.rawValue static let addressByIDEndpoint = AddressQueryType.findByID.rawValue diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index 201c295..8c252a6 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -1,7 +1,7 @@ import Foundation - +import protocol Combine.ObservableObject @available(iOS 14.0, *) -public class DadataSuggestions { +public actor DadataSuggestions: ObservableObject { // Static Properties private static var sharedInstance: DadataSuggestions? @@ -23,7 +23,7 @@ public class DadataSuggestions { /// 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 { + public init() throws { let key = try DadataSuggestions.readAPIKeyFromPlist() self.init(apiKey: key) } @@ -42,14 +42,14 @@ public class DadataSuggestions { /// /// - 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(api: String /* , checkWithTimeout timeout: Int */ ) throws { + public init(api: String /* , checkWithTimeout timeout: Int */ ) throws { self.init(apiKey: api) Task { try await checkAPIConnectivity() } } /// New instance of DadataSuggestions. /// - Parameter apiKey: Dadata API token. Check it in account settings at dadata.ru. - public required convenience init(apiKey: String) { + public /*required*/ init(apiKey: String) { self.init(apiKey: apiKey, url: Constants.suggestionsAPIURL) } @@ -119,7 +119,7 @@ public class DadataSuggestions { /// - 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. - public func suggestAddress( + @Sendable public func suggestAddress( _ query: String, queryType: AddressQueryType = .address, resultsCount: Int? = 10, diff --git a/IIDadata/Sources/Model/Address/AddressSuggestion.swift b/IIDadata/Sources/Model/Address/AddressSuggestion.swift index 72db1a4..ddaf1cd 100644 --- a/IIDadata/Sources/Model/Address/AddressSuggestion.swift +++ b/IIDadata/Sources/Model/Address/AddressSuggestion.swift @@ -264,6 +264,191 @@ public extension AddressSuggestion { 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) diff --git a/IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift b/IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift index 832c010..25e1f64 100644 --- a/IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift +++ b/IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift @@ -96,9 +96,9 @@ public enum ScaleLevel: String, Encodable { } // 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 diff --git a/IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift b/IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift index 665c7a1..5c3c188 100644 --- a/IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift +++ b/IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift @@ -7,7 +7,7 @@ import Foundation // MARK: - AddressSuggestionResponse /// AddressSuggestionResponse represents a deserializable object used to hold API response. -public struct AddressSuggestionResponse: Decodable { +public struct AddressSuggestionResponse: Decodable, Sendable { public let suggestions: [AddressSuggestion]? } @@ -16,7 +16,7 @@ public struct AddressSuggestionResponse: Decodable { /// 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 struct Metro: Decodable, Sendable { // Nested Types enum CodingKeys: String, CodingKey { diff --git a/IIDadata/Sources/Model/Fio/FioData.swift b/IIDadata/Sources/Model/Fio/FioData.swift index 5b3319f..755c1ac 100644 --- a/IIDadata/Sources/Model/Fio/FioData.swift +++ b/IIDadata/Sources/Model/Fio/FioData.swift @@ -72,8 +72,8 @@ public extension FioSuggestion { surname: String?, name: String?, patronymic: String?, - gender: Gender?, - qc: String? + gender: Gender? = nil, + qc: String? = nil ) { self.surname = surname self.name = name diff --git a/IIDadata/Sources/Model/SuggestionProtocol.swift b/IIDadata/Sources/Model/SuggestionProtocol.swift index b984dd1..9b2dacd 100644 --- a/IIDadata/Sources/Model/SuggestionProtocol.swift +++ b/IIDadata/Sources/Model/SuggestionProtocol.swift @@ -5,6 +5,7 @@ // Created by NSFuntik on 09.08.2024. // import SwiftUI + // MARK: - Suggestion /** @@ -20,28 +21,48 @@ import SwiftUI > - ``FioSuggestion``: Помогает человеку быстро ввести ФИО на веб-форме или в приложении - SeeAlso: [DaData API documentation](https://dadata.ru/api/suggest/) */ -public protocol Suggestion: Decodable, Equatable, Hashable, Identifiable { - associatedtype `Data`: Decodable - var unrestrictedValue: String? { get } - var value: String { get } - var data: `Data`? { get } +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 } +} +extension FioSuggestion { + public var type: SuggestionType { .fio } } -extension Suggestion { - public static func == (lhs: Self, rhs: Self) -> Bool { +extension AddressSuggestion { + public var type: SuggestionType { .address } +} + +public extension Suggestion { + static func == (lhs: Self, rhs: Self) -> Bool { lhs.value == rhs.value } - public func hash(into hasher: inout Hasher) { + func hash(into hasher: inout Hasher) { hasher.combine(value) } - public var id: String { + var id: String { value } - public var description: String { + var description: String { value } +} + + +// MARK: - SuggestionType + +public enum SuggestionType { + case address + case fio +} +public enum IIDadataError: Error { + case noSuggestions } diff --git a/IIDadata/Suggestions Popover/FloatingPopover.swift b/IIDadata/Suggestions Popover/FloatingPopover.swift new file mode 100644 index 0000000..cdddc86 --- /dev/null +++ b/IIDadata/Suggestions Popover/FloatingPopover.swift @@ -0,0 +1,300 @@ +import SwiftUI + +#if canImport(UIKit) + /// A view modifier for displaying a floating popover over a given anchor view. + /// + /// This view modifier provides the ability to present a popover view as a floating overlay + /// over an anchor view when the `isPresented` binding is `true`. + /// + /// - Parameters: + /// - isPresented: A binding that controls whether the popover should be presented. + /// - contentBlock: A closure returning the content of the popover, which conforms to the `View` protocol. + /// + /// This view modifier is designed to work as a part of a view hierarchy and should be applied to a view to enable popover presentation. + /// + /// For iOS 15 compatibility, it includes a workaround for the missing `@StateObject` property wrapper, which uses an internal `Root` to manage the anchor view. + public struct FloatingPopover: ViewModifier where Item: Identifiable, PopoverContent: View { + // Workaround for missing @StateObject in iOS 15. + private struct Parent { + var anchorView = UIView() + } + + /// A private struct that represents an internal anchor view. + private struct InternalAnchorView: UIViewRepresentable { + typealias UIViewType = UIView + + // Properties + + @State var uiView: UIView + + // Functions + + /// Creates and returns the view for the anchor. + /// + /// - Parameter context: The context of the UIViewRepresentable. + /// - Returns: A UIView with a background color of white. + func makeUIView(context _: Self.Context) -> Self.UIViewType { + uiView.backgroundColor = UIColor.white + return uiView + } + + /// Updates the anchor view with the latest state. + /// + /// - Parameters: + /// - uiView: The UIView instance to be updated. + /// - context: The context of the UIViewRepresentable. + func updateUIView(_ uiView: Self.UIViewType, context _: Self.Context) { + self.uiView = uiView + } + } + + // Nested Types + + /// A nested class that represents a content view controller for the popover. + private class ContentViewController: UIHostingController, UIPopoverPresentationControllerDelegate where V: View { + // Properties + + @Binding var isPresented: Bool + var size: CGSize = .init(width: 300, height: 400) + + // Lifecycle + + /// Initializes the view controller with a root view and binding to `isPresented`. + /// + /// - Parameters: + /// - rootView: The root view to be hosted. + /// - isPresented: A binding that indicates whether the popover is presented. + init(rootView: V, isPresented: Binding) { + _isPresented = isPresented + super.init(rootView: rootView) + } + + @available(*, unavailable) + @MainActor @objc dynamic required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // Overridden Functions + + /// Called after the controller's view is loaded into memory. Sets the view's background color and preferred content size. + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + size = sizeThatFits(in: UIView.layoutFittingExpandedSize) + preferredContentSize = size + } + + // Functions + + /// Specifies the presentation style for the view controller. + /// + /// - Parameters: + /// - controller: The presentation controller. + /// - traitCollection: The trait collection of the interface environment. + /// - Returns: The modal presentation style, which is `.popover` for this implementation. + func adaptivePresentationStyle(for _: UIPresentationController, traitCollection _: UITraitCollection) -> UIModalPresentationStyle { + return .popover + } + + /// Notifies that the popover presentation controller was dismissed. + /// + /// - Parameter controller: The presentation controller that was dismissed. + func presentationControllerDidDismiss(_: UIPresentationController) { + $isPresented.animation(.bouncy).wrappedValue = false + } + } + + // Properties + + /// A binding that controls whether the popover should be presented. + @Binding var item: Item? + /// A closure returning the content of the popover. + @State var contentBlock: ((Item) -> PopoverContent)? + /// A closure returning the content of the popover. + @State var contentOptional: (() -> PopoverContent)? + + /// A private property that represents the anchor view. + @State private var perent = Parent() + + // Lifecycle + + /// Initializes the FloatingPopover with a binding to an item and a content block. + /// + /// - Parameters: + /// - item: A binding to the item that controls the presentation of the popover. + /// - contentBlock: A closure that returns the content of the popover based on the item. + init( + item: Binding, + @ViewBuilder contentBlock: @escaping (Item) -> PopoverContent + ) { + _item = item + self.contentBlock = contentBlock + contentOptional = nil + } + + /// Initializes the FloatingPopover with a boolean binding and a content block. + /// + /// - Parameters: + /// - isPresented: A binding that indicates whether the popover should be presented. + /// - contentBlock: A closure that returns the content of the popover. + init( + isPresented: Binding, + @ViewBuilder contentBlock: @escaping () -> PopoverContent + ) where Item == Bool { + _item = .init(get: { + let bool: Bool? = isPresented.wrappedValue + return bool + }, set: { + guard let _ = $0 else { isPresented.wrappedValue = false; return } + isPresented.wrappedValue = true + }) + contentOptional = contentBlock + } + + // Content + + /// Modifies the content view by adding popover presentation logic. + /// + /// If `isPresented` is `true`, this modifier presents the popover containing the provided content. + /// + /// - Parameter content: The content view to be modified. + /// - Returns: A view with popover presentation capabilities. + public func body(content: Content) -> some View { + if let item = item { + withAnimation(.bouncy) { + presentPopover(with: item) + } + } + return Button(action: { + withAnimation(.bouncy) { + if let item = item { + withAnimation(.bouncy) { + presentPopover(with: item) + } + } + } + }, label: { + content + .background(InternalAnchorView(uiView: perent.anchorView).background(Color.black)) + }) + } + + // Functions + + /// Presents the popover with the provided item. + /// + /// - Parameter item: The item that triggers the popover presentation. + /// - Returns: The presented popover view controller. + /// - Note: This function is called by the `body` modifier and should not be called directly. + private func presentPopover(with item: Item) { + var contentController: ContentViewController + if let contentBlock = contentBlock { + contentController = ContentViewController( + rootView: contentBlock(item), + isPresented: .init(get: { + $item.wrappedValue != nil + }, set: { newState in + self.item = newState ? $item.wrappedValue : nil + }) + ) + } else { + guard let contentOptional = contentOptional else { return } + contentController = ContentViewController( + rootView: contentOptional(), + isPresented: .init(get: { + $item.wrappedValue != nil + }, set: { newState in + self.item = newState ? $item.wrappedValue : nil + }) + ) + } + contentController.modalPresentationStyle = .popover + let view = perent.anchorView + view.backgroundColor = .black + guard let popover = contentController.popoverPresentationController else { return } + popover.sourceView = view + popover.sourceRect = view.bounds + popover.delegate = contentController + popover.backgroundColor = UIColor.black + guard let sourceVC = view.closestVC() else { return } + if let presentedVC = sourceVC.presentedViewController { + presentedVC.dismiss(animated: true) { + sourceVC.present(contentController, animated: true) + } + } else { + sourceVC.present(contentController, animated: true) + } + } + } + + extension Bool: @retroactive Identifiable { public var id: Bool { self } } + + public extension UIView { + func closestVC() -> UIViewController? { + var responder: UIResponder? = self + while responder != nil { + if let vc = responder as? UIViewController { + return vc + } + responder = responder?.next + } + return nil + } + } + + public extension View { + /** + Adds a floating popover to the current view that is presented when the given identifiable item is non-nil. + + - Parameters: + - item: A binding to an optional identifiable item that controls the presentation of the popover. When the item becomes non-nil, the popover is presented. + - content: A view builder that creates the content of the popover using the provided item. + + - Returns: A view that conditionally presents a popover when the item is non-nil. On iOS 16.4 and later, it uses the native `popover` modifier with a fixed size. For earlier OS, it applies a custom `FloatingPopover` modifier. + + - Note: On iOS 16.4 and later, the popover is presented using the native popover modifier with a fixed size and `.popover` adaptation. On earlier versions, a custom `FloatingPopover` modifier is used. + */ + @ViewBuilder + func floatingPopover( + item: Binding, + @ViewBuilder content: @escaping (Item) -> some View + ) -> some View { +// if #available(iOS 16.4, *) { +// popover(item: item) { item in +// content(item) +// .presentationCompactAdaptation(.popover) +// .fixedSize() +// } +// } else { + modifier(FloatingPopover(item: item, contentBlock: content)) +// } + } + + /** + Adds a floating popover to the current view that is presented when the given boolean binding is true. + + - Parameters: + - isPresented: A binding to a boolean value that controls the presentation of the popover. When the value becomes true, the popover is presented. + - content: A view builder that creates the content of the popover. + + - Returns: A view that conditionally presents a popover when the boolean value is true. On iOS 16.4 and later, it uses the native `popover` modifier with a fixed size. For earlier OS, it applies a custom `FloatingPopover` modifier. + + - Note: On iOS 16.4 and later, the popover is presented using the native popover modifier with a fixed size and `.popover` adaptation. On earlier versions, a custom `FloatingPopover` modifier is used. + */ + @ViewBuilder + func floatingPopover( + isPresented: Binding, + @ViewBuilder content: @escaping () -> some View + ) -> some View { + if #available(iOS 16.4, *) { + popover(isPresented: isPresented) { + content() + .presentationCompactAdaptation(.popover) + .fixedSize() + } + } else { + modifier(FloatingPopover(isPresented: isPresented, contentBlock: content)) + } + } + } +#endif diff --git a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift new file mode 100644 index 0000000..da4bb58 --- /dev/null +++ b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -0,0 +1,377 @@ +// +// IIDadataSuggestionsView.swift +// IIDadata +// +// Created by NSFuntik on 11.08.2024. +// +import IIDadata +import SwiftUI + +// 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`` +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 = (String) -> Void + + // Properties + + @Binding var text: String + @Binding var suggestions: [T]? + let onSuggestionSelected: OnSuggestionSelected + + @StateObject private var dadata: DadataSuggestions + @Binding private var isPopoverPresented: Bool + + // 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( + input text: Binding, + dadata: DadataSuggestions, + suggestions: Binding<[T]?>, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) { + _dadata = StateObject(wrappedValue: dadata) + _text = text + _suggestions = suggestions + _isPopoverPresented = isPresented + self.onSuggestionSelected = onSuggestionSelected + } + + /// 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( + apiKey: String, + input text: Binding, + suggestions: Binding<[T]?>, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) { + _dadata = StateObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) + _text = text + _suggestions = suggestions + _isPopoverPresented = isPresented + self.onSuggestionSelected = onSuggestionSelected + } + + // Content + + public func body(content: Content) -> some View { + content + .onChange(of: text) { _ in + guard !text.isEmpty else { + suggestions = nil + return + } + Task(priority: .userInitiated) { + await getSuggestions() + isPopoverPresented = suggestions?.isEmpty == false + } + } + .floatingPopover(isPresented: $isPopoverPresented) { + if let suggestions = suggestions?.compactMap(\.value) { + SuggestionsPopover( + with: suggestions, + onSelect: { suggestion in + text = suggestion + onSuggestionSelected(suggestion) + if suggestions.count == 1 { + isPopoverPresented = false + } + } + ) + } + } + } + + // Functions + + /// 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`. + func getSuggestions() async { + guard !text.isEmpty else { + suggestions = nil + return + } + do { + switch suggestions?.first?.type { + case .address: + await getAddressSuggestions(address: text) + case .fio: + await getFioSuggestions(fio: text) + case nil: + throw IIDadataError.noSuggestions + } + } catch { + debugPrint("Error fetching suggestions: \(error)") + } + } + + /// 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(address: String) async { + guard !address.isEmpty else { return } + do { + suggestions = try await dadata.suggestAddress( + address, + queryType: .address, + resultsCount: 10, + language: .ru + ).suggestions?.compactMap { $0 as? T } + } 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(fio: String) async { + guard !fio.isEmpty else { return } + do { + suggestions = try await dadata.suggestFio( + fio, + count: 10, + gender: .male, + parts: [.surname, .name, .patronymic] + ).compactMap { $0 as? T } + } catch { + print("Error fetching FIO suggestions: \(error)") + } + } +} + +// 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. +/// - onSelect: A closure to handle the selection of a suggestion. +public struct SuggestionsPopover: View { + // Properties + + var suggestions: [Suggestion.Value] + let onSelect: (String) -> Void + + // Lifecycle + + /// Creates a new `SuggestionsPopover`. + /// + /// - Parameters: + /// - suggestions: An array of `Suggestion.Value` to be displayed. + /// - onSelect: A closure that gets executed when a suggestion is selected. + init( + with suggestions: [Suggestion.Value], + onSelect: @escaping (String) -> Void + ) { + self.suggestions = suggestions + self.onSelect = onSelect + } + + // Content + + public var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(suggestions, id: \.self) { suggestion in + Button(action: { + onSelect(suggestion) + }) { + Text(suggestion) + .font( + .system( + size: 12, + weight: .regular, + design: .rounded + ) + ) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.automatic) + .accentColor(.accentColor) + } + } + .padding(8) + } + } +} + +// 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. + @ViewBuilder @available(iOS 14.0, *) + func iidadataSuggestions( + apiKey: String, + input text: Binding, + suggestions: Binding<[T]?>, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) -> some View { + modifier( + IIDadataSuggestsPopover( + apiKey: apiKey, + input: text, + suggestions: suggestions, + isPresented: isPresented, + onSuggestionSelected: onSuggestionSelected + ) + ) + } + + /// 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. + /// - isPresented: A binding to a boolean value that indicates whether the popover is presented. + /// - onSuggestionSelected: A closure to handle the selection of a suggestion. + /// + /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. + @ViewBuilder func iidadataSuggestions( + dadata: DadataSuggestions, + input text: Binding, + suggestions: Binding<[T]?>, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) -> some View { + modifier( + IIDadataSuggestsPopover( + input: text, + dadata: dadata, + suggestions: suggestions, + isPresented: isPresented, + onSuggestionSelected: onSuggestionSelected + ) + ) + } +} + +// #if DEBUG + +// 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 +// +// +// +// // 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() +// } +// } +// #endif diff --git a/Package.swift b/Package.swift index 66071ef..bdb12a7 100644 --- a/Package.swift +++ b/Package.swift @@ -4,30 +4,44 @@ import PackageDescription let package = Package( - name: "IIDadata", - 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"]), - ], - 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: ["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. + + .target( + name: "IIDadata", + dependencies: [], + path: "IIDadata/Sources/" + ), + .target( + name: "IIDadataUI", + dependencies: ["IIDadata"], + path: "IIDadata/Suggestions Popover/" + ), + .testTarget( + name: "IIDadataTests", + dependencies: ["IIDadata"], + path: "IIDadata/Tests/IIDadataTests/" + ), + ] ) From 88903c95c3bc854d7385f3870df5b46415686b55 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov <39944472+NSFuntik@users.noreply.github.com> Date: Mon, 12 Aug 2024 01:37:34 +0300 Subject: [PATCH 10/16] Example usage added --- .../xcshareddata/xcschemes/Example.xcscheme | 85 +++ .../xcschemes/IIDadata-Package.xcscheme | 141 +++++ .../xcshareddata/xcschemes/IIDadata.xcscheme | 18 +- .../xcschemes/IIDadataExample.xcscheme | 78 +++ .../xcschemes/IIDadataTests.xcscheme | 55 -- ...IDadata 1.xcscheme => IIDadataUI.xcscheme} | 20 +- IIDadata/Example/ContentView.swift | 63 +++ .../Sources/Model/SuggestionProtocol.swift | 37 +- .../Suggestions Popover/FloatingPopover.swift | 54 +- .../IIDadataSuggestionsPopover.swift | 487 ++++++++++++------ Package.swift | 17 +- 11 files changed, 789 insertions(+), 266 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadataExample.xcscheme rename .swiftpm/xcode/xcshareddata/xcschemes/{IIDadata 1.xcscheme => IIDadataUI.xcscheme} (76%) create mode 100644 IIDadata/Example/ContentView.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme new file mode 100644 index 0000000..f6926a5 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme new file mode 100644 index 0000000..2885449 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata-Package.xcscheme @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme index c56af30..248e6fc 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme @@ -27,13 +27,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> @@ -57,15 +52,6 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme index 182c960..4e9836e 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme @@ -6,36 +6,6 @@ parallelizeBuildables = "YES" buildImplicitDependencies = "YES" buildArchitectures = "Automatic"> - - - - - - - - - - - - - - - - - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme similarity index 76% rename from .swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme rename to .swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme index 79a22c2..1c8552b 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata 1.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:"> + + + + diff --git a/IIDadata/Example/ContentView.swift b/IIDadata/Example/ContentView.swift new file mode 100644 index 0000000..883698d --- /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"] ?? "abadf779d0525bebb9e16b72a97eabf4f7143292" + + _suggestions = State(initialValue: []) + } + + // Content + + var body: some View { + TextField("ФИО", text: $text, prompt: Text("ФИО")) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + .font(.body) + .iidadataSuggestions( + apiKey: apiKey, + input: $text, + suggestions: $suggestions, + isPresented: $isPresented + ) { s in + debugPrint(s) + text = s + 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/Model/SuggestionProtocol.swift b/IIDadata/Sources/Model/SuggestionProtocol.swift index 9b2dacd..f086b83 100644 --- a/IIDadata/Sources/Model/SuggestionProtocol.swift +++ b/IIDadata/Sources/Model/SuggestionProtocol.swift @@ -29,40 +29,67 @@ public protocol Suggestion: Decodable, Equatable, Hashable, Identifiable, Sendab var data: Data? { get } var type: SuggestionType { get } } -extension FioSuggestion { - public var type: SuggestionType { .fio } + +public extension Suggestion where Self == FioSuggestion { + /// A computed property that returns the suggestion type for Fio suggestion, which is `.fio` + var type: SuggestionType { .fio } } -extension AddressSuggestion { - public var type: SuggestionType { .address } +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/IIDadata/Suggestions Popover/FloatingPopover.swift b/IIDadata/Suggestions Popover/FloatingPopover.swift index cdddc86..680b173 100644 --- a/IIDadata/Suggestions Popover/FloatingPopover.swift +++ b/IIDadata/Suggestions Popover/FloatingPopover.swift @@ -13,7 +13,8 @@ import SwiftUI /// This view modifier is designed to work as a part of a view hierarchy and should be applied to a view to enable popover presentation. /// /// For iOS 15 compatibility, it includes a workaround for the missing `@StateObject` property wrapper, which uses an internal `Root` to manage the anchor view. - public struct FloatingPopover: ViewModifier where Item: Identifiable, PopoverContent: View { +@available(iOS 15.0, *) +public struct FloatingPopover: ViewModifier where Item: Hashable, PopoverContent: View { // Workaround for missing @StateObject in iOS 15. private struct Parent { var anchorView = UIView() @@ -34,6 +35,7 @@ import SwiftUI /// - Parameter context: The context of the UIViewRepresentable. /// - Returns: A UIView with a background color of white. func makeUIView(context _: Self.Context) -> Self.UIViewType { +// context.coordinator.anchorView uiView.backgroundColor = UIColor.white return uiView } @@ -44,6 +46,7 @@ import SwiftUI /// - uiView: The UIView instance to be updated. /// - context: The context of the UIViewRepresentable. func updateUIView(_ uiView: Self.UIViewType, context _: Self.Context) { + uiView.backgroundColor = UIColor.white self.uiView = uiView } } @@ -92,7 +95,10 @@ import SwiftUI /// - controller: The presentation controller. /// - traitCollection: The trait collection of the interface environment. /// - Returns: The modal presentation style, which is `.popover` for this implementation. - func adaptivePresentationStyle(for _: UIPresentationController, traitCollection _: UITraitCollection) -> UIModalPresentationStyle { + func adaptivePresentationStyle( + for _: UIPresentationController, + traitCollection _: UITraitCollection + ) -> UIModalPresentationStyle { return .popover } @@ -145,8 +151,8 @@ import SwiftUI let bool: Bool? = isPresented.wrappedValue return bool }, set: { - guard let _ = $0 else { isPresented.wrappedValue = false; return } - isPresented.wrappedValue = true + guard let bool = $0 else { isPresented.wrappedValue = false; return } + isPresented.wrappedValue = bool }) contentOptional = contentBlock } @@ -165,18 +171,18 @@ import SwiftUI presentPopover(with: item) } } - return Button(action: { - withAnimation(.bouncy) { - if let item = item { - withAnimation(.bouncy) { - presentPopover(with: item) - } - } - } - }, label: { - content - .background(InternalAnchorView(uiView: perent.anchorView).background(Color.black)) - }) +// return Button(action: { +// if let item = item { +// withAnimation(.bouncy) { +// presentPopover(with: item) +// } +// } +// +// }, label: { + return content + + .background(InternalAnchorView(uiView: perent.anchorView).background(.bar, in: .containerRelative)) +// }) } // Functions @@ -254,8 +260,8 @@ import SwiftUI - Note: On iOS 16.4 and later, the popover is presented using the native popover modifier with a fixed size and `.popover` adaptation. On earlier versions, a custom `FloatingPopover` modifier is used. */ - @ViewBuilder - func floatingPopover( + @ViewBuilder @available(iOS 15.0, *) + func floatingPopover( item: Binding, @ViewBuilder content: @escaping (Item) -> some View ) -> some View { @@ -281,20 +287,12 @@ import SwiftUI - Note: On iOS 16.4 and later, the popover is presented using the native popover modifier with a fixed size and `.popover` adaptation. On earlier versions, a custom `FloatingPopover` modifier is used. */ - @ViewBuilder + @ViewBuilder @available(iOS 15.0, *) func floatingPopover( isPresented: Binding, @ViewBuilder content: @escaping () -> some View ) -> some View { - if #available(iOS 16.4, *) { - popover(isPresented: isPresented) { - content() - .presentationCompactAdaptation(.popover) - .fixedSize() - } - } else { - modifier(FloatingPopover(isPresented: isPresented, contentBlock: content)) - } + modifier(FloatingPopover(isPresented: isPresented, contentBlock: content)) } } #endif diff --git a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift index da4bb58..3baa17c 100644 --- a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift +++ b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -24,19 +24,28 @@ import SwiftUI /// - Note: The `getSuggestions` function is called asynchronously and updates the `suggestions` property. /// /// - SeeAlso: ``DadataSuggestions`` -public struct IIDadataSuggestsPopover: ViewModifier { +@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 = (String) -> Void // Properties + /// The action to perform when a suggestion is selected. + public var onSuggestionSelected: OnSuggestionSelected + + /// The TextField input text. @Binding var text: String - @Binding var suggestions: [T]? - let onSuggestionSelected: OnSuggestionSelected + /// The list of suggestions. + @Binding var suggestions: [S]? - @StateObject private var dadata: DadataSuggestions + /// The DadataSuggestions instance for fetching suggestions. + @ObservedObject private var dadata: DadataSuggestions + /// A binding to a boolean value that indicates whether the popover is presented. @Binding private var isPopoverPresented: Bool + /// The error message. + @State private var error: String? = nil // Lifecycle @@ -53,11 +62,11 @@ public struct IIDadataSuggestsPopover: ViewModifier { public init( input text: Binding, dadata: DadataSuggestions, - suggestions: Binding<[T]?>, + suggestions: Binding<[S]?>, isPresented: Binding, onSuggestionSelected: @escaping (String) -> Void - ) { - _dadata = StateObject(wrappedValue: dadata) + ) where S: Suggestion { + _dadata = ObservedObject(wrappedValue: dadata) _text = text _suggestions = suggestions _isPopoverPresented = isPresented @@ -76,11 +85,11 @@ public struct IIDadataSuggestsPopover: ViewModifier { public init( apiKey: String, input text: Binding, - suggestions: Binding<[T]?>, + suggestions: Binding<[S]?>, isPresented: Binding, onSuggestionSelected: @escaping (String) -> Void - ) { - _dadata = StateObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) + ) where S: Suggestion { + _dadata = ObservedObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) _text = text _suggestions = suggestions _isPopoverPresented = isPresented @@ -91,93 +100,159 @@ public struct IIDadataSuggestsPopover: ViewModifier { public func body(content: Content) -> some View { content - .onChange(of: text) { _ in - guard !text.isEmpty else { - suggestions = nil - return - } - Task(priority: .userInitiated) { - await getSuggestions() - isPopoverPresented = suggestions?.isEmpty == false - } + .onAppear { + isPopoverPresented = !text.isEmpty } - .floatingPopover(isPresented: $isPopoverPresented) { - if let suggestions = suggestions?.compactMap(\.value) { - SuggestionsPopover( - with: suggestions, - onSelect: { suggestion in - text = suggestion - onSuggestionSelected(suggestion) - if suggestions.count == 1 { - isPopoverPresented = false - } - } - ) + + .onChange(of: text, perform: getSuggestions(for:)) + .floatingPopover(isPresented: $isPopoverPresented, content: popoverContent) + } + + @ViewBuilder + func popoverContent() -> some View { + if let suggestions = suggestions?.compactMap(\.value) { + SuggestionsPopover( + with: suggestions, + onSelect: { suggestion in + text = suggestion + onSuggestionSelected(suggestion) + if suggestions.count == 1 { + isPopoverPresented = false + } + } + ) + } else { + VStack { + ProgressView().progressViewStyle(.circular) + if let error = error { + Text("Error fetching suggestions: " + error) + } else { + Text("Fetching suggestions...") } } + .padding() + .foregroundColor(.secondary) + .font(.caption) + } } // 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 + isPopoverPresented = !input.isEmpty + guard !input.isEmpty else { + suggestions = nil + error = "Error fetching suggestions: \(IIDadataError.invalidInput)" + return + } + + do { + isPopoverPresented = true + switch S.self { + case is AddressSuggestion.Type: + if let addressSuggestions = try await getAddressSuggestions(for: input) as? [S] { + suggestions = addressSuggestions + } else { + throw IIDadataError.noSuggestions + } + case is FioSuggestion.Type: + if let fioSuggestions = try await getFioSuggestions() as? [S] { + suggestions = fioSuggestions + } else { + throw IIDadataError.noSuggestions + } + default: + 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. - /// If the text input is empty, it sets `suggestions` to `nil`. - func getSuggestions() async { + /// 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 - return + throw IIDadataError.invalidInput } do { - switch suggestions?.first?.type { - case .address: - await getAddressSuggestions(address: text) - case .fio: - await getFioSuggestions(fio: text) - case nil: - throw IIDadataError.noSuggestions - } + let suggestions = try await dadata.suggestFio( + text, + count: 10, + gender: .male, + parts: [.surname, .name, .patronymic] + ) + 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 { - debugPrint("Error fetching suggestions: \(error)") + throw IIDadataError.unknown(error.localizedDescription) } } +} - /// Fetches address suggestions based on the current address input. +@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. - @MainActor - func getAddressSuggestions(address: String) async { - guard !address.isEmpty else { return } + /// - 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] { do { - suggestions = try await dadata.suggestAddress( - address, + guard !text.isEmpty else { + suggestions = nil + throw IIDadataError.invalidInput + } + + guard let suggestions = try await dadata.suggestAddress( + text, queryType: .address, resultsCount: 10, language: .ru - ).suggestions?.compactMap { $0 as? T } - } catch { - print("Error fetching address suggestions: \(error)") - } - } + ).suggestions + else { + throw IIDadataError.noSuggestions + } - /// 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(fio: String) async { - guard !fio.isEmpty else { return } - do { - suggestions = try await dadata.suggestFio( - fio, - count: 10, - gender: .male, - parts: [.surname, .name, .patronymic] - ).compactMap { $0 as? T } + 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 { - print("Error fetching FIO suggestions: \(error)") + throw IIDadataError.unknown(error.localizedDescription) } } } @@ -191,12 +266,17 @@ public struct IIDadataSuggestsPopover: ViewModifier { /// - Parameters: /// - suggestions: An array of `Suggestion.Value` to be displayed. /// - onSelect: A closure to handle the selection of a suggestion. +/// +/// - Returns: A `View` that displays a list of suggestions. +@available(iOS 15.0, *) public struct SuggestionsPopover: View { // Properties var suggestions: [Suggestion.Value] let onSelect: (String) -> Void + let maxWidth: CGFloat = UIScreen.main.bounds.width - 44 + // Lifecycle /// Creates a new `SuggestionsPopover`. @@ -217,28 +297,33 @@ public struct SuggestionsPopover: View { public var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 4) { - ForEach(suggestions, id: \.self) { suggestion in - Button(action: { - onSelect(suggestion) - }) { - Text(suggestion) - .font( - .system( - size: 12, - weight: .regular, - design: .rounded - ) - ) - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.automatic) - .accentColor(.accentColor) - } + ForEach(suggestions, id: \.self, content: SelectedSuggestionView(_:)) } .padding(8) + .background(.bar) + .animation(.interactiveSpring, value: suggestions) + } + } + + @ViewBuilder + func SelectedSuggestionView(_ suggestion: Suggestion.Value) -> some View { + Button(action: { + onSelect(suggestion) + }) { + Text(suggestion) + .font( + .system(.subheadline, design: .rounded) + ) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(.secondary) + .frame(minWidth: 166, maxWidth: maxWidth, alignment: .leading) + } + .buttonStyle(.borderless) + .accentColor(.accentColor) + .frame(maxWidth: maxWidth) + .safeAreaInset(edge: .bottom) { + Divider() } } } @@ -257,7 +342,7 @@ public extension View { /// - onSuggestionSelected: A closure to handle the selection of a suggestion. /// /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. - @ViewBuilder @available(iOS 14.0, *) + @ViewBuilder @available(iOS 15.0, *) func iidadataSuggestions( apiKey: String, input text: Binding, @@ -288,6 +373,7 @@ public extension View { /// - onSuggestionSelected: A closure to handle the selection of a suggestion. /// /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. + @available(iOS 15.0, *) @ViewBuilder func iidadataSuggestions( dadata: DadataSuggestions, input text: Binding, @@ -307,71 +393,160 @@ public extension View { } } -// #if DEBUG +// MARK: - View Extension -// MARK: - ContentView +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. + @ViewBuilder @available(iOS 15.0, *) + func iidadataSuggestions( + apiKey: String, + input text: Binding, + suggestions: Binding<[FioSuggestion]?>, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) -> some View { + modifier( + IIDadataSuggestsPopover( + apiKey: apiKey, + input: text, + suggestions: suggestions, + isPresented: isPresented, + onSuggestionSelected: onSuggestionSelected + ) + ) + } -/// 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 -// -// -// -// // 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() -// } -// } -// #endif + /// 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. + /// - isPresented: A binding to a boolean value that indicates whether the popover is presented. + /// - onSuggestionSelected: A closure to handle the selection of a suggestion. + /// + /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. + @available(iOS 15.0, *) + @ViewBuilder func iidadataSuggestions( + dadata: DadataSuggestions, + input text: Binding, + suggestions: Binding<[AddressSuggestion]?>, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) -> some View { + modifier( + IIDadataSuggestsPopover( + input: text, + dadata: dadata, + suggestions: suggestions, + isPresented: isPresented, + onSuggestionSelected: onSuggestionSelected + ) + ) + } +} + +#if DEBUG + + // MARK: - ContentView + + /// A sample view demonstrating the usage of `IIDadataSuggestionsView`. + @available(iOS 15.0, *) + struct ContentView: View { + // Properties + + // MARK: - IIDadataViewModel + + /// The view model for managing address and FIO suggestions. + + @State var address = "Грибал" + @State var fio = "Михайл" + @State var addressSuggestions: [AddressSuggestion]? = nil + @State var fioSuggestions: [FioSuggestion]? = nil + @State var error: String? + @State var isFioSuggestionsPresented = true + @State var isAddressSuggestionsPresented = true + + @StateObject private var 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 = StateObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) + } + + // Content + + @ViewBuilder + func IIDadataSuggestionsView( + inputText: Binding, + suggestions: Binding<[T]?>, + placeholder: String, + isPresented: Binding, + onSuggestionSelected: @escaping (String) -> Void + ) -> some View { + TextField( + placeholder, + text: inputText + ) + .font(.body) + .textFieldStyle(.roundedBorder) + .tint(.blue) + .iidadataSuggestions( + dadata: dadata, + input: inputText, + suggestions: suggestions, + isPresented: isPresented, + onSuggestionSelected: onSuggestionSelected + ) + .padding(8) + } + + var body: some View { + VStack(spacing: 16) { + IIDadataSuggestionsView( + inputText: $address, + suggestions: $addressSuggestions, + placeholder: "Enter address", + isPresented: .constant(true), + onSuggestionSelected: { + fio = $0 + } + ) + IIDadataSuggestionsView( + inputText: $fio, + suggestions: $fioSuggestions, + placeholder: "Enter Full Name", + isPresented: .constant(true), + onSuggestionSelected: { + fio = $0 + } + ) + } + .padding() + } + } + + @available(iOS 15.0, *) + struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } + } +#endif diff --git a/Package.swift b/Package.swift index bdb12a7..57e1378 100644 --- a/Package.swift +++ b/Package.swift @@ -7,7 +7,10 @@ let package = Package( name: "IIDadata", defaultLocalization: "ru", platforms: [ - .iOS(.v14), .macOS(.v11), .tvOS(.v14), .watchOS(.v7), + .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. @@ -17,7 +20,11 @@ let package = Package( ), .library( name: "IIDadataUI", - targets: ["IIDadataUI"] + targets: ["IIDadata", "IIDadataUI"] + ), + .executable( + name: "IIDadataExample", + targets: ["IIDadata", "IIDadataUI", "Example"] ), ], dependencies: [ @@ -27,7 +34,11 @@ let package = Package( 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: [], From ea54444890ac8975f0ab3efb1ad6f21f012eb217 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov <39944472+NSFuntik@users.noreply.github.com> Date: Mon, 12 Aug 2024 05:22:16 +0300 Subject: [PATCH 11/16] Release version --- .../xcschemes/IIDadataUI.xcscheme | 7 + IIDadata/Example/ContentView.swift | 2 +- IIDadata/Sources/DadataSuggestions.swift | 10 +- .../Suggestions Popover/FloatingPopover.swift | 298 ------------------ .../IIDadataSuggestionsPopover.swift | 210 +++++------- Package.swift | 6 +- 6 files changed, 91 insertions(+), 442 deletions(-) delete mode 100644 IIDadata/Suggestions Popover/FloatingPopover.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme index 1c8552b..8547cde 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme @@ -54,6 +54,13 @@ debugDocumentVersioning = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> + + + + : ViewModifier where Item: Hashable, PopoverContent: View { - // Workaround for missing @StateObject in iOS 15. - private struct Parent { - var anchorView = UIView() - } - - /// A private struct that represents an internal anchor view. - private struct InternalAnchorView: UIViewRepresentable { - typealias UIViewType = UIView - - // Properties - - @State var uiView: UIView - - // Functions - - /// Creates and returns the view for the anchor. - /// - /// - Parameter context: The context of the UIViewRepresentable. - /// - Returns: A UIView with a background color of white. - func makeUIView(context _: Self.Context) -> Self.UIViewType { -// context.coordinator.anchorView - uiView.backgroundColor = UIColor.white - return uiView - } - - /// Updates the anchor view with the latest state. - /// - /// - Parameters: - /// - uiView: The UIView instance to be updated. - /// - context: The context of the UIViewRepresentable. - func updateUIView(_ uiView: Self.UIViewType, context _: Self.Context) { - uiView.backgroundColor = UIColor.white - self.uiView = uiView - } - } - - // Nested Types - - /// A nested class that represents a content view controller for the popover. - private class ContentViewController: UIHostingController, UIPopoverPresentationControllerDelegate where V: View { - // Properties - - @Binding var isPresented: Bool - var size: CGSize = .init(width: 300, height: 400) - - // Lifecycle - - /// Initializes the view controller with a root view and binding to `isPresented`. - /// - /// - Parameters: - /// - rootView: The root view to be hosted. - /// - isPresented: A binding that indicates whether the popover is presented. - init(rootView: V, isPresented: Binding) { - _isPresented = isPresented - super.init(rootView: rootView) - } - - @available(*, unavailable) - @MainActor @objc dynamic required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // Overridden Functions - - /// Called after the controller's view is loaded into memory. Sets the view's background color and preferred content size. - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .black - size = sizeThatFits(in: UIView.layoutFittingExpandedSize) - preferredContentSize = size - } - - // Functions - - /// Specifies the presentation style for the view controller. - /// - /// - Parameters: - /// - controller: The presentation controller. - /// - traitCollection: The trait collection of the interface environment. - /// - Returns: The modal presentation style, which is `.popover` for this implementation. - func adaptivePresentationStyle( - for _: UIPresentationController, - traitCollection _: UITraitCollection - ) -> UIModalPresentationStyle { - return .popover - } - - /// Notifies that the popover presentation controller was dismissed. - /// - /// - Parameter controller: The presentation controller that was dismissed. - func presentationControllerDidDismiss(_: UIPresentationController) { - $isPresented.animation(.bouncy).wrappedValue = false - } - } - - // Properties - - /// A binding that controls whether the popover should be presented. - @Binding var item: Item? - /// A closure returning the content of the popover. - @State var contentBlock: ((Item) -> PopoverContent)? - /// A closure returning the content of the popover. - @State var contentOptional: (() -> PopoverContent)? - - /// A private property that represents the anchor view. - @State private var perent = Parent() - - // Lifecycle - - /// Initializes the FloatingPopover with a binding to an item and a content block. - /// - /// - Parameters: - /// - item: A binding to the item that controls the presentation of the popover. - /// - contentBlock: A closure that returns the content of the popover based on the item. - init( - item: Binding, - @ViewBuilder contentBlock: @escaping (Item) -> PopoverContent - ) { - _item = item - self.contentBlock = contentBlock - contentOptional = nil - } - - /// Initializes the FloatingPopover with a boolean binding and a content block. - /// - /// - Parameters: - /// - isPresented: A binding that indicates whether the popover should be presented. - /// - contentBlock: A closure that returns the content of the popover. - init( - isPresented: Binding, - @ViewBuilder contentBlock: @escaping () -> PopoverContent - ) where Item == Bool { - _item = .init(get: { - let bool: Bool? = isPresented.wrappedValue - return bool - }, set: { - guard let bool = $0 else { isPresented.wrappedValue = false; return } - isPresented.wrappedValue = bool - }) - contentOptional = contentBlock - } - - // Content - - /// Modifies the content view by adding popover presentation logic. - /// - /// If `isPresented` is `true`, this modifier presents the popover containing the provided content. - /// - /// - Parameter content: The content view to be modified. - /// - Returns: A view with popover presentation capabilities. - public func body(content: Content) -> some View { - if let item = item { - withAnimation(.bouncy) { - presentPopover(with: item) - } - } -// return Button(action: { -// if let item = item { -// withAnimation(.bouncy) { -// presentPopover(with: item) -// } -// } -// -// }, label: { - return content - - .background(InternalAnchorView(uiView: perent.anchorView).background(.bar, in: .containerRelative)) -// }) - } - - // Functions - - /// Presents the popover with the provided item. - /// - /// - Parameter item: The item that triggers the popover presentation. - /// - Returns: The presented popover view controller. - /// - Note: This function is called by the `body` modifier and should not be called directly. - private func presentPopover(with item: Item) { - var contentController: ContentViewController - if let contentBlock = contentBlock { - contentController = ContentViewController( - rootView: contentBlock(item), - isPresented: .init(get: { - $item.wrappedValue != nil - }, set: { newState in - self.item = newState ? $item.wrappedValue : nil - }) - ) - } else { - guard let contentOptional = contentOptional else { return } - contentController = ContentViewController( - rootView: contentOptional(), - isPresented: .init(get: { - $item.wrappedValue != nil - }, set: { newState in - self.item = newState ? $item.wrappedValue : nil - }) - ) - } - contentController.modalPresentationStyle = .popover - let view = perent.anchorView - view.backgroundColor = .black - guard let popover = contentController.popoverPresentationController else { return } - popover.sourceView = view - popover.sourceRect = view.bounds - popover.delegate = contentController - popover.backgroundColor = UIColor.black - guard let sourceVC = view.closestVC() else { return } - if let presentedVC = sourceVC.presentedViewController { - presentedVC.dismiss(animated: true) { - sourceVC.present(contentController, animated: true) - } - } else { - sourceVC.present(contentController, animated: true) - } - } - } - - extension Bool: @retroactive Identifiable { public var id: Bool { self } } - - public extension UIView { - func closestVC() -> UIViewController? { - var responder: UIResponder? = self - while responder != nil { - if let vc = responder as? UIViewController { - return vc - } - responder = responder?.next - } - return nil - } - } - - public extension View { - /** - Adds a floating popover to the current view that is presented when the given identifiable item is non-nil. - - - Parameters: - - item: A binding to an optional identifiable item that controls the presentation of the popover. When the item becomes non-nil, the popover is presented. - - content: A view builder that creates the content of the popover using the provided item. - - - Returns: A view that conditionally presents a popover when the item is non-nil. On iOS 16.4 and later, it uses the native `popover` modifier with a fixed size. For earlier OS, it applies a custom `FloatingPopover` modifier. - - - Note: On iOS 16.4 and later, the popover is presented using the native popover modifier with a fixed size and `.popover` adaptation. On earlier versions, a custom `FloatingPopover` modifier is used. - */ - @ViewBuilder @available(iOS 15.0, *) - func floatingPopover( - item: Binding, - @ViewBuilder content: @escaping (Item) -> some View - ) -> some View { -// if #available(iOS 16.4, *) { -// popover(item: item) { item in -// content(item) -// .presentationCompactAdaptation(.popover) -// .fixedSize() -// } -// } else { - modifier(FloatingPopover(item: item, contentBlock: content)) -// } - } - - /** - Adds a floating popover to the current view that is presented when the given boolean binding is true. - - - Parameters: - - isPresented: A binding to a boolean value that controls the presentation of the popover. When the value becomes true, the popover is presented. - - content: A view builder that creates the content of the popover. - - - Returns: A view that conditionally presents a popover when the boolean value is true. On iOS 16.4 and later, it uses the native `popover` modifier with a fixed size. For earlier OS, it applies a custom `FloatingPopover` modifier. - - - Note: On iOS 16.4 and later, the popover is presented using the native popover modifier with a fixed size and `.popover` adaptation. On earlier versions, a custom `FloatingPopover` modifier is used. - */ - @ViewBuilder @available(iOS 15.0, *) - func floatingPopover( - isPresented: Binding, - @ViewBuilder content: @escaping () -> some View - ) -> some View { - modifier(FloatingPopover(isPresented: isPresented, contentBlock: content)) - } - } -#endif diff --git a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift index 3baa17c..4080610 100644 --- a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift +++ b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -28,7 +28,7 @@ import SwiftUI 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 = (String) -> Void + public typealias OnSuggestionSelected = (S) -> Void // Properties @@ -40,6 +40,8 @@ public struct IIDadataSuggestsPopover: ViewModifier { /// The list of suggestions. @Binding var suggestions: [S]? + let viewID = UUID().uuidString + /// The DadataSuggestions instance for fetching suggestions. @ObservedObject private var dadata: DadataSuggestions /// A binding to a boolean value that indicates whether the popover is presented. @@ -64,7 +66,7 @@ public struct IIDadataSuggestsPopover: ViewModifier { dadata: DadataSuggestions, suggestions: Binding<[S]?>, isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void + onSuggestionSelected: @escaping (S) -> Void ) where S: Suggestion { _dadata = ObservedObject(wrappedValue: dadata) _text = text @@ -87,7 +89,7 @@ public struct IIDadataSuggestsPopover: ViewModifier { input text: Binding, suggestions: Binding<[S]?>, isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void + onSuggestionSelected: @escaping (S) -> Void ) where S: Suggestion { _dadata = ObservedObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) _text = text @@ -98,29 +100,46 @@ public struct IIDadataSuggestsPopover: ViewModifier { // Content + @ViewBuilder public func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + main(content).popover(isPresented: $isPopoverPresented) { + content + .presentationCompactAdaptation(.popover) + .fixedSize() + } + } else { + main(content).popover( + isPresented: $isPopoverPresented, + arrowEdge: .bottom, + content: popoverContent + ) + } + } + + @ViewBuilder + public func main(_ content: Content) -> some View { content + .onAppear { isPopoverPresented = !text.isEmpty } - .onChange(of: text, perform: getSuggestions(for:)) - .floatingPopover(isPresented: $isPopoverPresented, content: popoverContent) } @ViewBuilder func popoverContent() -> some View { - if let suggestions = suggestions?.compactMap(\.value) { + if let suggestions = suggestions?.compactMap(\.self) { SuggestionsPopover( with: suggestions, onSelect: { suggestion in - text = suggestion + text = suggestion.value onSuggestionSelected(suggestion) if suggestions.count == 1 { isPopoverPresented = false } } - ) + ).fixedSize() } else { VStack { ProgressView().progressViewStyle(.circular) @@ -201,8 +220,7 @@ extension IIDadataSuggestsPopover { let suggestions = try await dadata.suggestFio( text, count: 10, - gender: .male, - parts: [.surname, .name, .patronymic] + gender: .male ) dump(suggestions, name: "FIO Suggestion for: \(text)") return suggestions as [FioSuggestion] @@ -210,6 +228,7 @@ extension IIDadataSuggestsPopover { self.error = "Error fetching FIO suggestions: \(error)" throw error } catch { + self.error = "Error fetching FIO suggestions: \(error)" throw IIDadataError.unknown(error.localizedDescription) } } @@ -229,12 +248,11 @@ extension IIDadataSuggestsPopover { /// - 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 !text.isEmpty else { - suggestions = nil - throw IIDadataError.invalidInput - } - guard let suggestions = try await dadata.suggestAddress( text, queryType: .address, @@ -252,6 +270,7 @@ extension IIDadataSuggestsPopover { self.error = "Error fetching Address Suggestions: \(error)" throw error } catch { + self.error = "Error fetching Address Suggestions: \(error)" throw IIDadataError.unknown(error.localizedDescription) } } @@ -269,11 +288,11 @@ extension IIDadataSuggestsPopover { /// /// - Returns: A `View` that displays a list of suggestions. @available(iOS 15.0, *) -public struct SuggestionsPopover: View { +public struct SuggestionsPopover: View { // Properties - var suggestions: [Suggestion.Value] - let onSelect: (String) -> Void + var suggestions: [S] + let onSelect: (S) -> Void let maxWidth: CGFloat = UIScreen.main.bounds.width - 44 @@ -285,8 +304,8 @@ public struct SuggestionsPopover: View { /// - suggestions: An array of `Suggestion.Value` to be displayed. /// - onSelect: A closure that gets executed when a suggestion is selected. init( - with suggestions: [Suggestion.Value], - onSelect: @escaping (String) -> Void + with suggestions: [S], + onSelect: @escaping (S) -> Void ) { self.suggestions = suggestions self.onSelect = onSelect @@ -300,17 +319,17 @@ public struct SuggestionsPopover: View { ForEach(suggestions, id: \.self, content: SelectedSuggestionView(_:)) } .padding(8) - .background(.bar) + .background(.regularMaterial) .animation(.interactiveSpring, value: suggestions) } } @ViewBuilder - func SelectedSuggestionView(_ suggestion: Suggestion.Value) -> some View { + func SelectedSuggestionView(_ suggestion: S) -> some View { Button(action: { onSelect(suggestion) }) { - Text(suggestion) + Text(suggestion.value) .font( .system(.subheadline, design: .rounded) ) @@ -343,77 +362,12 @@ public extension View { /// /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. @ViewBuilder @available(iOS 15.0, *) - func iidadataSuggestions( + func iidadataSuggestions( apiKey: String, input text: Binding, - suggestions: Binding<[T]?>, - isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void - ) -> some View { - modifier( - IIDadataSuggestsPopover( - apiKey: apiKey, - input: text, - suggestions: suggestions, - isPresented: isPresented, - onSuggestionSelected: onSuggestionSelected - ) - ) - } - - /// 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. - /// - isPresented: A binding to a boolean value that indicates whether the popover is presented. - /// - onSuggestionSelected: A closure to handle the selection of a suggestion. - /// - /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. - @available(iOS 15.0, *) - @ViewBuilder func iidadataSuggestions( - dadata: DadataSuggestions, - input text: Binding, - suggestions: Binding<[T]?>, - isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void - ) -> some View { - modifier( - IIDadataSuggestsPopover( - input: text, - dadata: dadata, - suggestions: suggestions, - isPresented: isPresented, - onSuggestionSelected: onSuggestionSelected - ) - ) - } -} - -// 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. - @ViewBuilder @available(iOS 15.0, *) - func iidadataSuggestions( - apiKey: String, - input text: Binding, - suggestions: Binding<[FioSuggestion]?>, + suggestions: Binding<[S]?>, isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void + onSuggestionSelected: @escaping (S) -> Void ) -> some View { modifier( IIDadataSuggestsPopover( @@ -439,12 +393,12 @@ public extension View { /// /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. @available(iOS 15.0, *) - @ViewBuilder func iidadataSuggestions( + @ViewBuilder func iidadataSuggestions( dadata: DadataSuggestions, input text: Binding, - suggestions: Binding<[AddressSuggestion]?>, + suggestions: Binding<[S]?>, isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void + onSuggestionSelected: @escaping (S) -> Void ) -> some View { modifier( IIDadataSuggestsPopover( @@ -479,7 +433,7 @@ public extension View { @State var isFioSuggestionsPresented = true @State var isAddressSuggestionsPresented = true - @StateObject private var dadata: DadataSuggestions + @StateObject private var dadata: DadataSuggestions = DadataSuggestions.sharedInstance.unsafelyUnwrapped // Lifecycle @@ -488,55 +442,41 @@ public extension View { /// The API key is fetched from the environment variables. init() { let apiKey = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" - _dadata = StateObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) + DadataSuggestions.sharedInstance = DadataSuggestions(apiKey: apiKey) } // Content - @ViewBuilder - func IIDadataSuggestionsView( - inputText: Binding, - suggestions: Binding<[T]?>, - placeholder: String, - isPresented: Binding, - onSuggestionSelected: @escaping (String) -> Void - ) -> some View { - TextField( - placeholder, - text: inputText - ) - .font(.body) - .textFieldStyle(.roundedBorder) - .tint(.blue) - .iidadataSuggestions( - dadata: dadata, - input: inputText, - suggestions: suggestions, - isPresented: isPresented, - onSuggestionSelected: onSuggestionSelected - ) - .padding(8) - } - var body: some View { VStack(spacing: 16) { - IIDadataSuggestionsView( - inputText: $address, + TextField( + "Enter address", + text: $address + ) + .font(.body) + .textFieldStyle(.roundedBorder) + .tint(.blue).clipped() + .iidadataSuggestions( + dadata: dadata, + input: $address, suggestions: $addressSuggestions, - placeholder: "Enter address", - isPresented: .constant(true), - onSuggestionSelected: { - fio = $0 - } + isPresented: $isAddressSuggestionsPresented, + onSuggestionSelected: { address = $0.value } ) - IIDadataSuggestionsView( - inputText: $fio, + + TextField( + "Enter Full Name", + text: $fio + ) + .font(.body) + .textFieldStyle(.roundedBorder) + .tint(.blue) + .iidadataSuggestions( + dadata: dadata, + input: $fio, suggestions: $fioSuggestions, - placeholder: "Enter Full Name", - isPresented: .constant(true), - onSuggestionSelected: { - fio = $0 - } + isPresented: $isFioSuggestionsPresented, + onSuggestionSelected: { fio = $0.value } ) } .padding() diff --git a/Package.swift b/Package.swift index 57e1378..d348e13 100644 --- a/Package.swift +++ b/Package.swift @@ -21,11 +21,7 @@ let package = Package( .library( name: "IIDadataUI", targets: ["IIDadata", "IIDadataUI"] - ), - .executable( - name: "IIDadataExample", - targets: ["IIDadata", "IIDadataUI", "Example"] - ), + ) ], dependencies: [ // Dependencies declare other packages that this package depends on. From 35893cfc055c0a3f2d2a6f24421c4e6d5173c04b Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov <39944472+NSFuntik@users.noreply.github.com> Date: Tue, 13 Aug 2024 01:03:26 +0300 Subject: [PATCH 12/16] Modularity improved UI refactored --- IIDadata/Sources/DadataSuggestions.swift | 744 +++++++-------- .../DadataSuggestionsEnvironmentKey.swift | 88 ++ .../IIDadataSuggestionsPopover.swift | 851 +++++++++--------- .../SuggestionsPopover.swift | 161 ++++ 4 files changed, 1024 insertions(+), 820 deletions(-) create mode 100644 IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift create mode 100644 IIDadata/Suggestions Popover/SuggestionsPopover.swift diff --git a/IIDadata/Sources/DadataSuggestions.swift b/IIDadata/Sources/DadataSuggestions.swift index c153a2b..9f9db63 100644 --- a/IIDadata/Sources/DadataSuggestions.swift +++ b/IIDadata/Sources/DadataSuggestions.swift @@ -3,376 +3,376 @@ import Foundation @available(iOS 14.0, *) public actor DadataSuggestions: ObservableObject { - // Static Properties - - public 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) - } + // 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/IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift b/IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift new file mode 100644 index 0000000..a44b92b --- /dev/null +++ b/IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift @@ -0,0 +1,88 @@ +// +// 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( + apiKey: String, + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) throws -> some View { + try modifier( + IIDadataSuggestsPopover( + apiKey: apiKey, + input: text, + suggestions: suggestions, + textfieldHeight: textfieldHeight, + onSuggestionSelected: onSuggestionSelected + ) + ).environment(\.dadataSuggestions, 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( + 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/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift index 4080610..6e93ddb 100644 --- a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift +++ b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -7,6 +7,8 @@ import IIDadata import SwiftUI + + // MARK: - IIDadataSuggestsPopover /// A view modifier that provides a text field with suggestions as the user types. @@ -26,467 +28,420 @@ import SwiftUI /// - 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 - - // 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 viewID = UUID().uuidString - - /// The DadataSuggestions instance for fetching suggestions. - @ObservedObject private var dadata: DadataSuggestions - /// A binding to a boolean value that indicates whether the popover is presented. - @Binding private var isPopoverPresented: Bool - /// The error message. - @State private var error: String? = nil - - // 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( - input text: Binding, - dadata: DadataSuggestions, - suggestions: Binding<[S]?>, - isPresented: Binding, - onSuggestionSelected: @escaping (S) -> Void - ) where S: Suggestion { - _dadata = ObservedObject(wrappedValue: dadata) - _text = text - _suggestions = suggestions - _isPopoverPresented = isPresented - self.onSuggestionSelected = onSuggestionSelected - } - - /// 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( - apiKey: String, - input text: Binding, - suggestions: Binding<[S]?>, - isPresented: Binding, - onSuggestionSelected: @escaping (S) -> Void - ) where S: Suggestion { - _dadata = ObservedObject(wrappedValue: DadataSuggestions(apiKey: apiKey)) - _text = text - _suggestions = suggestions - _isPopoverPresented = isPresented - self.onSuggestionSelected = onSuggestionSelected - } - - // Content - - @ViewBuilder - public func body(content: Content) -> some View { - if #available(iOS 16.4, *) { - main(content).popover(isPresented: $isPopoverPresented) { - content - .presentationCompactAdaptation(.popover) - .fixedSize() - } - } else { - main(content).popover( - isPresented: $isPopoverPresented, - arrowEdge: .bottom, - content: popoverContent - ) - } - } - - @ViewBuilder - public func main(_ content: Content) -> some View { - content - - .onAppear { - isPopoverPresented = !text.isEmpty - } - .onChange(of: text, perform: getSuggestions(for:)) - } - - @ViewBuilder - func popoverContent() -> some View { - if let suggestions = suggestions?.compactMap(\.self) { - SuggestionsPopover( - with: suggestions, - onSelect: { suggestion in - text = suggestion.value - onSuggestionSelected(suggestion) - if suggestions.count == 1 { - isPopoverPresented = false - } - } - ).fixedSize() - } else { - VStack { - ProgressView().progressViewStyle(.circular) - if let error = error { - Text("Error fetching suggestions: " + error) - } else { - Text("Fetching suggestions...") - } - } - .padding() - .foregroundColor(.secondary) - .font(.caption) - } - } - - // 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 - isPopoverPresented = !input.isEmpty - guard !input.isEmpty else { - suggestions = nil - error = "Error fetching suggestions: \(IIDadataError.invalidInput)" - return - } - - do { - isPopoverPresented = true - switch S.self { - case is AddressSuggestion.Type: - if let addressSuggestions = try await getAddressSuggestions(for: input) as? [S] { - suggestions = addressSuggestions - } else { - throw IIDadataError.noSuggestions - } - case is FioSuggestion.Type: - if let fioSuggestions = try await getFioSuggestions() as? [S] { - suggestions = fioSuggestions - } else { - throw IIDadataError.noSuggestions - } - default: - throw IIDadataError.unknown(String(describing: S.self)) - } - } catch { - self.error = "Error fetching suggestions: \(error)" - suggestions = nil - } - } + /// 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 + + private let viewID = UUID().uuidString + + /// A binding to a boolean value that indicates whether the popover is presented. + @State private var isPopoverPresented: Bool = false + /// 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( + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) where S: Suggestion { + _text = text + _suggestions = suggestions + self.textfieldHeight = textfieldHeight + self.onSuggestionSelected = onSuggestionSelected + guard let dadataSuggestions = try? DadataSuggestions.shared() else { + 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( + apiKey _: String, + input text: Binding, + suggestions: Binding<[S]?>, + textfieldHeight: CGFloat, + onSuggestionSelected: @escaping (S) -> Void + ) throws where S: Suggestion { +// dadata = try + _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) + .padding(.bottom, isPopoverPresented && !(suggestions?.isEmpty ?? true) ? idealHeight : 0) + .background { + Color.clear + .matchedGeometryEffect(id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: true) + .offset(x: 0, y: textfieldHeight / 2) + .frame(height: idealHeight) + } + .overlay(alignment: .top) { + popover().compositingGroup().matchedGeometryEffect(id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: false) + .fixedSize() + .layoutPriority(1) + .zIndex(1) + .transaction { view in + view.animation = .interactiveSpring + view.isContinuous = true + view.disablesAnimations = true + } + .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(.interactiveSpring, value: isPopoverPresented) + .animation(.smooth, value: suggestions) + .onAppear { + isPopoverPresented = !text.isEmpty + } + } + + @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 + ) +// +// .safeAreaInset(edge: .top, content: { Spacer().frame(height: 8) }) + .tag(viewID, includeOptional: false) + .fixedSize().edgesIgnoringSafeArea(.all).ignoresSafeArea(.all) + .transition(.offset(x: 0, y: -idealHeight).animation(.interactiveSpring)) + } + } + + // @ViewBuilder + // func popoverContent() -> some View { + // if let suggestions = suggestions?.compactMap(\.self) { + // SuggestionsPopover( + // with: suggestions, + // onSelect: { suggestion in + // text = suggestion.value + // onSuggestionSelected(suggestion) + // if suggestions.count == 1 { + // isPopoverPresented = false + // } + // } + // ).fixedSize() + // } else { + // VStack { + // ProgressView().progressViewStyle(.circular) + // if let error = error { + // Text("Error fetching suggestions: " + error) + // } else { + // Text("Fetching suggestions...") + // } + // } + // .padding() + // .foregroundColor(.secondary) + // .font(.caption) + // } + // } + + // 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 dadata.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) - } - } + /// 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 dadata.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) - } - } + /// 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: - 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. -/// - onSelect: A closure to handle the selection of a suggestion. -/// -/// - Returns: A `View` that displays a list of suggestions. -@available(iOS 15.0, *) -public struct SuggestionsPopover: View { - // Properties - - var suggestions: [S] - let onSelect: (S) -> Void - - let maxWidth: CGFloat = UIScreen.main.bounds.width - 44 - - // Lifecycle - - /// Creates a new `SuggestionsPopover`. - /// - /// - Parameters: - /// - suggestions: An array of `Suggestion.Value` to be displayed. - /// - onSelect: A closure that gets executed when a suggestion is selected. - init( - with suggestions: [S], - onSelect: @escaping (S) -> Void - ) { - self.suggestions = suggestions - self.onSelect = onSelect - } - - // Content - - public var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { - ForEach(suggestions, id: \.self, content: SelectedSuggestionView(_:)) - } - .padding(8) - .background(.regularMaterial) - .animation(.interactiveSpring, value: suggestions) - } - } - - @ViewBuilder - func SelectedSuggestionView(_ suggestion: S) -> some View { - Button(action: { - onSelect(suggestion) - }) { - Text(suggestion.value) - .font( - .system(.subheadline, design: .rounded) - ) - .lineLimit(1) - .truncationMode(.middle) - .foregroundColor(.secondary) - .frame(minWidth: 166, maxWidth: maxWidth, alignment: .leading) - } - .buttonStyle(.borderless) - .accentColor(.accentColor) - .frame(maxWidth: maxWidth) - .safeAreaInset(edge: .bottom) { - Divider() - } - } -} -// 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. - @ViewBuilder @available(iOS 15.0, *) - func iidadataSuggestions( - apiKey: String, - input text: Binding, - suggestions: Binding<[S]?>, - isPresented: Binding, - onSuggestionSelected: @escaping (S) -> Void - ) -> some View { - modifier( - IIDadataSuggestsPopover( - apiKey: apiKey, - input: text, - suggestions: suggestions, - isPresented: isPresented, - onSuggestionSelected: onSuggestionSelected - ) - ) - } - - /// 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. - /// - isPresented: A binding to a boolean value that indicates whether the popover is presented. - /// - onSuggestionSelected: A closure to handle the selection of a suggestion. - /// - /// - Returns: A view with the `IIDadataSuggestsPopover` modifier applied. - @available(iOS 15.0, *) - @ViewBuilder func iidadataSuggestions( - dadata: DadataSuggestions, - input text: Binding, - suggestions: Binding<[S]?>, - isPresented: Binding, - onSuggestionSelected: @escaping (S) -> Void - ) -> some View { - modifier( - IIDadataSuggestsPopover( - input: text, - dadata: dadata, - suggestions: suggestions, - isPresented: isPresented, - onSuggestionSelected: onSuggestionSelected - ) - ) - } -} +// MARK: - Previews #if DEBUG - // MARK: - ContentView - - /// A sample view demonstrating the usage of `IIDadataSuggestionsView`. - @available(iOS 15.0, *) - struct ContentView: View { - // Properties - - // MARK: - IIDadataViewModel - - /// The view model for managing address and FIO suggestions. - - @State var address = "Грибал" - @State var fio = "Михайл" - @State var addressSuggestions: [AddressSuggestion]? = nil - @State var fioSuggestions: [FioSuggestion]? = nil - @State var error: String? - @State var isFioSuggestionsPresented = true - @State var isAddressSuggestionsPresented = true - - @StateObject private var dadata: DadataSuggestions = DadataSuggestions.sharedInstance.unsafelyUnwrapped - - // 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"] ?? "" - DadataSuggestions.sharedInstance = DadataSuggestions(apiKey: apiKey) - } - - // Content - - var body: some View { - VStack(spacing: 16) { - TextField( - "Enter address", - text: $address - ) - .font(.body) - .textFieldStyle(.roundedBorder) - .tint(.blue).clipped() - .iidadataSuggestions( - dadata: dadata, - input: $address, - suggestions: $addressSuggestions, - isPresented: $isAddressSuggestionsPresented, - onSuggestionSelected: { address = $0.value } - ) - - TextField( - "Enter Full Name", - text: $fio - ) - .font(.body) - .textFieldStyle(.roundedBorder) - .tint(.blue) - .iidadataSuggestions( - dadata: dadata, - input: $fio, - suggestions: $fioSuggestions, - isPresented: $isFioSuggestionsPresented, - onSuggestionSelected: { fio = $0.value } - ) - } - .padding() - } - } - - @available(iOS 15.0, *) - struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } - } + // 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 + + // MARK: - IIDadataViewModel + + /// The view model for managing address and FIO suggestions. + + @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 isFocused: Bool + + // 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"] ?? "abadf779d0525bebb9e16b72a97eabf4f7143292" + _dadata = StateObject(wrappedValue: try! DadataSuggestions.shared(apiKey: apiKey)) + } + + // Content + + var body: some View { + ScrollView { + VStack(spacing: 44) { + Spacer() + TextField( + "Enter address", + text: $address + ).focused($isFocused, equals: true) + .font(.body) + .textFieldStyle(.roundedBorder) + .tint(.blue).frame(height: 44) + .withDadataSuggestions( + 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 + } + } + ) + + TextField( + "Enter Full Name", + text: $fio + ) + .font(.body) + .focused($isFocused, equals: false) + .textFieldStyle(.roundedBorder) + .tint(.blue).background(.white).frame(height: 56).clipped() + .iidadataSuggestions( + dadata: dadata, + input: $fio, + suggestions: $fioSuggestions, + textfieldHeight: 56, + onSuggestionSelected: { + fio = $0.value.appending(" ") + } + ) + + Spacer() + } + .padding() + } + .onAppear { + isFocused = true + } + .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/IIDadata/Suggestions Popover/SuggestionsPopover.swift b/IIDadata/Suggestions Popover/SuggestionsPopover.swift new file mode 100644 index 0000000..21e9724 --- /dev/null +++ b/IIDadata/Suggestions Popover/SuggestionsPopover.swift @@ -0,0 +1,161 @@ +// +// 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) + } + } + return attributedString + } + } +} From 1a86cf14aadf04392117767edf4a81c1b22b1295 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov <39944472+NSFuntik@users.noreply.github.com> Date: Tue, 13 Aug 2024 05:17:06 +0300 Subject: [PATCH 13/16] RENAME: -withDadataSuggestions --- IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift index 6e93ddb..068e093 100644 --- a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift +++ b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -415,7 +415,7 @@ extension IIDadataSuggestsPopover { .focused($isFocused, equals: false) .textFieldStyle(.roundedBorder) .tint(.blue).background(.white).frame(height: 56).clipped() - .iidadataSuggestions( + .withDadataSuggestions( dadata: dadata, input: $fio, suggestions: $fioSuggestions, From 41204cd205fe5b26deefd529ae1d768ca65910b5 Mon Sep 17 00:00:00 2001 From: Dmitry Mikhaylov <39944472+NSFuntik@users.noreply.github.com> Date: Mon, 9 Sep 2024 18:04:41 +0300 Subject: [PATCH 14/16] Contribution --- .../xcshareddata/xcschemes/Example.xcscheme | 7 - .../xcschemes/IIDadata-Package.xcscheme | 31 +- .../xcshareddata/xcschemes/IIDadata.xcscheme | 12 - .../xcschemes/IIDadataExample.xcscheme | 78 --- .../xcschemes/IIDadataTests.xcscheme | 54 --- .../xcschemes/IIDadataUI.xcscheme | 25 +- .vscode/settings.json | 3 - IIDadata/Example/ContentView.swift | 8 +- .../IIDadataSuggestionsPopover.swift | 447 ------------------ Package.swift | 92 ++-- .../Sources => Sources/IIDadata}/.gitkeep | 0 .../IIDadata}/Constants.swift | 0 .../IIDadata}/DadataQueryProtocol.swift | 0 .../IIDadata}/DadataSuggestions.swift | 0 ...edDecodingContainer+decodeJSONNumber.swift | 0 .../Model/Address/AddressSuggestion.swift | 0 .../Address/AddressSuggestionQuery.swift | 0 .../Address/AddressSuggestionResponse.swift | 0 .../Model/Address/ReverseGeocodeQuery.swift | 0 .../IIDadata}/Model/Fio/FioData.swift | 0 .../IIDadata}/Model/Fio/FioSuggestion.swift | 0 .../Model/Fio/FioSuggestionQuery.swift | 0 .../Model/Fio/FioSuggestionResponse.swift | 0 .../IIDadata}/Model/Fio/Gender.swift | 0 .../IIDadata}/Model/SuggestionProtocol.swift | 0 .../DadataSuggestionsEnvironmentKey.swift | 8 +- .../IIDadataSuggestionsPopover.swift | 443 +++++++++++++++++ .../SuggestionsPopover.swift | 0 28 files changed, 510 insertions(+), 698 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadataExample.xcscheme delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme delete mode 100644 .vscode/settings.json delete mode 100644 IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift rename {IIDadata/Sources => Sources/IIDadata}/.gitkeep (100%) rename {IIDadata/Sources => Sources/IIDadata}/Constants.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/DadataQueryProtocol.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/DadataSuggestions.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Extension/KeyedDecodingContainer+decodeJSONNumber.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Address/AddressSuggestion.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Address/AddressSuggestionQuery.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Address/AddressSuggestionResponse.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Address/ReverseGeocodeQuery.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Fio/FioData.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Fio/FioSuggestion.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Fio/FioSuggestionQuery.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Fio/FioSuggestionResponse.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/Fio/Gender.swift (100%) rename {IIDadata/Sources => Sources/IIDadata}/Model/SuggestionProtocol.swift (100%) rename {IIDadata => Sources/IIDadataUI}/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift (92%) create mode 100644 Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift rename {IIDadata => Sources/IIDadataUI}/Suggestions Popover/SuggestionsPopover.swift (100%) diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme index f6926a5..9619a1d 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Example.xcscheme @@ -50,13 +50,6 @@ ReferencedContainer = "container:"> - - - - - - - - - + - - - - - + - + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme index 248e6fc..79a22c2 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadata.xcscheme @@ -29,18 +29,6 @@ selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" shouldAutocreateTestPlan = "YES"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme deleted file mode 100644 index 4e9836e..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataTests.xcscheme +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme index 8547cde..8e0638f 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/IIDadataUI.xcscheme @@ -7,20 +7,6 @@ buildImplicitDependencies = "YES" buildArchitectures = "Automatic"> - - - - + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 3cd049d..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.quickSuggestions": false -} \ No newline at end of file diff --git a/IIDadata/Example/ContentView.swift b/IIDadata/Example/ContentView.swift index 01faa9e..f7f20c3 100644 --- a/IIDadata/Example/ContentView.swift +++ b/IIDadata/Example/ContentView.swift @@ -18,7 +18,7 @@ struct IIDadataDemo: View { @State var text = "Миха" @State var suggestions: [FioSuggestion]? { willSet { - debugPrint(suggestions, separator: "\n ● ") + debugPrint(suggestions ?? [], separator: "\n ● ") } } @State var isPresented = false @@ -27,7 +27,7 @@ struct IIDadataDemo: View { // Lifecycle init() { - apiKey = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "abadf779d0525bebb9e16b72a97eabf4f7143292" + apiKey = ProcessInfo.processInfo.environment["IIDadataAPIToken"] ?? "" _suggestions = State(initialValue: []) } @@ -39,11 +39,11 @@ struct IIDadataDemo: View { .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() .font(.body) - .iidadataSuggestions( + .withDadataSuggestions( apiKey: apiKey, input: $text, suggestions: $suggestions, - isPresented: $isPresented + textfieldHeight: 44 ) { s in debugPrint(s) text = s.value diff --git a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift b/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift deleted file mode 100644 index 068e093..0000000 --- a/IIDadata/Suggestions Popover/IIDadataSuggestionsPopover.swift +++ /dev/null @@ -1,447 +0,0 @@ -// -// IIDadataSuggestionsView.swift -// IIDadata -// -// Created by NSFuntik on 11.08.2024. -// -import IIDadata -import SwiftUI - - - -// 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 - - private let viewID = UUID().uuidString - - /// A binding to a boolean value that indicates whether the popover is presented. - @State private var isPopoverPresented: Bool = false - /// 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( - input text: Binding, - suggestions: Binding<[S]?>, - textfieldHeight: CGFloat, - onSuggestionSelected: @escaping (S) -> Void - ) where S: Suggestion { - _text = text - _suggestions = suggestions - self.textfieldHeight = textfieldHeight - self.onSuggestionSelected = onSuggestionSelected - guard let dadataSuggestions = try? DadataSuggestions.shared() else { - 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( - apiKey _: String, - input text: Binding, - suggestions: Binding<[S]?>, - textfieldHeight: CGFloat, - onSuggestionSelected: @escaping (S) -> Void - ) throws where S: Suggestion { -// dadata = try - _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) - .padding(.bottom, isPopoverPresented && !(suggestions?.isEmpty ?? true) ? idealHeight : 0) - .background { - Color.clear - .matchedGeometryEffect(id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: true) - .offset(x: 0, y: textfieldHeight / 2) - .frame(height: idealHeight) - } - .overlay(alignment: .top) { - popover().compositingGroup().matchedGeometryEffect(id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: false) - .fixedSize() - .layoutPriority(1) - .zIndex(1) - .transaction { view in - view.animation = .interactiveSpring - view.isContinuous = true - view.disablesAnimations = true - } - .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(.interactiveSpring, value: isPopoverPresented) - .animation(.smooth, value: suggestions) - .onAppear { - isPopoverPresented = !text.isEmpty - } - } - - @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 - ) -// -// .safeAreaInset(edge: .top, content: { Spacer().frame(height: 8) }) - .tag(viewID, includeOptional: false) - .fixedSize().edgesIgnoringSafeArea(.all).ignoresSafeArea(.all) - .transition(.offset(x: 0, y: -idealHeight).animation(.interactiveSpring)) - } - } - - // @ViewBuilder - // func popoverContent() -> some View { - // if let suggestions = suggestions?.compactMap(\.self) { - // SuggestionsPopover( - // with: suggestions, - // onSelect: { suggestion in - // text = suggestion.value - // onSuggestionSelected(suggestion) - // if suggestions.count == 1 { - // isPopoverPresented = false - // } - // } - // ).fixedSize() - // } else { - // VStack { - // ProgressView().progressViewStyle(.circular) - // if let error = error { - // Text("Error fetching suggestions: " + error) - // } else { - // Text("Fetching suggestions...") - // } - // } - // .padding() - // .foregroundColor(.secondary) - // .font(.caption) - // } - // } - - // 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 - - // MARK: - IIDadataViewModel - - /// The view model for managing address and FIO suggestions. - - @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 isFocused: Bool - - // 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"] ?? "abadf779d0525bebb9e16b72a97eabf4f7143292" - _dadata = StateObject(wrappedValue: try! DadataSuggestions.shared(apiKey: apiKey)) - } - - // Content - - var body: some View { - ScrollView { - VStack(spacing: 44) { - Spacer() - TextField( - "Enter address", - text: $address - ).focused($isFocused, equals: true) - .font(.body) - .textFieldStyle(.roundedBorder) - .tint(.blue).frame(height: 44) - .withDadataSuggestions( - 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 - } - } - ) - - TextField( - "Enter Full Name", - text: $fio - ) - .font(.body) - .focused($isFocused, equals: false) - .textFieldStyle(.roundedBorder) - .tint(.blue).background(.white).frame(height: 56).clipped() - .withDadataSuggestions( - dadata: dadata, - input: $fio, - suggestions: $fioSuggestions, - textfieldHeight: 56, - onSuggestionSelected: { - fio = $0.value.appending(" ") - } - ) - - Spacer() - } - .padding() - } - .onAppear { - isFocused = true - } - .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/Package.swift b/Package.swift index d348e13..149e2c3 100644 --- a/Package.swift +++ b/Package.swift @@ -4,51 +4,49 @@ import PackageDescription let package = Package( - 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: [], - path: "IIDadata/Sources/" - ), - .target( - name: "IIDadataUI", - dependencies: ["IIDadata"], - path: "IIDadata/Suggestions Popover/" - ), - .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: [] + ), + .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/IIDadata/Sources/Constants.swift b/Sources/IIDadata/Constants.swift similarity index 100% rename from IIDadata/Sources/Constants.swift rename to Sources/IIDadata/Constants.swift diff --git a/IIDadata/Sources/DadataQueryProtocol.swift b/Sources/IIDadata/DadataQueryProtocol.swift similarity index 100% rename from IIDadata/Sources/DadataQueryProtocol.swift rename to Sources/IIDadata/DadataQueryProtocol.swift diff --git a/IIDadata/Sources/DadataSuggestions.swift b/Sources/IIDadata/DadataSuggestions.swift similarity index 100% rename from IIDadata/Sources/DadataSuggestions.swift rename to Sources/IIDadata/DadataSuggestions.swift diff --git a/IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift b/Sources/IIDadata/Extension/KeyedDecodingContainer+decodeJSONNumber.swift similarity index 100% rename from IIDadata/Sources/Extension/KeyedDecodingContainer+decodeJSONNumber.swift rename to Sources/IIDadata/Extension/KeyedDecodingContainer+decodeJSONNumber.swift diff --git a/IIDadata/Sources/Model/Address/AddressSuggestion.swift b/Sources/IIDadata/Model/Address/AddressSuggestion.swift similarity index 100% rename from IIDadata/Sources/Model/Address/AddressSuggestion.swift rename to Sources/IIDadata/Model/Address/AddressSuggestion.swift diff --git a/IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift b/Sources/IIDadata/Model/Address/AddressSuggestionQuery.swift similarity index 100% rename from IIDadata/Sources/Model/Address/AddressSuggestionQuery.swift rename to Sources/IIDadata/Model/Address/AddressSuggestionQuery.swift diff --git a/IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift b/Sources/IIDadata/Model/Address/AddressSuggestionResponse.swift similarity index 100% rename from IIDadata/Sources/Model/Address/AddressSuggestionResponse.swift rename to Sources/IIDadata/Model/Address/AddressSuggestionResponse.swift diff --git a/IIDadata/Sources/Model/Address/ReverseGeocodeQuery.swift b/Sources/IIDadata/Model/Address/ReverseGeocodeQuery.swift similarity index 100% rename from IIDadata/Sources/Model/Address/ReverseGeocodeQuery.swift rename to Sources/IIDadata/Model/Address/ReverseGeocodeQuery.swift diff --git a/IIDadata/Sources/Model/Fio/FioData.swift b/Sources/IIDadata/Model/Fio/FioData.swift similarity index 100% rename from IIDadata/Sources/Model/Fio/FioData.swift rename to Sources/IIDadata/Model/Fio/FioData.swift diff --git a/IIDadata/Sources/Model/Fio/FioSuggestion.swift b/Sources/IIDadata/Model/Fio/FioSuggestion.swift similarity index 100% rename from IIDadata/Sources/Model/Fio/FioSuggestion.swift rename to Sources/IIDadata/Model/Fio/FioSuggestion.swift diff --git a/IIDadata/Sources/Model/Fio/FioSuggestionQuery.swift b/Sources/IIDadata/Model/Fio/FioSuggestionQuery.swift similarity index 100% rename from IIDadata/Sources/Model/Fio/FioSuggestionQuery.swift rename to Sources/IIDadata/Model/Fio/FioSuggestionQuery.swift diff --git a/IIDadata/Sources/Model/Fio/FioSuggestionResponse.swift b/Sources/IIDadata/Model/Fio/FioSuggestionResponse.swift similarity index 100% rename from IIDadata/Sources/Model/Fio/FioSuggestionResponse.swift rename to Sources/IIDadata/Model/Fio/FioSuggestionResponse.swift diff --git a/IIDadata/Sources/Model/Fio/Gender.swift b/Sources/IIDadata/Model/Fio/Gender.swift similarity index 100% rename from IIDadata/Sources/Model/Fio/Gender.swift rename to Sources/IIDadata/Model/Fio/Gender.swift diff --git a/IIDadata/Sources/Model/SuggestionProtocol.swift b/Sources/IIDadata/Model/SuggestionProtocol.swift similarity index 100% rename from IIDadata/Sources/Model/SuggestionProtocol.swift rename to Sources/IIDadata/Model/SuggestionProtocol.swift diff --git a/IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift b/Sources/IIDadataUI/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift similarity index 92% rename from IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift rename to Sources/IIDadataUI/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift index a44b92b..be36bc4 100644 --- a/IIDadata/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift +++ b/Sources/IIDadataUI/Suggestions Popover/DadataSuggestionsEnvironmentKey.swift @@ -23,13 +23,14 @@ public extension View { /// - 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 - ) throws -> some View { - try modifier( + ) -> some View { + modifier( IIDadataSuggestsPopover( apiKey: apiKey, input: text, @@ -37,7 +38,7 @@ public extension View { textfieldHeight: textfieldHeight, onSuggestionSelected: onSuggestionSelected ) - ).environment(\.dadataSuggestions, DadataSuggestions.shared(apiKey: apiKey)) + ).environment(\.dadataSuggestions, try? DadataSuggestions.shared(apiKey: apiKey)) } /// A view modifier to display suggestions for the given input using `Dadata` API. @@ -54,6 +55,7 @@ public extension View { /// - 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]?>, diff --git a/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift b/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift new file mode 100644 index 0000000..b222c33 --- /dev/null +++ b/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -0,0 +1,443 @@ +// +// IIDadataSuggestionsView.swift +// IIDadata +// +// Created by NSFuntik on 11.08.2024. +// +import IIDadata +import SwiftUI + +// 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 { + Color.clear + .matchedGeometryEffect( + id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: true + ) + .offset(x: 0, y: textfieldHeight / 2) + .frame(height: idealHeight) + } + .overlay(alignment: .top) { + if isPopoverPresented { + popover().compositingGroup().matchedGeometryEffect( + id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: false + ) + .fixedSize() + .layoutPriority(1) + .zIndex(1) + .transaction { view in + view.animation = .interactiveSpring + view.isContinuous = true + view.disablesAnimations = true + } + .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) + .fixedSize().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/IIDadata/Suggestions Popover/SuggestionsPopover.swift b/Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift similarity index 100% rename from IIDadata/Suggestions Popover/SuggestionsPopover.swift rename to Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift From ac282e3a89d17475350092fc0b04ea90a6ca6a66 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Tue, 22 Oct 2024 00:06:28 +0300 Subject: [PATCH 15/16] Update Package.swift --- Package.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 149e2c3..25fdc2e 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,11 @@ let package = Package( ), .target( name: "IIDadata", - dependencies: [] + dependencies: [], + swiftSettings: [ + .define("SWIFT_PACKAGE"), + .unsafeFlags(["-enable-library-evolution"]), + ] ), .target( name: "IIDadataUI", From a05ef037ddb51741e2f459b98238e2494dbdf8c3 Mon Sep 17 00:00:00 2001 From: "d.mikhailov" Date: Wed, 10 Sep 2025 14:37:47 +0300 Subject: [PATCH 16/16] fixes --- .../IIDadataSuggestionsPopover.swift | 738 +++++++++--------- .../SuggestionsPopover.swift | 1 + 2 files changed, 366 insertions(+), 373 deletions(-) diff --git a/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift b/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift index b222c33..b2b2389 100644 --- a/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift +++ b/Sources/IIDadataUI/Suggestions Popover/IIDadataSuggestionsPopover.swift @@ -4,14 +4,15 @@ // // Created by NSFuntik on 11.08.2024. // -import IIDadata 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`. +/// 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. @@ -26,418 +27,409 @@ import SwiftUI /// - 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 + /// 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 } - } - - /// 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 { - Color.clear - .matchedGeometryEffect( - id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: true - ) - .offset(x: 0, y: textfieldHeight / 2) - .frame(height: idealHeight) - } - .overlay(alignment: .top) { - if isPopoverPresented { - popover().compositingGroup().matchedGeometryEffect( - id: viewID, in: nsPopover, properties: .frame, anchor: .top, isSource: false - ) - .fixedSize() - .layoutPriority(1) - .zIndex(1) - .transaction { view in - view.animation = .interactiveSpring - view.isContinuous = true - view.disablesAnimations = true - } - .opacity(suggestions?.isEmpty == true ? 0 : 1) + + // 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)) } - } - .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) - .fixedSize().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 + + // Functions + + /// Fetches suggestions based on the input text. + /// + @MainActor private func getSuggestions(for input: String) { + Task { await getAsyncSuggestions(for: input) } } - do { - switch S.self { - case is AddressSuggestion.Type: - debugPrint("AddressSuggestion – Fetching address suggestions for: \(input)") + /// Fetches suggestions based on the input text. + /// + @MainActor @Sendable private func getAsyncSuggestions(for input: String) async { + // let input = text - let addressSuggestions = try await getAddressSuggestions(for: input) as! [S] - suggestions = addressSuggestions + 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)") - case is FioSuggestion.Type: - debugPrint("FioSuggestion – Fetching Fio suggestions for: \(input)") + let addressSuggestions = try await getAddressSuggestions(for: input) as! [S] + suggestions = addressSuggestions - let fioSuggestions = try await getFioSuggestions() as! [S] - suggestions = fioSuggestions + case is FioSuggestion.Type: + debugPrint("FioSuggestion – Fetching Fio suggestions for: \(input)") - default: - debugPrint("Unknown Suggestion – Fetching suggestions for: \(input)") - throw IIDadataError.unknown(String(describing: S.self)) - } - } catch { - self.error = "Error fetching suggestions: \(error)" - suggestions = nil + 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) + /// 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) + /// 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 + // MARK: - ContentView - 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))" - ) - } + /// A sample view demonstrating the usage of `IIDadataSuggestionsView`. + @available(iOS 15.0, *) + struct ContentView: View { + // Static Computed Properties - // 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))" + ) + } - @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? + // Properties - @StateObject private var dadata: DadataSuggestions + @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? - @FocusState private var isAddressFocused: Bool - @FocusState private var isFIOFocused: Bool + @StateObject private var dadata: DadataSuggestions - @State private var isAddressSuggestionsPresented = true - @State private var isFIOSuggestionsPresented = true + @FocusState private var isAddressFocused: Bool + @FocusState private var isFIOFocused: Bool - // Lifecycle + @State private var isAddressSuggestionsPresented = true + @State private var isFIOSuggestionsPresented = true - /// 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)) - } + // Lifecycle - // Content + /// 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)) + } - 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 - } + // 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() } - ) - 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(" ") + .onAppear { + dump(dadata) } - ) - - Spacer() + .background( + .conicGradient( + colors: [Color.pink, .accentColor, .teal, .purple, .brown, .mint, .indigo], + center: .center, angle: .degrees(.pi) + ) + ) + .ignoresSafeArea() } - .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() + @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 index 21e9724..ae0f2f7 100644 --- a/Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift +++ b/Sources/IIDadataUI/Suggestions Popover/SuggestionsPopover.swift @@ -153,6 +153,7 @@ public extension IIDadataSuggestsPopover { // 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