You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

460 lines
17 KiB

// Copyright © 2015 Abhishek Banthia
import Cocoa
class OnboardingSearchController: NSViewController {
@IBOutlet private var appName: NSTextField!
@IBOutlet private var onboardingTypeLabel: NSTextField!
@IBOutlet private var searchBar: ClockerSearchField!
@IBOutlet private var resultsTableView: NSTableView!
@IBOutlet private var accessoryLabel: NSTextField!
@IBOutlet var undoButton: NSButton!
private var results: [TimezoneData] = []
private var dataTask: URLSessionDataTask? = .none
private var themeDidChangeNotification: NSObjectProtocol?
private var geocodingKey: String = {
guard let path = Bundle.main.path(forResource: "Keys", ofType: "plist"),
let dictionary = NSDictionary(contentsOfFile: path),
let apiKey = dictionary["GeocodingKey"] as? String else {
assertionFailure("Unable to find the API key")
return ""
}
return apiKey
}()
override func viewDidLoad() {
super.viewDidLoad()
view.wantsLayer = true
resultsTableView.delegate = self
resultsTableView.setAccessibility("ResultsTableView")
resultsTableView.dataSource = self
resultsTableView.target = self
resultsTableView.doubleAction = #selector(doubleClickAction(_:))
if #available(OSX 11.0, *) {
resultsTableView.style = .fullWidth
}
setup()
themeDidChangeNotification = NotificationCenter.default.addObserver(forName: .themeDidChangeNotification, object: nil, queue: OperationQueue.main) { _ in
self.setup()
}
resultsTableView.reloadData()
func setupUndoButton() {
let font = NSFont(name: "Avenir", size: 13)!
let attributes = [NSAttributedString.Key.foregroundColor: NSColor.linkColor,
NSAttributedString.Key.font: font]
undoButton.attributedTitle = NSAttributedString(string: "UNDO", attributes: attributes)
undoButton.setAccessibility("UndoButton")
}
setupUndoButton()
}
deinit {
if let themeDidChangeNotif = themeDidChangeNotification {
NotificationCenter.default.removeObserver(themeDidChangeNotif)
}
}
@objc func doubleClickAction(_: NSTableView?) {
[accessoryLabel].forEach { $0?.isHidden = false }
if resultsTableView.selectedRow >= 0, resultsTableView.selectedRow < results.count {
let selectedTimezone = results[resultsTableView.selectedRow]
addTimezoneToDefaults(selectedTimezone)
}
}
private func addTimezoneToDefaults(_ timezone: TimezoneData) {
func setupLabelHidingTimer() {
Timer.scheduledTimer(withTimeInterval: 5,
repeats: false) { _ in
OperationQueue.main.addOperation {
self.accessoryLabel.stringValue = CLEmptyString
}
}
}
if resultsTableView.selectedRow == -1 {
setInfoLabel(PreferencesConstants.noTimezoneSelectedErrorMessage)
setupLabelHidingTimer()
return
}
if DataStore.shared().timezones().count >= 100 {
setInfoLabel(PreferencesConstants.maxTimezonesErrorMessage)
setupLabelHidingTimer()
return
}
guard let latitude = timezone.latitude, let longitude = timezone.longitude else {
setInfoLabel("Unable to fetch latitude/longitude. Try again.")
return
}
fetchTimezone(for: latitude, and: longitude, timezone)
}
// We want to display the undo button only if we've added a timezone.
// If else, we want it hidden. This below method ensures that.
private func setInfoLabel(_ text: String) {
accessoryLabel.stringValue = text
undoButton.isHidden = true
}
/// Returns true if there's an error.
private func handleEdgeCase(for response: Data?) -> Bool {
func setErrorPlaceholders() {
setInfoLabel("No timezone found! Try entering an exact name.")
searchBar.placeholderString = placeholders.randomElement()
}
guard let json = response, let jsonUnserialized = try? JSONSerialization.jsonObject(with: json, options: .allowFragments), let unwrapped = jsonUnserialized as? [String: Any] else {
setErrorPlaceholders()
return true
}
if let status = unwrapped["status"] as? String, status == "ZERO_RESULTS" {
setErrorPlaceholders()
return true
}
return false
}
// Extracting this out for tests
private func decodeTimezone(from data: Data) -> Timezone? {
let jsonDecoder = JSONDecoder()
do {
let decodedObject = try jsonDecoder.decode(Timezone.self, from: data)
return decodedObject
} catch {
print("decodedObject error: \n\(error)")
return nil
}
}
private func fetchTimezone(for latitude: Double, and longitude: Double, _ dataObject: TimezoneData) {
if NetworkManager.isConnected() == false || ProcessInfo.processInfo.arguments.contains("mockTimezoneDown") {
setInfoLabel(PreferencesConstants.noInternetConnectivityError)
results = []
resultsTableView.reloadData()
return
}
resultsTableView.isHidden = true
let tuple = "\(latitude),\(longitude)"
let timeStamp = Date().timeIntervalSince1970
let urlString = "https://maps.googleapis.com/maps/api/timezone/json?location=\(tuple)&timestamp=\(timeStamp)&key=\(geocodingKey)"
NetworkManager.task(with: urlString) { [weak self] response, error in
guard let self = self else { return }
OperationQueue.main.addOperation {
if self.handleEdgeCase(for: response) == true {
return
}
if error == nil, let json = response, let response = self.decodeTimezone(from: json) {
if self.resultsTableView.selectedRow >= 0, self.resultsTableView.selectedRow < self.results.count {
var filteredAddress = "Error"
if let address = dataObject.formattedAddress {
filteredAddress = address.filteredName()
}
let newTimeZone = [
CLTimezoneID: response.timeZoneId,
CLTimezoneName: filteredAddress,
CLPlaceIdentifier: dataObject.placeID!,
"latitude": latitude,
"longitude": longitude,
"nextUpdate": CLEmptyString,
CLCustomLabel: filteredAddress,
] as [String: Any]
DataStore.shared().addTimezone(TimezoneData(with: newTimeZone))
Logger.log(object: ["PlaceName": filteredAddress, "Timezone": response.timeZoneId], for: "Filtered Address")
self.accessoryLabel.stringValue = "Added \(filteredAddress)."
self.undoButton.isHidden = false
Logger.log(object: ["Place Name": filteredAddress],
for: "Added Timezone while Onboarding")
}
// Cleanup.
self.resetSearchView()
} else {
OperationQueue.main.addOperation {
if error?.localizedDescription == "The Internet connection appears to be offline." {
self.setInfoLabel(PreferencesConstants.noInternetConnectivityError)
} else {
self.setInfoLabel(PreferencesConstants.noInternetConnectivityError)
}
}
}
}
}
}
private var placeholders: [String] = ["New York", "Los Angeles", "Chicago",
"Moscow", "Tokyo", "Istanbul",
"Beijing", "Shanghai", "Sao Paulo",
"Cairo", "Mexico City", "London",
"Seoul", "Copenhagen", "Tel Aviv",
"Bern", "San Francisco", "Los Angeles",
"Sydney NSW", "Berlin"]
private func setup() {
appName.stringValue = "Quick Add Locations".localized()
onboardingTypeLabel.stringValue = "More search options in Clocker Preferences.".localized()
setInfoLabel(CLEmptyString)
searchBar.placeholderString = "Press Enter to Search!"
searchBar.delegate = self
searchBar.setAccessibility("MainSearchField")
resultsTableView.backgroundColor = Themer.shared().mainBackgroundColor()
resultsTableView.enclosingScrollView?.backgroundColor = Themer.shared().mainBackgroundColor()
[appName, onboardingTypeLabel, accessoryLabel].forEach { $0?.textColor = Themer.shared().mainTextColor() }
[accessoryLabel].forEach { $0?.isHidden = true }
}
@IBAction func search(_ sender: NSSearchField) {
resultsTableView.deselectAll(nil)
let searchString = sender.stringValue
if searchString.isEmpty {
resetSearchView()
setInfoLabel(CLEmptyString)
return
}
if resultsTableView.isHidden {
resultsTableView.isHidden = false
}
accessoryLabel.isHidden = false
if NetworkManager.isConnected() == false {
setInfoLabel(PreferencesConstants.noInternetConnectivityError)
return
}
NSObject.cancelPreviousPerformRequests(withTarget: self)
perform(#selector(OnboardingSearchController.actualSearch), with: nil, afterDelay: 0.2)
}
fileprivate func resetIfNeccesary(_ searchString: String) {
if searchString.isEmpty {
resetSearchView()
setInfoLabel(CLEmptyString)
}
}
@objc func actualSearch() {
func setupForError() {
resultsTableView.isHidden = true
}
let userPreferredLanguage = Locale.preferredLanguages.first ?? "en-US"
var searchString = searchBar.stringValue
let words = searchString.components(separatedBy: CharacterSet.whitespacesAndNewlines)
searchString = words.joined(separator: CLEmptyString)
if searchString.count < 3 {
resetIfNeccesary(searchString)
return
}
let urlString = "https://maps.googleapis.com/maps/api/geocode/json?address=\(searchString)&key=\(geocodingKey)&language=\(userPreferredLanguage)"
dataTask = NetworkManager.task(with: urlString,
completionHandler: { [weak self] response, error in
guard let self = self else { return }
OperationQueue.main.addOperation {
let currentSearchBarValue = self.searchBar.stringValue
let words = currentSearchBarValue.components(separatedBy: CharacterSet.whitespacesAndNewlines)
if words.joined(separator: CLEmptyString) != searchString {
return
}
self.results = []
if let errorPresent = error {
self.presentErrorMessage(errorPresent.localizedDescription)
setupForError()
return
}
guard let data = response else {
self.setInfoLabel(PreferencesConstants.tryAgainMessage)
setupForError()
return
}
let searchResults = self.decode(from: data)
if searchResults?.status == "ZERO_RESULTS" {
self.setInfoLabel("No results! 😔 Try entering the exact name.")
setupForError()
return
}
self.appendResultsToFilteredArray(searchResults!.results)
self.setInfoLabel(CLEmptyString)
self.resultsTableView.reloadData()
}
})
}
private func presentErrorMessage(_ errorMessage: String) {
if errorMessage == PreferencesConstants.offlineErrorMessage {
setInfoLabel(PreferencesConstants.noInternetConnectivityError)
} else {
setInfoLabel(PreferencesConstants.tryAgainMessage)
}
}
private func appendResultsToFilteredArray(_ results: [SearchResult.Result]) {
results.forEach {
let location = $0.geometry.location
let latitude = location.lat
let longitude = location.lng
let formattedAddress = $0.formattedAddress
let totalPackage = [
"latitude": latitude,
"longitude": longitude,
CLTimezoneName: formattedAddress,
CLCustomLabel: formattedAddress,
CLTimezoneID: CLEmptyString,
CLPlaceIdentifier: $0.placeId,
] as [String: Any]
self.results.append(TimezoneData(with: totalPackage))
}
}
// Extracting this out for tests
private func decode(from data: Data) -> SearchResult? {
let jsonDecoder = JSONDecoder()
do {
let decodedObject = try jsonDecoder.decode(SearchResult.self, from: data)
return decodedObject
} catch {
print("decodedObject error: \n\(error)")
return nil
}
}
private func resetSearchView() {
results = []
resultsTableView.reloadData()
searchBar.stringValue = CLEmptyString
searchBar.placeholderString = "Press Enter to Search"
}
@IBAction func undoAction(_: Any) {
DataStore.shared().removeLastTimezone()
setInfoLabel("Removed.")
}
}
extension OnboardingSearchController: NSTableViewDataSource {
func numberOfRows(in _: NSTableView) -> Int {
return results.count
}
func tableView(_ tableView: NSTableView, viewFor _: NSTableColumn?, row: Int) -> NSView? {
if let result = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "resultCellView"), owner: self) as? ResultTableViewCell, row >= 0, row < results.count {
let currentTimezone = results[row]
result.result.stringValue = " \(currentTimezone.formattedAddress ?? "Place Name")"
result.result.textColor = Themer.shared().mainTextColor()
return result
}
return nil
}
}
extension OnboardingSearchController: NSTableViewDelegate {
func tableView(_: NSTableView, heightOfRow row: Int) -> CGFloat {
if row == 0, results.isEmpty {
return 30
}
return 36
}
func tableView(_: NSTableView, shouldSelectRow row: Int) -> Bool {
return results.isEmpty ? row != 0 : true
}
func tableView(_: NSTableView, rowViewForRow _: Int) -> NSTableRowView? {
return OnboardingSelectionTableRowView()
}
}
class ResultSectionHeaderTableViewCell: NSTableCellView {
@IBOutlet var headerLabel: NSTextField!
}
class OnboardingSelectionTableRowView: NSTableRowView {
override func drawSelection(in _: NSRect) {
if selectionHighlightStyle != .none {
let selectionRect = NSInsetRect(bounds, 1, 1)
NSColor(calibratedWhite: 0.4, alpha: 1).setStroke()
NSColor(calibratedWhite: 0.4, alpha: 1).setFill()
let selectionPath = NSBezierPath(roundedRect: selectionRect, xRadius: 6, yRadius: 6)
selectionPath.fill()
selectionPath.stroke()
}
}
}
class ResultTableViewCell: NSTableCellView {
@IBOutlet var result: NSTextField!
}
extension OnboardingSearchController: NSSearchFieldDelegate {
func control(_ control: NSControl, textView _: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
guard let searchField = control as? NSSearchField else {
return false
}
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
self.search(searchField)
return true
} else if commandSelector == #selector(NSResponder.deleteForward(_:)) || commandSelector == #selector(NSResponder.deleteBackward(_:)) {
// Handle DELETE key
self.search(searchField)
return false
}
print("Not Handled")
// return true if the action was handled; otherwise false
return false
}
}