~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
149 lines · 5.7 KB Raw History
import CoreLocation
import Foundation
import SwiftyH3

struct NominatimResult {
    let placeId: Int
    let latitude: Double
    let longitude: Double
    let name: String
    let context: String?
    let address: [String: AnyCodable]?

    init?(from json: [String: Any]) {
        guard let placeId = json["place_id"] as? Int else { return nil }
        self.placeId = placeId

        if let lat = json["lat"] as? String, let lon = json["lon"] as? String,
           let latD = Double(lat), let lonD = Double(lon)
        {
            latitude = latD
            longitude = lonD
        } else if let lat = json["lat"] as? Double, let lon = json["lon"] as? Double {
            latitude = lat
            longitude = lon
        } else {
            return nil
        }

        let addr = json["address"] as? [String: Any]
        let city = addr?["city"] as? String ?? addr?["town"] as? String ?? addr?["village"] as? String

        var locationParts: [String] = []
        if let city {
            locationParts.append(city)
        }
        if let state = addr?["state"] as? String {
            locationParts.append(state)
        }
        if let country = addr?["country"] as? String {
            locationParts.append(country)
        }

        if let placeName = json["name"] as? String, !placeName.isEmpty {
            name = placeName
        } else {
            name = locationParts.isEmpty
                ? (json["display_name"] as? String ?? "Unknown").components(separatedBy: ",").first ?? "Unknown"
                : locationParts.joined(separator: ", ")
        }

        // Use display_name for context, stripping the place name prefix for more detail
        if let displayName = json["display_name"] as? String {
            let stripped = displayName.drop(while: { $0 != "," })
                .dropFirst() // drop the comma
                .trimmingCharacters(in: .whitespaces)
            context = stripped.isEmpty ? (locationParts.isEmpty ? nil : locationParts.joined(separator: ", ")) : stripped
        } else {
            context = locationParts.isEmpty ? nil : locationParts.joined(separator: ", ")
        }

        if let countryCode = (addr?["country_code"] as? String)?.uppercased() {
            var addressFields: [String: AnyCodable] = ["country": AnyCodable(countryCode)]
            if let city {
                addressFields["locality"] = AnyCodable(city)
            }
            if let state = addr?["state"] as? String {
                addressFields["region"] = AnyCodable(state)
            }
            if let road = addr?["road"] as? String {
                if let houseNumber = addr?["house_number"] as? String {
                    addressFields["street"] = AnyCodable("\(houseNumber) \(road)")
                } else {
                    addressFields["street"] = AnyCodable(road)
                }
            }
            if let postcode = addr?["postcode"] as? String {
                addressFields["postalCode"] = AnyCodable(postcode)
            }
            address = addressFields
        } else {
            address = nil
        }
    }
}

enum LocationServices {
    /// Convert lat/lon to H3 index string at resolution 10.
    static func latLonToH3(latitude: Double, longitude: Double) -> String {
        let latLng = H3LatLng(latitudeDegs: latitude, longitudeDegs: longitude)
        guard let cell = try? latLng.cell(at: .res10) else { return "" }
        return cell.description
    }

    /// Convert an H3 index string to a CLLocationCoordinate2D.
    static func h3ToCoordinate(_ h3Index: String) -> CLLocationCoordinate2D? {
        guard let cell = H3Cell(h3Index),
              let center = try? cell.center else { return nil }
        return center.coordinates
    }

    /// Reverse geocode coordinates via Nominatim.
    static func reverseGeocode(latitude: Double, longitude: Double) async -> NominatimResult? {
        var components = URLComponents(string: "https://nominatim.openstreetmap.org/reverse")!
        components.queryItems = [
            URLQueryItem(name: "format", value: "json"),
            URLQueryItem(name: "lat", value: String(latitude)),
            URLQueryItem(name: "lon", value: String(longitude)),
            URLQueryItem(name: "addressdetails", value: "1"),
        ]
        guard let url = components.url else { return nil }

        var request = URLRequest(url: url)
        request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent")

        guard let (data, _) = try? await NetworkEnvironment.session.data(for: request),
              let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
        else {
            return nil
        }

        return NominatimResult(from: json)
    }

    /// Search for locations via Nominatim.
    static func searchLocation(query: String) async -> [NominatimResult] {
        let trimmed = query.trimmingCharacters(in: .whitespaces)
        guard trimmed.count >= 2 else { return [] }

        var components = URLComponents(string: "https://nominatim.openstreetmap.org/search")!
        components.queryItems = [
            URLQueryItem(name: "format", value: "json"),
            URLQueryItem(name: "q", value: trimmed),
            URLQueryItem(name: "limit", value: "5"),
            URLQueryItem(name: "addressdetails", value: "1"),
        ]
        guard let url = components.url else { return [] }

        var request = URLRequest(url: url)
        request.setValue("grain-app/1.0", forHTTPHeaderField: "User-Agent")

        guard let (data, _) = try? await NetworkEnvironment.session.data(for: request),
              let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
        else {
            return []
        }

        return json.compactMap { NominatimResult(from: $0) }
    }
}