|
|
|
// Copyright © 2015 Abhishek Banthia
|
|
|
|
|
|
|
|
import Cocoa
|
|
|
|
|
|
|
|
enum States {
|
|
|
|
case initial
|
|
|
|
case search
|
|
|
|
case error
|
|
|
|
}
|
|
|
|
|
|
|
|
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 weak var undoButton: NSButton!
|
|
|
|
|
|
|
|
private var results: [TimezoneData] = []
|
|
|
|
private var dataTask: URLSessionDataTask? = .none
|
|
|
|
private var themeDidChangeNotification: NSObjectProtocol?
|
|
|
|
|
|
|
|
override func viewDidLoad() {
|
|
|
|
super.viewDidLoad()
|
|
|
|
|
|
|
|
view.wantsLayer = true
|
|
|
|
|
|
|
|
resultsTableView.delegate = self
|
|
|
|
resultsTableView.dataSource = self
|
|
|
|
resultsTableView.target = self
|
|
|
|
resultsTableView.doubleAction = #selector(doubleClickAction(_:))
|
|
|
|
|
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
|
|
|
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)×tamp=\(timeStamp)&key=\(CLGeocodingKey)"
|
|
|
|
|
|
|
|
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"
|
|
|
|
onboardingTypeLabel.stringValue = "More search options in Clocker Preferences."
|
|
|
|
setInfoLabel(CLEmptyString)
|
|
|
|
searchBar.placeholderString = "Search Locations"
|
|
|
|
|
|
|
|
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.5)
|
|
|
|
}
|
|
|
|
|
|
|
|
@objc func actualSearch() {
|
|
|
|
|
|
|
|
func setupForError() {
|
|
|
|
self.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)
|
|
|
|
|
|
|
|
let urlString = "https://maps.googleapis.com/maps/api/geocode/json?address=\(searchString)&key=\(CLGeocodingKey)&language=\(userPreferredLanguage)"
|
|
|
|
|
|
|
|
dataTask = NetworkManager.task(with: urlString,
|
|
|
|
completionHandler: { [weak self] response, error in
|
|
|
|
|
|
|
|
guard let `self` = self else { return }
|
|
|
|
|
|
|
|
OperationQueue.main.addOperation {
|
|
|
|
|
|
|
|
print("Search string was: \(searchString)")
|
|
|
|
|
|
|
|
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 {
|
|
|
|
self.setInfoLabel(PreferencesConstants.noInternetConnectivityError)
|
|
|
|
} else {
|
|
|
|
self.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 = placeholders.randomElement()
|
|
|
|
}
|
|
|
|
|
|
|
|
@IBAction func undoAction(_ sender: 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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class ResultSectionHeaderTableViewCell: NSTableCellView {
|
|
|
|
@IBOutlet var headerLabel: NSTextField!
|
|
|
|
}
|
|
|
|
|
|
|
|
class ResultTableViewCell: NSTableCellView {
|
|
|
|
@IBOutlet var result: NSTextField!
|
|
|
|
}
|