// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2012-2020 Threema GmbH // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License, version 3, // as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . #import "PreviewLocationViewController.h" #import "UIImageView+WebCache.h" #import "PoiTableViewCell.h" #import "UserSettings.h" #import "PointOfInterest.h" #ifdef DEBUG static const DDLogLevel ddLogLevel = DDLogLevelVerbose; #else static const DDLogLevel ddLogLevel = DDLogLevelWarning; #endif static const CLLocationDegrees kDefaultSpan = 0.01; static const float kPlacesUpdateInterval = 5.0; @interface PreviewLocationViewController () @property CLLocationManager *locationManager; @end @implementation PreviewLocationViewController { CLLocation *lastLocation, *lastLocationPlaces; NSMutableArray *curPlaces; NSUInteger placesRequestCount; NSDate *lastPlacesUpdate; BOOL loading; NSString *lastSearchText; UIBarButtonItem *rightBarButton; UIBarButtonItem *leftBarButton; UIView *overlayView; UIView *lineView; UIRefreshControl *refreshControl; } - (void)dealloc { self.mapView.delegate = nil; } - (void)viewDidLoad { [super viewDidLoad]; if ([UserSettings sharedUserSettings].enablePoi) { [self setRefreshControlTitle:NO]; overlayView = [[UIView alloc] initWithFrame:CGRectMake(0, self.navigationController.navigationBar.frame.size.height, self.view.frame.size.width, self.view.frame.size.height)]; overlayView.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:0.4]; overlayView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [overlayView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(overlayViewTapped)]]; leftBarButton = self.navigationItem.leftBarButtonItem; rightBarButton = self.navigationItem.rightBarButtonItem; self.searchController = [[UISearchController alloc]initWithSearchResultsController:nil]; self.searchController.searchBar.showsScopeBar = NO; self.searchController.searchBar.scopeButtonTitles = nil; self.searchController.searchBar.delegate = self; self.searchController.searchResultsUpdater = self; self.searchController.delegate = self; self.searchController.searchBar.autoresizingMask = UIViewAutoresizingFlexibleWidth; [self.searchController.searchBar sizeToFit]; self.searchController.dimsBackgroundDuringPresentation = NO; self.searchController.hidesNavigationBarDuringPresentation = false; self.definesPresentationContext = YES; self.navigationItem.titleView = self.searchController.searchBar; self.navigationItem.leftBarButtonItem = leftBarButton; self.navigationItem.rightBarButtonItem = rightBarButton; refreshControl = [UIRefreshControl new]; [refreshControl addTarget:self action:@selector(pulledForRefresh:) forControlEvents:UIControlEventValueChanged]; self.poiTableView.refreshControl = refreshControl; self.poiTableView.rowHeight = UITableViewAutomaticDimension; self.poiTableView.estimatedRowHeight = 44.0; } else { CGRect frame = self.mapView.frame; frame.size.height = self.poiTableView.frame.origin.y + self.poiTableView.frame.size.height; self.mapView.frame = frame; [self.poiTableView removeFromSuperview]; } [self setupColors]; } - (void)setupColors { [self.view setBackgroundColor:[Colors backgroundLight]]; [Colors updateTableView:self.poiTableView]; [Colors updateSearchBar:_searchController.searchBar]; } - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self updateCanSend]; [self.mapView setRegion:MKCoordinateRegionMake(self.mapView.userLocation.coordinate, MKCoordinateSpanMake(kDefaultSpan, kDefaultSpan)) animated:NO]; if (lineView != nil) lineView.frame = CGRectMake(0, self.poiTableView.frame.origin.y - 1, self.poiTableView.frame.size.width, 1); } - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; } - (void)viewDidAppear:(BOOL)animated { [self checkLocationAccess]; [self mapView:self.mapView didUpdateUserLocation:self.mapView.userLocation]; [super viewDidAppear:animated]; } - (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; lineView.frame = CGRectMake(0, self.poiTableView.frame.origin.y - 1, self.poiTableView.frame.size.width, 1); } - (BOOL)shouldAutorotate { return YES; } -(UIInterfaceOrientationMask)supportedInterfaceOrientations { if (SYSTEM_IS_IPAD) { return UIInterfaceOrientationMaskAll; } return UIInterfaceOrientationMaskAllButUpsideDown; } #pragma mark - Private functions - (void)setRefreshControlTitle:(BOOL)active { NSString *refreshText = nil; UIFont *font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; if (active) { refreshText = NSLocalizedString(@"refreshing", nil); } else { refreshText = NSLocalizedString(@"pull_to_refresh", nil); } NSMutableAttributedString *attributedRefreshText = [[NSMutableAttributedString alloc] initWithString:refreshText attributes:@{ NSFontAttributeName: font, NSForegroundColorAttributeName: [Colors fontLight], NSBackgroundColorAttributeName: [UIColor clearColor]}]; refreshControl.attributedTitle = attributedRefreshText; } - (void)checkLocationAccess { CLAuthorizationStatus status = [CLLocationManager authorizationStatus]; if (status == kCLAuthorizationStatusRestricted || status == kCLAuthorizationStatusDenied) { [self showLocationAccessAlert]; } else { if (status == kCLAuthorizationStatusNotDetermined) { _locationManager = [[CLLocationManager alloc] init]; [_locationManager requestWhenInUseAuthorization]; } } } - (void)showLocationAccessAlert { [UIAlertTemplate showAlertWithOwner:self title:NSLocalizedString(@"location_disabled_title", nil) message:NSLocalizedString(@"location_disabled_message", nil) actionOk:nil]; } - (void)updateCanSend { self.navigationItem.rightBarButtonItem.enabled = (self.mapView.userLocation != nil && !(self.mapView.userLocation.coordinate.latitude == 0 && self.mapView.userLocation.coordinate.longitude == 0)); } - (void)pulledForRefresh:(UIRefreshControl *)sender { [self setRefreshControlTitle:YES]; lastPlacesUpdate = nil; [self updatePlacesForLocation:self.mapView.userLocation.location noDelay:NO onCompletion:^{ [sender endRefreshing]; [self setRefreshControlTitle:NO]; }]; } #pragma mark Map view delegate - (void)mapView:(MKMapView *)mapView didUpdateUserLocation:(MKUserLocation *)userLocation { [self updateCanSend]; if (userLocation == nil) return; /* Determine distance to last location. If greater than 1 km, pan map */ CLLocationDistance distance = 0; if (lastLocation != nil) distance = [lastLocation distanceFromLocation:userLocation.location]; if (lastLocation == nil || distance > 1000) { MKCoordinateRegion region = MKCoordinateRegionMake(userLocation.coordinate, MKCoordinateSpanMake(kDefaultSpan, kDefaultSpan)); [mapView setRegion:region animated:(lastLocation != nil)]; lastLocation = userLocation.location; } /* Determine distance separately for places reload */ distance = 0; if (lastLocationPlaces != nil) distance = [lastLocationPlaces distanceFromLocation:userLocation.location]; if (lastLocationPlaces == nil || distance > 20) { lastLocationPlaces = userLocation.location; [self updatePlacesForLocation:lastLocationPlaces noDelay:NO onCompletion:nil]; } } - (void)mapView:(MKMapView *)mapView didSelectAnnotationView:(MKAnnotationView *)view { if (![view.annotation isKindOfClass:[PointOfInterest class]]) return; PointOfInterest *poi = view.annotation; [self.delegate previewLocationController:self didChooseToSendCoordinate:CLLocationCoordinate2DMake(poi.latitude, poi.longitude) accuracy:0 poiName:poi.name poiAddress:poi.address]; [self dismissViewControllerAnimated:YES completion:nil]; } #pragma mark Table view delegate - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { if (curPlaces.count == 0) return; PointOfInterest *poi = [curPlaces objectAtIndex:indexPath.row]; [self.delegate previewLocationController:self didChooseToSendCoordinate:CLLocationCoordinate2DMake(poi.latitude, poi.longitude) accuracy:0 poiName:poi.name poiAddress:poi.address]; [self dismissViewControllerAnimated:YES completion:nil]; } #pragma mark Table view data source - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { if ([cell isKindOfClass:[UITableViewCell class]]) { [Colors updateTableViewCell:cell]; } if ([cell isKindOfClass:[PoiTableViewCell class]]) { [((PoiTableViewCell *)cell).addressLabel setTextColor:[Colors fontLight]]; } } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { if (tableView == self.poiTableView) { if (curPlaces.count == 0) return 1; else return curPlaces.count; } else { /* search bar */ return 0; } } - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { return UITableViewAutomaticDimension; } -(CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath { return UITableViewAutomaticDimension; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { if (tableView == self.poiTableView) { if (curPlaces.count == 0) { if (loading) { static NSString *CellIdentifier = @"SpinnerCell"; return [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; } else if (_searchController.searchBar.text.length == 0) { static NSString *CellIdentifier = @"EnterQueryCell"; return [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; } else { static NSString *CellIdentifier = @"NoPlacesFoundCell"; return [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; } } else { static NSString *CellIdentifier = @"PoiCell"; PoiTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier]; PointOfInterest *poi = [curPlaces objectAtIndex:indexPath.row]; cell.nameLabel.text = poi.name; cell.addressLabel.text = poi.address; return cell; } } else { /* search bar */ return nil; } } #pragma mark - Search controller delegate - (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { NSString *searchText = searchBar.text; DDLogVerbose(@"search: %@", searchBar.text); [_searchController setActive:NO]; _searchController.searchBar.text = searchText; lastPlacesUpdate = nil; lastSearchText = searchText; [self updatePlacesForLocation:self.mapView.userLocation.location noDelay:NO onCompletion:nil]; } - (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { DDLogVerbose(@"text changed: %@, active: %d", searchBar.text, _searchController.active); if (searchText.length == 0 && ![lastSearchText isEqualToString:searchText]) { lastPlacesUpdate = nil; lastSearchText = searchText; [self updatePlacesForLocation:self.mapView.userLocation.location noDelay:NO onCompletion:nil]; } } - (void)searchBarTextDidBeginEditing:(UISearchBar *)searchBar { DDLogVerbose(@"searchDisplayControllerWillBeginSearch"); if (_searchController.active && searchBar.text.length > 0) [self.view addSubview:overlayView]; else [overlayView removeFromSuperview]; [self.navigationItem setRightBarButtonItem:nil animated:YES]; [self.navigationItem setLeftBarButtonItem:nil animated:YES]; } - (void)searchBarTextDidEndEditing:(UISearchBar *)searchBar { DDLogVerbose(@"searchDisplayControllerWillEndSearch"); [overlayView removeFromSuperview]; [self.navigationItem setRightBarButtonItem:rightBarButton animated:YES]; [self.navigationItem setLeftBarButtonItem:leftBarButton animated:YES]; } -(void)updateSearchResultsForSearchController:(UISearchController *)searchController { if (searchController.searchBar.text.length == 0 && ![lastSearchText isEqualToString:searchController.searchBar.text]) { lastPlacesUpdate = nil; lastSearchText = searchController.searchBar.text; [self updatePlacesForLocation:self.mapView.userLocation.location noDelay:NO onCompletion:nil]; } if (searchController.active && searchController.searchBar.text.length > 0) [self.view addSubview:overlayView]; else [overlayView removeFromSuperview]; } - (void)overlayViewTapped { _searchController.active = NO; } #pragma mark - IBActions - (IBAction)cancelAction:(id)sender { if (_searchController.active) [_searchController setActive:NO]; else [self dismissViewControllerAnimated:YES completion:nil]; } - (IBAction)sendAction:(id)sender { if (self.mapView.userLocation != nil && !(self.mapView.userLocation.coordinate.latitude == 0 && self.mapView.userLocation.coordinate.longitude == 0)) [self.delegate previewLocationController:self didChooseToSendCoordinate:self.mapView.userLocation.coordinate accuracy:self.mapView.userLocation.location.horizontalAccuracy poiName:nil poiAddress:nil]; [self dismissViewControllerAnimated:YES completion:nil]; } - (void)updatePlacesForLocation:(CLLocation*)location noDelay:(BOOL)noDelay onCompletion:(void(^)(void))onCompletion { if (location == nil || (fabs(location.coordinate.latitude) < 0.000001 && fabs(location.coordinate.longitude) < 0.000001)) { if (onCompletion != nil) onCompletion(); return; } DDLogVerbose(@"updatePlacesForLocation: %@, loading: %d", location, loading); if (loading || (lastPlacesUpdate != nil && [lastPlacesUpdate timeIntervalSinceNow] > -kPlacesUpdateInterval)) { /* Another update is already in progress or was completed a short time ago. Check again in a moment. */ DDLogVerbose(@"Already loading or loaded in the past %.1f seconds - %@", kPlacesUpdateInterval, noDelay ? @"ignoring" : @"delaying"); if (!noDelay) { self.pendingLocation = location; /* use weak reference to self to avoid retain cycle that will keep us from not using location services anymore */ __weak PreviewLocationViewController *wself = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kPlacesUpdateInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [wself updatePlacesForLocation:wself.pendingLocation noDelay:YES onCompletion:onCompletion]; wself.pendingLocation = nil; }); } else { if (onCompletion != nil) onCompletion(); } return; } lastPlacesUpdate = [NSDate date]; double radius = MAX(50, MAX(location.horizontalAccuracy, location.verticalAccuracy) * 2); if (_searchController.searchBar.text.length > 0) radius = 1000; placesRequestCount++; loading = YES; NSUInteger lastPlacesRequestCount = placesRequestCount; if ([UserSettings sharedUserSettings].enablePoi) { [self.poiTableView reloadData]; if (_searchController.searchBar.text.length > 0) { MKLocalSearchRequest *searchRequest = [[MKLocalSearchRequest alloc] init]; searchRequest.naturalLanguageQuery = _searchController.searchBar.text; searchRequest.region = self.mapView.region; MKLocalSearch *localSearch = [[MKLocalSearch alloc] initWithRequest:searchRequest]; [localSearch startWithCompletionHandler:^(MKLocalSearchResponse * _Nullable response, NSError * _Nullable error) { if (lastPlacesRequestCount < placesRequestCount) { DDLogInfo(@"Discarding old result from concurrent request"); return; } if (curPlaces != nil) { [self.mapView removeAnnotations:curPlaces]; } curPlaces = [[NSMutableArray alloc] init]; for (MKMapItem *mapItem in response.mapItems) { PointOfInterest *poi = [[PointOfInterest alloc] init]; poi.name = mapItem.placemark.name; poi.address = [self formatAddressForPlacemark:mapItem.placemark]; poi.latitude = mapItem.placemark.coordinate.latitude; poi.longitude = mapItem.placemark.coordinate.longitude; [curPlaces addObject:poi]; } [self.mapView addAnnotations:curPlaces]; loading = NO; [self.poiTableView reloadData]; [self.poiTableView setContentOffset:CGPointZero animated:YES]; if (onCompletion != nil) onCompletion(); }]; } else { loading = NO; if (onCompletion != nil) onCompletion(); } } } - (NSString*)formatAddressForPlacemark:(MKPlacemark*)placemark { NSMutableString *address = [[NSMutableString alloc] initWithString:@""]; if (placemark.thoroughfare != nil && placemark.subThoroughfare != nil) { if ([[NSLocale currentLocale].languageCode isEqualToString:@"de"]) { [address appendString:[NSString stringWithFormat:@"%@ %@", placemark.thoroughfare, placemark.subThoroughfare]]; } else if ([[NSLocale currentLocale].languageCode isEqualToString:@"fr"]) { [address appendString:[NSString stringWithFormat:@"%@, %@", placemark.subThoroughfare, placemark.thoroughfare]]; } else { [address appendString:[NSString stringWithFormat:@"%@ %@", placemark.subThoroughfare, placemark.thoroughfare]]; } } else if (placemark.thoroughfare != nil) { [address appendString:placemark.thoroughfare]; } else if (placemark.subThoroughfare != nil) { [address appendString:placemark.subThoroughfare]; } if (placemark.postalCode != nil || placemark.locality != nil) { if (address.length > 0) { [address appendString:@", "]; } if (placemark.postalCode != nil) { [address appendString:[NSString stringWithFormat:@"%@ ", placemark.postalCode]]; } if (placemark.locality != nil) { [address appendString:placemark.locality]; } } return address; } @end