Swiftgram/submodules/LocationUI/Sources/LocationPickerControllerNode.swift
2023-08-05 15:32:12 +02:00

1231 lines
63 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import LegacyComponents
import TelegramCore
import SwiftSignalKit
import MergeLists
import ItemListUI
import ItemListVenueItem
import TelegramPresentationData
import TelegramStringFormatting
import AccountContext
import AppBundle
import CoreLocation
import Geocoding
import PhoneNumberFormat
import DeviceAccess
private struct LocationPickerTransaction {
let deletions: [ListViewDeleteItem]
let insertions: [ListViewInsertItem]
let updates: [ListViewUpdateItem]
let isLoading: Bool
let isEmpty: Bool
let crossFade: Bool
}
private enum LocationPickerEntryId: Hashable {
case city
case location
case liveLocation
case header
case venue(String)
case attribution
}
private enum LocationPickerEntry: Comparable, Identifiable {
case city(PresentationTheme, String, String, TelegramMediaMap?, Int64?, String?, CLLocationCoordinate2D?, String?, String?)
case location(PresentationTheme, String, String, TelegramMediaMap?, Int64?, String?, CLLocationCoordinate2D?, String?, String?, Bool)
case liveLocation(PresentationTheme, String, String, CLLocationCoordinate2D?)
case header(PresentationTheme, String)
case venue(PresentationTheme, TelegramMediaMap?, Int64?, String?, Int)
case attribution(PresentationTheme, LocationAttribution)
var stableId: LocationPickerEntryId {
switch self {
case .city:
return .city
case .location:
return .location
case .liveLocation:
return .liveLocation
case .header:
return .header
case let .venue(_, venue, _, _, index):
return .venue(venue?.venue?.id ?? "\(index)")
case .attribution:
return .attribution
}
}
static func ==(lhs: LocationPickerEntry, rhs: LocationPickerEntry) -> Bool {
switch lhs {
case let .city(lhsTheme, lhsTitle, lhsSubtitle, lhsVenue, lhsQueryId, lhsResultId, lhsCoordinate, lhsName, lhsCountryCode):
if case let .city(rhsTheme, rhsTitle, rhsSubtitle, rhsVenue, rhsQueryId, rhsResultId, rhsCoordinate, rhsName, rhsCountryCode) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsVenue?.venue?.id == rhsVenue?.venue?.id, lhsQueryId == rhsQueryId && lhsResultId == rhsResultId, lhsCoordinate == rhsCoordinate, lhsName == rhsName, lhsCountryCode == rhsCountryCode {
return true
} else {
return false
}
case let .location(lhsTheme, lhsTitle, lhsSubtitle, lhsVenue, lhsQueryId, lhsResultId, lhsCoordinate, lhsName, lhsCountryCode, lhsIsTop):
if case let .location(rhsTheme, rhsTitle, rhsSubtitle, rhsVenue, rhsQueryId, rhsResultId, rhsCoordinate, rhsName, rhsCountryCode, rhsIsTop) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsVenue?.venue?.id == rhsVenue?.venue?.id, lhsQueryId == rhsQueryId && lhsResultId == rhsResultId, lhsCoordinate == rhsCoordinate, lhsName == rhsName, lhsCountryCode == rhsCountryCode, lhsIsTop == rhsIsTop {
return true
} else {
return false
}
case let .liveLocation(lhsTheme, lhsTitle, lhsSubtitle, lhsCoordinate):
if case let .liveLocation(rhsTheme, rhsTitle, rhsSubtitle, rhsCoordinate) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle, lhsSubtitle == rhsSubtitle, lhsCoordinate == rhsCoordinate {
return true
} else {
return false
}
case let .header(lhsTheme, lhsTitle):
if case let .header(rhsTheme, rhsTitle) = rhs, lhsTheme === rhsTheme, lhsTitle == rhsTitle {
return true
} else {
return false
}
case let .venue(lhsTheme, lhsVenue, lhsQueryId, lhsResultId, lhsIndex):
if case let .venue(rhsTheme, rhsVenue, rhsQueryId, rhsResultId, rhsIndex) = rhs, lhsTheme === rhsTheme, lhsVenue?.venue?.id == rhsVenue?.venue?.id, lhsQueryId == rhsQueryId && lhsResultId == rhsResultId, lhsIndex == rhsIndex {
return true
} else {
return false
}
case let .attribution(lhsTheme, lhsAttribution):
if case let .attribution(rhsTheme, rhsAttribution) = rhs, lhsTheme === rhsTheme, lhsAttribution == rhsAttribution {
return true
} else {
return false
}
}
}
static func <(lhs: LocationPickerEntry, rhs: LocationPickerEntry) -> Bool {
switch lhs {
case .city:
switch rhs {
case .city:
return false
case .location, .liveLocation, .header, .venue, .attribution:
return true
}
case .location:
switch rhs {
case .city, .location:
return false
case .liveLocation, .header, .venue, .attribution:
return true
}
case .liveLocation:
switch rhs {
case .city, .location, .liveLocation:
return false
case .header, .venue, .attribution:
return true
}
case .header:
switch rhs {
case .city, .location, .liveLocation, .header:
return false
case .venue, .attribution:
return true
}
case let .venue(_, _, _, _, lhsIndex):
switch rhs {
case .city, .location, .liveLocation, .header:
return false
case let .venue(_, _, _, _, rhsIndex):
return lhsIndex < rhsIndex
case .attribution:
return true
}
case .attribution:
return false
}
}
func item(engine: TelegramEngine, presentationData: PresentationData, interaction: LocationPickerInteraction?) -> ListViewItem {
switch self {
case let .city(_, title, subtitle, _, _, _, coordinate, name, countryCode):
let icon: LocationActionListItemIcon
if let name {
icon = .venue(TelegramMediaMap(latitude: 0, longitude: 0, heading: nil, accuracyRadius: nil, geoPlace: nil, venue: MapVenue(title: name, address: "City", provider: nil, id: "city", type: "building/default"), liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil))
} else {
icon = .location
}
return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), engine: engine, title: title, subtitle: subtitle, icon: icon, beginTimeAndTimeout: nil, action: {
if let coordinate = coordinate {
interaction?.sendLocation(coordinate, name, countryCode)
}
}, highlighted: { highlighted in
interaction?.updateSendActionHighlight(highlighted)
})
case let .location(_, title, subtitle, venue, queryId, resultId, coordinate, name, countryCode, isTop):
let icon: LocationActionListItemIcon
if let venue = venue {
icon = .venue(venue)
} else {
icon = .location
}
return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), engine: engine, title: title, subtitle: subtitle, icon: icon, beginTimeAndTimeout: nil, action: {
if let venue = venue {
interaction?.sendVenue(venue, queryId, resultId)
} else if let coordinate = coordinate {
interaction?.sendLocation(coordinate, name, countryCode)
}
}, highlighted: { highlighted in
if isTop {
interaction?.updateSendActionHighlight(highlighted)
}
})
case let .liveLocation(_, title, subtitle, coordinate):
return LocationActionListItem(presentationData: ItemListPresentationData(presentationData), engine: engine, title: title, subtitle: subtitle, icon: .liveLocation, beginTimeAndTimeout: nil, action: {
if let coordinate = coordinate {
interaction?.sendLiveLocation(coordinate)
}
})
case let .header(_, title):
return LocationSectionHeaderItem(presentationData: ItemListPresentationData(presentationData), title: title)
case let .venue(_, venue, queryId, resultId, _):
let venueType = venue?.venue?.type ?? ""
return ItemListVenueItem(presentationData: ItemListPresentationData(presentationData), engine: engine, venue: venue, style: .plain, action: venue.flatMap { venue in
return { interaction?.sendVenue(venue, queryId, resultId) }
}, infoAction: ["home", "work"].contains(venueType) ? {
interaction?.openHomeWorkInfo()
} : nil)
case let .attribution(_, attribution):
return LocationAttributionItem(presentationData: ItemListPresentationData(presentationData), attribution: attribution)
}
}
}
private func preparedTransition(from fromEntries: [LocationPickerEntry], to toEntries: [LocationPickerEntry], isLoading: Bool, isEmpty: Bool, crossFade: Bool, engine: TelegramEngine, presentationData: PresentationData, interaction: LocationPickerInteraction?) -> LocationPickerTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(engine: engine, presentationData: presentationData, interaction: interaction), directionHint: nil) }
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(engine: engine, presentationData: presentationData, interaction: interaction), directionHint: nil) }
return LocationPickerTransaction(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, crossFade: crossFade)
}
enum LocationPickerLocation: Equatable {
case none
case selecting
case location(CLLocationCoordinate2D, String?)
case venue(TelegramMediaMap, Int64?, String?)
var isCustom: Bool {
switch self {
case .selecting, .location:
return true
default:
return false
}
}
public static func ==(lhs: LocationPickerLocation, rhs: LocationPickerLocation) -> Bool {
switch lhs {
case .none:
if case .none = rhs {
return true
} else {
return false
}
case .selecting:
if case .selecting = rhs {
return true
} else {
return false
}
case let .location(lhsCoordinate, lhsAddress):
if case let .location(rhsCoordinate, rhsAddress) = rhs, lhsCoordinate == rhsCoordinate, lhsAddress == rhsAddress {
return true
} else {
return false
}
case let .venue(lhsVenue, lhsQueryId, lhsResultId):
if case let .venue(rhsVenue, rhsQueryId, rhsResultId) = rhs, lhsVenue.venue?.id == rhsVenue.venue?.id, lhsQueryId == rhsQueryId, lhsResultId == rhsResultId {
return true
} else {
return false
}
}
}
}
struct LocationPickerState {
var mapMode: LocationMapMode
var displayingMapModeOptions: Bool
var selectedLocation: LocationPickerLocation
var city: String?
var street: String?
var countryCode: String?
var isStreet: Bool
var forceSelection: Bool
var searchingVenuesAround: Bool
init() {
self.mapMode = .map
self.displayingMapModeOptions = false
self.selectedLocation = .none
self.city = nil
self.street = nil
self.isStreet = false
self.forceSelection = false
self.searchingVenuesAround = false
}
}
private class LocationContext: NSObject, CLLocationManagerDelegate {
private let locationManager: CLLocationManager
private let accessSink = ValuePipe<CLAuthorizationStatus>()
override init() {
self.locationManager = CLLocationManager()
super.init()
self.locationManager.delegate = self
}
func locationAccess() -> Signal<CLAuthorizationStatus, NoError> {
let initialStatus: CLAuthorizationStatus
if #available(iOS 14.0, *) {
initialStatus = self.locationManager.authorizationStatus
} else {
initialStatus = CLLocationManager.authorizationStatus()
}
return .single(initialStatus)
|> then(
self.accessSink.signal()
)
}
@available(iOS 14.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
self.accessSink.putNext(manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
self.accessSink.putNext(status)
}
}
final class LocationPickerControllerNode: ViewControllerTracingNode, CLLocationManagerDelegate {
private weak var controller: LocationPickerController?
private let context: AccountContext
private var presentationData: PresentationData
private let presentationDataPromise: Promise<PresentationData>
private let mode: LocationPickerMode
private let source: LocationPickerController.Source
private let interaction: LocationPickerInteraction
private let locationManager: LocationManager
private let locationContext: LocationContext
private let listNode: ListView
private let emptyResultsTextNode: ImmediateTextNode
private let headerNode: LocationMapHeaderNode
private let shadeNode: ASDisplayNode
private let innerShadeNode: ASDisplayNode
private let optionsNode: LocationOptionsNode
private(set) var searchContainerNode: LocationSearchContainerNode?
private var placeholderBackgroundNode: NavigationBackgroundNode?
private var placeholderNode: LocationPlaceholderNode?
private var locationAccessDenied = false
private var enqueuedTransitions: [LocationPickerTransaction] = []
private var disposable: Disposable?
private var state: LocationPickerState
private let statePromise: Promise<LocationPickerState>
private var geocodingDisposable = MetaDisposable()
private let searchVenuesPromise = Promise<CLLocationCoordinate2D?>()
private var validLayout: (layout: ContainerViewLayout, navigationHeight: CGFloat)?
private var listOffset: CGFloat?
var beganInteractiveDragging: () -> Void = {}
var locationAccessDeniedUpdated: (Bool) -> Void = { _ in }
init(controller: LocationPickerController, context: AccountContext, presentationData: PresentationData, mode: LocationPickerMode, source: LocationPickerController.Source, interaction: LocationPickerInteraction, locationManager: LocationManager) {
self.controller = controller
self.context = context
self.presentationData = presentationData
self.presentationDataPromise = Promise(presentationData)
self.mode = mode
self.source = source
self.interaction = interaction
self.locationManager = locationManager
self.locationContext = LocationContext()
self.state = LocationPickerState()
self.statePromise = Promise(self.state)
self.listNode = ListView()
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.listNode.verticalScrollIndicatorColor = UIColor(white: 0.0, alpha: 0.3)
self.listNode.verticalScrollIndicatorFollowsOverscroll = true
self.listNode.accessibilityPageScrolledString = { row, count in
return presentationData.strings.VoiceOver_ScrollStatus(row, count).string
}
self.emptyResultsTextNode = ImmediateTextNode()
self.emptyResultsTextNode.maximumNumberOfLines = 0
self.emptyResultsTextNode.textAlignment = .center
self.emptyResultsTextNode.isHidden = true
self.headerNode = LocationMapHeaderNode(presentationData: presentationData, toggleMapModeSelection: interaction.toggleMapModeSelection, goToUserLocation: interaction.goToUserLocation, showPlacesInThisArea: interaction.showPlacesInThisArea)
self.headerNode.mapNode.isRotateEnabled = false
self.optionsNode = LocationOptionsNode(presentationData: presentationData, updateMapMode: interaction.updateMapMode)
self.shadeNode = ASDisplayNode()
self.shadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.shadeNode.alpha = 0.0
self.innerShadeNode = ASDisplayNode()
self.innerShadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
super.init()
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.addSubnode(self.listNode)
self.addSubnode(self.headerNode)
self.addSubnode(self.optionsNode)
self.listNode.addSubnode(self.emptyResultsTextNode)
self.shadeNode.addSubnode(self.innerShadeNode)
self.addSubnode(self.shadeNode)
let userLocation: Signal<CLLocation?, NoError> = self.headerNode.mapNode.userLocation
let personalAddresses = self.context.account.postbox.peerView(id: self.context.account.peerId)
|> mapToSignal { view -> Signal<(DeviceContactAddressData?, DeviceContactAddressData?)?, NoError> in
if let user = peerViewMainPeer(view) as? TelegramUser, let phoneNumber = user.phone {
return ((context.sharedContext.contactDataManager?.basicData() ?? .single([:])) |> take(1))
|> mapToSignal { basicData -> Signal<DeviceContactExtendedData?, NoError> in
var stableId: String?
let queryPhoneNumber = formatPhoneNumber(phoneNumber)
outer: for (id, data) in basicData {
for phoneNumber in data.phoneNumbers {
if formatPhoneNumber(phoneNumber.value) == queryPhoneNumber {
stableId = id
break outer
}
}
}
if let stableId = stableId {
return (context.sharedContext.contactDataManager?.extendedData(stableId: stableId) ?? .single(nil))
|> take(1)
|> map { extendedData -> DeviceContactExtendedData? in
return extendedData
}
} else {
return .single(nil)
}
}
|> map { extendedData -> (DeviceContactAddressData?, DeviceContactAddressData?)? in
if let extendedData = extendedData {
var homeAddress: DeviceContactAddressData?
var workAddress: DeviceContactAddressData?
for address in extendedData.addresses {
if address.label == "_$!<Home>!$_" {
homeAddress = address
} else if address.label == "_$!<Work>!$_" {
workAddress = address
}
}
return (homeAddress, workAddress)
} else {
return nil
}
}
} else {
return .single(nil)
}
}
let personalVenues: Signal<[TelegramMediaMap]?, NoError> = .single(nil)
|> then(
personalAddresses
|> mapToSignal { homeAndWorkAddresses -> Signal<[TelegramMediaMap]?, NoError> in
if let (homeAddress, workAddress) = homeAndWorkAddresses {
let home: Signal<(Double, Double)?, NoError>
let work: Signal<(Double, Double)?, NoError>
if let address = homeAddress {
home = geocodeAddress(engine: context.engine, address: address)
} else {
home = .single(nil)
}
if let address = workAddress {
work = geocodeAddress(engine: context.engine, address: address)
} else {
work = .single(nil)
}
return combineLatest(home, work)
|> map { homeCoordinate, workCoordinate -> [TelegramMediaMap]? in
var venues: [TelegramMediaMap] = []
if let (latitude, longitude) = homeCoordinate, let address = homeAddress {
venues.append(TelegramMediaMap(latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, geoPlace: nil, venue: MapVenue(title: presentationData.strings.Map_Home, address: address.displayString, provider: nil, id: "home", type: "home"), liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil))
}
if let (latitude, longitude) = workCoordinate, let address = workAddress {
venues.append(TelegramMediaMap(latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, geoPlace: nil, venue: MapVenue(title: presentationData.strings.Map_Work, address: address.displayString, provider: nil, id: "work", type: "work"), liveBroadcastingTimeout: nil, liveProximityNotificationRadius: nil))
}
return venues
}
} else {
return .single(nil)
}
}
)
let venuesLocation: Signal<CLLocation?, NoError>
if let initialLocation = controller.initialLocation {
venuesLocation = .single(CLLocation(coordinate: initialLocation, altitude: 0.0, horizontalAccuracy: 1.0, verticalAccuracy: 1.0, timestamp: Date()))
} else {
venuesLocation = throttledUserLocation(userLocation)
}
let venues: Signal<([(TelegramMediaMap, String)], Int64)?, NoError> = .single(nil)
|> then(
venuesLocation
|> mapToSignal { location -> Signal<([(TelegramMediaMap, String)], Int64)?, NoError> in
if let location = location, location.horizontalAccuracy > 0 {
return combineLatest(nearbyVenues(context: context, story: source == .story, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude), personalVenues)
|> map { contextResult, personalVenues -> ([(TelegramMediaMap, String)], Int64)? in
var resultVenues: [(TelegramMediaMap, String)] = []
if let personalVenues = personalVenues {
for venue in personalVenues {
let venueLocation = CLLocation(latitude: venue.latitude, longitude: venue.longitude)
if venueLocation.distance(from: location) <= 1000 {
resultVenues.append((venue, ""))
}
}
}
if let contextResult {
for result in contextResult.results {
switch result.message {
case let .mapLocation(mapMedia, _):
if let _ = mapMedia.venue {
resultVenues.append((mapMedia, result.id))
}
default:
break
}
}
return (resultVenues, contextResult.queryId)
} else {
return (resultVenues, 0)
}
}
} else {
return .single(nil)
}
}
)
let foundVenues: Signal<([(TelegramMediaMap, String)], Int64, CLLocation)?, NoError> = .single(nil)
|> then(
self.searchVenuesPromise.get()
|> distinctUntilChanged
|> mapToSignal { coordinate -> Signal<([(TelegramMediaMap, String)], Int64, CLLocation)?, NoError> in
if let coordinate = coordinate {
return (.single(nil)
|> then(
nearbyVenues(context: context, story: source == .story, latitude: coordinate.latitude, longitude: coordinate.longitude)
|> map { contextResult -> ([(TelegramMediaMap, String)], Int64, CLLocation)? in
if let contextResult {
var resultVenues: [(TelegramMediaMap, String)] = []
for result in contextResult.results {
switch result.message {
case let .mapLocation(mapMedia, _):
if let _ = mapMedia.venue {
resultVenues.append((mapMedia, result.id))
}
default:
break
}
}
return (resultVenues, contextResult.queryId, CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude))
} else {
return nil
}
}
))
} else {
return .single(nil)
}
}
)
let previousState = Atomic<LocationPickerState>(value: self.state)
let previousUserLocation = Atomic<CLLocation?>(value: nil)
let previousAnnotations = Atomic<[LocationPinAnnotation]>(value: [])
let previousEntries = Atomic<[LocationPickerEntry]?>(value: nil)
self.disposable = (combineLatest(self.presentationDataPromise.get(), self.statePromise.get(), userLocation, venues, foundVenues, self.locationContext.locationAccess())
|> deliverOnMainQueue).start(next: { [weak self] presentationData, state, userLocation, venues, foundVenuesAndLocation, access in
if let strongSelf = self {
let (foundVenues, _, foundVenuesLocation) = foundVenuesAndLocation ?? (nil, nil, nil)
var entries: [LocationPickerEntry] = []
switch state.selectedLocation {
case let .location(coordinate, address):
let title: String
switch strongSelf.mode {
case .share:
if source == .story {
title = "Add This Location"
} else {
title = presentationData.strings.Map_SendThisLocation
}
case .pick:
title = presentationData.strings.Map_SetThisLocation
}
entries.append(.location(presentationData.theme, title, address ?? presentationData.strings.Map_Locating, nil, nil, nil, coordinate, state.street, state.countryCode, true))
case .selecting:
let title: String
switch strongSelf.mode {
case .share:
if source == .story {
title = "Add This Location"
} else {
title = presentationData.strings.Map_SendThisLocation
}
case .pick:
title = presentationData.strings.Map_SetThisLocation
}
entries.append(.location(presentationData.theme, title, presentationData.strings.Map_Locating, nil, nil, nil, nil, nil, nil, true))
case let .venue(venue, queryId, resultId):
let title: String
switch strongSelf.mode {
case .share:
title = presentationData.strings.Map_SendThisPlace
case .pick:
title = presentationData.strings.Map_SetThisPlace
}
entries.append(.location(presentationData.theme, title, venue.venue?.title ?? "", venue, queryId, resultId, venue.coordinate, nil, nil, true))
case .none:
let title: String
var coordinate = userLocation?.coordinate
switch strongSelf.mode {
case .share:
if source == .story {
if let initialLocation = strongSelf.controller?.initialLocation {
title = "Add This Location"
coordinate = initialLocation
} else {
title = "Add My Current Location"
}
} else {
title = presentationData.strings.Map_SendMyCurrentLocation
}
case .pick:
title = presentationData.strings.Map_SetThisLocation
}
if source == .story {
if state.city != "" {
entries.append(.city(presentationData.theme, state.city ?? presentationData.strings.Map_Locating, "City", nil, nil, nil, coordinate, state.city, state.countryCode))
}
if state.street != "" {
entries.append(.location(presentationData.theme, state.street ?? presentationData.strings.Map_Locating, state.isStreet ? "Street" : "Location", nil, nil, nil, coordinate, state.street, nil, false))
}
} else {
entries.append(.location(presentationData.theme, title, (userLocation?.horizontalAccuracy).flatMap { presentationData.strings.Map_AccurateTo(stringForDistance(strings: presentationData.strings, distance: $0)).string } ?? presentationData.strings.Map_Locating, nil, nil, nil, coordinate, state.street, nil, true))
}
}
if case .share(_, _, true) = mode {
entries.append(.liveLocation(presentationData.theme, presentationData.strings.Map_ShareLiveLocation, presentationData.strings.Map_ShareLiveLocationHelp, userLocation?.coordinate))
}
entries.append(.header(presentationData.theme, presentationData.strings.Map_ChooseAPlace.uppercased()))
let displayedVenues: [(TelegramMediaMap, String)]?
let queryId: Int64?
if foundVenues != nil || state.searchingVenuesAround {
displayedVenues = foundVenues
queryId = foundVenuesAndLocation?.1
} else {
displayedVenues = venues?.0
queryId = venues?.1
}
var index: Int = 0
if let venues = displayedVenues, let queryId {
var attribution: LocationAttribution?
for (venue, resultId) in venues {
if venue.venue?.provider == "foursquare" {
attribution = .foursquare
} else if venue.venue?.provider == "gplaces" {
attribution = .google
}
entries.append(.venue(presentationData.theme, venue, queryId, resultId, index))
index += 1
}
if let attribution = attribution {
entries.append(.attribution(presentationData.theme, attribution))
}
} else {
for _ in 0 ..< 8 {
entries.append(.venue(presentationData.theme, nil, nil, nil, index))
index += 1
}
}
let previousEntries = previousEntries.swap(entries)
let previousState = previousState.swap(state)
var crossFade = false
if previousEntries?.count != entries.count || previousState.selectedLocation != state.selectedLocation {
crossFade = true
}
let transition = preparedTransition(from: previousEntries ?? [], to: entries, isLoading: displayedVenues == nil, isEmpty: displayedVenues?.isEmpty ?? false, crossFade: crossFade, engine: context.engine, presentationData: presentationData, interaction: strongSelf.interaction)
strongSelf.enqueueTransition(transition)
var displayingPlacesButton = false
let previousUserLocation = previousUserLocation.swap(userLocation)
switch state.selectedLocation {
case .none:
if let initialLocation = strongSelf.controller?.initialLocation {
strongSelf.headerNode.mapNode.setMapCenter(coordinate: initialLocation, animated: false)
} else if let userLocation = userLocation {
strongSelf.headerNode.mapNode.setMapCenter(coordinate: userLocation.coordinate, isUserLocation: true, animated: previousUserLocation != nil)
}
strongSelf.headerNode.mapNode.resetAnnotationSelection()
case .selecting:
strongSelf.headerNode.mapNode.resetAnnotationSelection()
case let .location(coordinate, address):
var updateMap = false
switch previousState.selectedLocation {
case .none, .venue:
updateMap = true
case let .location(previousCoordinate, _):
if previousCoordinate != coordinate {
updateMap = true
}
default:
break
}
if updateMap {
strongSelf.headerNode.mapNode.setMapCenter(coordinate: coordinate, isUserLocation: false, hidePicker: false, animated: true)
strongSelf.headerNode.mapNode.switchToPicking(animated: false)
}
if address != nil {
if foundVenues == nil && !state.searchingVenuesAround {
displayingPlacesButton = true
} else if let previousLocation = foundVenuesLocation {
let currentLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
if currentLocation.distance(from: previousLocation) > 300 {
displayingPlacesButton = true
}
}
}
case let .venue(venue, _, _):
strongSelf.headerNode.mapNode.setMapCenter(coordinate: venue.coordinate, hidePicker: true, animated: true)
}
strongSelf.headerNode.updateState(mapMode: state.mapMode, trackingMode: .none, displayingMapModeOptions: state.displayingMapModeOptions, displayingPlacesButton: displayingPlacesButton, proximityNotification: nil, animated: true)
let annotations: [LocationPinAnnotation]
if let venues = displayedVenues, let queryId {
annotations = venues.compactMap { LocationPinAnnotation(context: context, theme: presentationData.theme, location: $0.0, queryId: queryId, resultId: $0.1) }
} else {
annotations = []
}
let previousAnnotations = previousAnnotations.swap(annotations)
if annotations != previousAnnotations {
strongSelf.headerNode.mapNode.annotations = annotations
}
if let (layout, navigationBarHeight) = strongSelf.validLayout {
var updateLayout = false
let transition: ContainedViewLayoutTransition = .animated(duration: 0.45, curve: .spring)
if [.denied, .restricted].contains(access) {
if !strongSelf.locationAccessDenied {
strongSelf.locationAccessDenied = true
strongSelf.locationAccessDeniedUpdated(true)
updateLayout = true
}
} else {
if strongSelf.locationAccessDenied {
strongSelf.locationAccessDenied = false
strongSelf.locationAccessDeniedUpdated(false)
updateLayout = true
}
}
if previousState.displayingMapModeOptions != state.displayingMapModeOptions {
updateLayout = true
} else if previousState.selectedLocation.isCustom != state.selectedLocation.isCustom {
updateLayout = true
} else if previousState.searchingVenuesAround != state.searchingVenuesAround {
updateLayout = true
}
if updateLayout {
strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationBarHeight, transition: transition)
}
}
let locale = localeWithStrings(presentationData.strings)
if case let .location(coordinate, address) = state.selectedLocation, address == nil {
strongSelf.geocodingDisposable.set((reverseGeocodeLocation(latitude: coordinate.latitude, longitude: coordinate.longitude, locale: locale)
|> deliverOnMainQueue).start(next: { [weak self] placemark in
if let strongSelf = self {
var address = placemark?.fullAddress ?? ""
if address.isEmpty {
address = presentationData.strings.Map_Unknown
}
var cityName: String?
var streetName: String?
let countryCode = placemark?.countryCode
if let city = placemark?.city, let countryCode = placemark?.countryCode {
cityName = "\(city), \(displayCountryName(countryCode, locale: locale))"
} else {
cityName = ""
}
if let street = placemark?.street {
if let city = placemark?.city {
streetName = "\(street), \(city)"
} else {
streetName = street
}
} else if let name = placemark?.name {
streetName = name
} else if let country = placemark?.country, cityName == "" {
streetName = country
} else {
streetName = ""
}
if streetName == "" && cityName == "" {
streetName = "Location"
}
strongSelf.updateState { state in
var state = state
state.selectedLocation = .location(coordinate, address)
state.city = cityName
state.street = streetName
state.countryCode = countryCode
state.isStreet = placemark?.street != nil
return state
}
}
}))
} else {
let coordinate = controller.initialLocation ?? userLocation?.coordinate
if case .none = state.selectedLocation, let coordinate, state.city == nil {
strongSelf.geocodingDisposable.set((reverseGeocodeLocation(latitude: coordinate.latitude, longitude: coordinate.longitude, locale: locale)
|> deliverOnMainQueue).start(next: { [weak self] placemark in
if let strongSelf = self {
var address = placemark?.fullAddress ?? ""
if address.isEmpty {
address = presentationData.strings.Map_Unknown
}
var cityName: String?
var streetName: String?
let countryCode = placemark?.countryCode
if let city = placemark?.city, let countryCode = placemark?.countryCode {
cityName = "\(city), \(displayCountryName(countryCode, locale: locale))"
} else {
cityName = ""
}
if let street = placemark?.street {
if let city = placemark?.city {
streetName = "\(street), \(city)"
} else {
streetName = street
}
} else if let name = placemark?.name {
streetName = name
} else if let country = placemark?.country, cityName == "" {
streetName = country
} else {
streetName = ""
}
if streetName == "" && cityName == "" {
streetName = "Location"
}
strongSelf.updateState { state in
var state = state
state.city = cityName
state.street = streetName
state.countryCode = countryCode
state.isStreet = placemark?.street != nil
return state
}
}
}))
} else {
strongSelf.geocodingDisposable.set(nil)
}
}
}
})
if case let .share(_, selfPeer, _) = self.mode {
if let selfPeer {
self.headerNode.mapNode.userLocationAnnotation = LocationPinAnnotation(context: context, theme: self.presentationData.theme, peer: selfPeer)
}
self.headerNode.mapNode.hasPickerAnnotation = true
}
self.listNode.updateFloatingHeaderOffset = { [weak self] offset, listTransition in
guard let strongSelf = self, let (layout, navigationBarHeight) = strongSelf.validLayout, strongSelf.listNode.scrollEnabled else {
return
}
let overlap: CGFloat = 6.0
strongSelf.listOffset = max(0.0, offset)
let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: max(0.0, offset + overlap)))
listTransition.updateFrame(node: strongSelf.headerNode, frame: headerFrame)
strongSelf.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, topPadding: strongSelf.state.displayingMapModeOptions ? 38.0 : 0.0, offset: 0.0, size: headerFrame.size, transition: listTransition)
strongSelf.layoutEmptyResultsPlaceholder(transition: listTransition)
}
self.listNode.beganInteractiveDragging = { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
state.displayingMapModeOptions = false
return state
}
}
self.headerNode.mapNode.beganInteractiveDragging = { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.beganInteractiveDragging()
strongSelf.updateState { state in
var state = state
state.displayingMapModeOptions = false
state.selectedLocation = .selecting
state.searchingVenuesAround = false
return state
}
}
self.headerNode.mapNode.endedInteractiveDragging = { [weak self] coordinate in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
if case .selecting = state.selectedLocation {
state.selectedLocation = .location(coordinate, nil)
state.searchingVenuesAround = false
}
return state
}
}
self.headerNode.mapNode.annotationSelected = { [weak self] annotation in
guard let strongSelf = self else {
return
}
strongSelf.updateState { state in
var state = state
state.displayingMapModeOptions = false
if let annotation, let location = annotation.location {
state.selectedLocation = .venue(location, annotation.queryId, annotation.resultId)
}
if annotation == nil {
state.searchingVenuesAround = false
}
return state
}
}
self.headerNode.mapNode.userLocationAnnotationSelected = { [weak self] in
if let strongSelf = self {
strongSelf.goToUserLocation()
}
}
self.locationManager.manager.startUpdatingHeading()
self.locationManager.manager.delegate = self
}
deinit {
self.disposable?.dispose()
self.geocodingDisposable.dispose()
self.locationManager.manager.stopUpdatingHeading()
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
self.headerNode.mapNode.userHeading = CGFloat(newHeading.magneticHeading)
}
func updatePresentationData(_ presentationData: PresentationData) {
self.presentationData = presentationData
self.presentationDataPromise.set(.single(presentationData))
self.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.listNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.headerNode.updatePresentationData(self.presentationData)
self.optionsNode.updatePresentationData(self.presentationData)
self.shadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.innerShadeNode.backgroundColor = self.presentationData.theme.list.plainBackgroundColor
self.searchContainerNode?.updatePresentationData(self.presentationData)
}
func updateState(_ f: (LocationPickerState) -> LocationPickerState) {
self.state = f(self.state)
self.statePromise.set(.single(self.state))
}
private func enqueueTransition(_ transition: LocationPickerTransaction) {
self.enqueuedTransitions.append(transition)
if let _ = self.validLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
guard let _ = self.validLayout, let transition = self.enqueuedTransitions.first else {
return
}
self.enqueuedTransitions.remove(at: 0)
var options = ListViewDeleteAndInsertOptions()
if transition.crossFade {
options.insert(.AnimateCrossfade)
}
self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in
if let strongSelf = self {
strongSelf.emptyResultsTextNode.isHidden = transition.isLoading || !transition.isEmpty
strongSelf.emptyResultsTextNode.attributedText = NSAttributedString(string: strongSelf.presentationData.strings.Map_NoPlacesNearby, font: Font.regular(15.0), textColor: strongSelf.presentationData.theme.list.freeTextColor)
strongSelf.layoutEmptyResultsPlaceholder(transition: .immediate)
}
})
}
func activateSearch(navigationBar: NavigationBar) -> Signal<Bool, NoError> {
guard let (layout, navigationBarHeight) = self.validLayout, self.searchContainerNode == nil, let coordinate = self.headerNode.mapNode.mapCenterCoordinate else {
return .complete()
}
let searchContainerNode = LocationSearchContainerNode(context: self.context, updatedPresentationData: self.controller?.updatedPresentationData, coordinate: coordinate, interaction: self.interaction, story: self.source == .story)
self.insertSubnode(searchContainerNode, belowSubnode: navigationBar)
self.searchContainerNode = searchContainerNode
searchContainerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.containerLayoutUpdated(layout, navigationHeight: navigationBarHeight, transition: .immediate)
return searchContainerNode.isSearching
}
func deactivateSearch() {
guard let searchContainerNode = self.searchContainerNode else {
return
}
searchContainerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak searchContainerNode] _ in
searchContainerNode?.removeFromSupernode()
})
self.searchContainerNode = nil
}
func scrollToTop() {
if let searchContainerNode = self.searchContainerNode {
searchContainerNode.scrollToTop()
} else {
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
}
}
private func layoutEmptyResultsPlaceholder(transition: ContainedViewLayoutTransition) {
guard let (layout, navigationHeight) = self.validLayout else {
return
}
let topInset: CGFloat = floor((layout.size.height - navigationHeight) / 2.0 + navigationHeight)
let headerHeight: CGFloat
if let listOffset = self.listOffset {
headerHeight = max(0.0, listOffset)
} else {
headerHeight = topInset
}
let actionsInset: CGFloat = 148.0
let padding: CGFloat = 16.0
let emptyTextSize = self.emptyResultsTextNode.updateLayout(CGSize(width: layout.size.width - layout.safeInsets.left - layout.safeInsets.right - padding * 2.0, height: CGFloat.greatestFiniteMagnitude))
transition.updateFrame(node: self.emptyResultsTextNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - emptyTextSize.width) / 2.0), y: headerHeight + actionsInset + floor((layout.size.height - headerHeight - actionsInset - emptyTextSize.height - layout.intrinsicInsets.bottom) / 2.0)), size: emptyTextSize))
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) {
let isFirstLayout = self.validLayout == nil
self.validLayout = (layout, navigationHeight)
let isPickingLocation = (self.state.selectedLocation.isCustom || self.state.forceSelection) && !self.state.searchingVenuesAround
let optionsHeight: CGFloat = 38.0
var actionHeight: CGFloat?
self.listNode.forEachItemNode { itemNode in
if let itemNode = itemNode as? LocationActionListItemNode {
if actionHeight == nil {
actionHeight = itemNode.frame.height
}
}
}
// let topInset: CGFloat = floor((layout.size.height - navigationHeight) / 2.0 + navigationHeight)
let topInset: CGFloat = 240.0
let overlap: CGFloat = 6.0
let headerHeight: CGFloat
if isPickingLocation, let actionHeight = actionHeight {
self.listOffset = topInset
headerHeight = layout.size.height - actionHeight - layout.intrinsicInsets.bottom + overlap - 2.0
} else if let listOffset = self.listOffset {
headerHeight = max(0.0, listOffset + overlap)
} else {
headerHeight = topInset + overlap
}
let headerFrame = CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: headerHeight))
transition.updateFrame(node: self.headerNode, frame: headerFrame)
self.headerNode.updateLayout(layout: layout, navigationBarHeight: navigationHeight, topPadding: self.state.displayingMapModeOptions ? optionsHeight : 0.0, offset: 0.0, size: headerFrame.size, transition: transition)
let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition)
let scrollToItem: ListViewScrollToItem?
if isPickingLocation {
scrollToItem = ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: curve, directionHint: .Up)
} else {
scrollToItem = nil
}
let insets = UIEdgeInsets(top: topInset, left: layout.safeInsets.left, bottom: layout.intrinsicInsets.bottom, right: layout.safeInsets.right)
self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: scrollToItem, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, headerInsets: UIEdgeInsets(top: navigationHeight, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), scrollIndicatorInsets: UIEdgeInsets(top: topInset + 3.0, left: 0.0, bottom: layout.intrinsicInsets.bottom, right: 0.0), duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in })
self.listNode.scrollEnabled = !isPickingLocation
var listFrame: CGRect = CGRect(origin: CGPoint(), size: layout.size)
if isPickingLocation {
listFrame.origin.y = headerHeight - topInset - overlap
}
transition.updateFrame(node: self.listNode, frame: listFrame)
transition.updateAlpha(node: self.shadeNode, alpha: isPickingLocation ? 1.0 : 0.0)
transition.updateFrame(node: self.shadeNode, frame: CGRect(x: 0.0, y: listFrame.minY + topInset + (actionHeight ?? 0.0) - 3.0, width: layout.size.width, height: 10000.0))
self.shadeNode.isUserInteractionEnabled = isPickingLocation
self.innerShadeNode.frame = CGRect(x: 0.0, y: 4.0, width: layout.size.width, height: 10000.0)
self.innerShadeNode.alpha = layout.intrinsicInsets.bottom > 0.0 ? 1.0 : 0.0
self.layoutEmptyResultsPlaceholder(transition: transition)
if isFirstLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
let optionsOffset: CGFloat = self.state.displayingMapModeOptions ? navigationHeight : navigationHeight - optionsHeight
let optionsFrame = CGRect(x: 0.0, y: optionsOffset, width: layout.size.width, height: optionsHeight)
transition.updateFrame(node: self.optionsNode, frame: optionsFrame)
self.optionsNode.updateLayout(size: optionsFrame.size, leftInset: insets.left, rightInset: insets.right, transition: transition)
self.optionsNode.isUserInteractionEnabled = self.state.displayingMapModeOptions
if let searchContainerNode = self.searchContainerNode {
searchContainerNode.frame = CGRect(origin: CGPoint(), size: layout.size)
searchContainerNode.containerLayoutUpdated(ContainerViewLayout(size: layout.size, metrics: LayoutMetrics(), deviceMetrics: layout.deviceMetrics, intrinsicInsets: layout.intrinsicInsets, safeInsets: layout.safeInsets, additionalInsets: layout.additionalInsets, statusBarHeight: nil, inputHeight: layout.inputHeight, inputHeightIsInteractivellyChanging: layout.inputHeightIsInteractivellyChanging, inVoiceOver: layout.inVoiceOver), navigationBarHeight: navigationHeight, transition: transition)
}
if self.locationAccessDenied {
self.controller?.navigationBar?.updateBackgroundAlpha(0.0, transition: .immediate)
Queue.mainQueue().after(0.25) {
self.controller?.updateTabBarAlpha(0.0, .immediate)
}
var placeholderTransition = transition
let placeholderNode: LocationPlaceholderNode
let backgroundNode: NavigationBackgroundNode
if let current = self.placeholderNode, let background = self.placeholderBackgroundNode {
placeholderNode = current
backgroundNode = background
backgroundNode.updateColor(color: self.presentationData.theme.rootController.tabBar.backgroundColor, transition: .immediate)
} else {
backgroundNode = NavigationBackgroundNode(color: self.presentationData.theme.rootController.tabBar.backgroundColor)
if let navigationBar = self.controller?.navigationBar {
self.insertSubnode(backgroundNode, belowSubnode: navigationBar)
} else {
self.addSubnode(backgroundNode)
}
self.placeholderBackgroundNode = backgroundNode
placeholderNode = LocationPlaceholderNode(content: .intro)
placeholderNode.settingsPressed = { [weak self] in
self?.context.sharedContext.applicationBindings.openSettings()
}
self.insertSubnode(placeholderNode, aboveSubnode: backgroundNode)
self.placeholderNode = placeholderNode
placeholderTransition = .immediate
}
placeholderNode.update(layout: layout, theme: self.presentationData.theme, strings: self.presentationData.strings, transition: placeholderTransition)
placeholderTransition.updateFrame(node: placeholderNode, frame: CGRect(origin: CGPoint(), size: layout.size))
let placeholderFrame = CGRect(origin: CGPoint(), size: layout.size)
backgroundNode.update(size: placeholderFrame.size, transition: placeholderTransition)
placeholderTransition.updateFrame(node: placeholderNode, frame: placeholderFrame)
} else {
if let placeholderNode = self.placeholderNode {
self.placeholderNode = nil
placeholderNode.removeFromSupernode()
}
if let placeholderBackgroundNode = self.placeholderBackgroundNode {
self.placeholderBackgroundNode = nil
placeholderBackgroundNode.removeFromSupernode()
}
self.controller?.navigationBar?.updateBackgroundAlpha(1.0, transition: .immediate)
self.controller?.updateTabBarAlpha(1.0, .immediate)
}
}
func updateSendActionHighlight(_ highlighted: Bool) {
self.headerNode.updateHighlight(highlighted)
self.shadeNode.backgroundColor = highlighted ? self.presentationData.theme.list.itemHighlightedBackgroundColor : self.presentationData.theme.list.plainBackgroundColor
}
func goToUserLocation() {
self.searchVenuesPromise.set(.single(nil))
self.updateState { state in
var state = state
state.displayingMapModeOptions = false
state.selectedLocation = .none
state.searchingVenuesAround = false
return state
}
}
func requestPlacesAtSelectedLocation() {
if case let .location(coordinate, _) = self.state.selectedLocation {
self.headerNode.mapNode.setMapCenter(coordinate: coordinate, animated: true)
self.searchVenuesPromise.set(.single(coordinate))
self.updateState { state in
var state = state
state.searchingVenuesAround = true
return state
}
}
}
}