// Copyright © 2015 Abhishek Banthia import Cocoa struct PreferencesConstants { static let timezoneNameIdentifier = "formattedAddress" static let customLabelIdentifier = "label" static let availableTimezoneIdentifier = "availableTimezones" static let noTimezoneSelectedErrorMessage = "Please select a timezone!" static let maxTimezonesErrorMessage = "Maximum 100 timezones allowed!" static let maxCharactersAllowed = "Only 50 characters allowed!" static let noInternetConnectivityError = "You're offline, maybe?" static let tryAgainMessage = "Try again, maybe?" static let offlineErrorMessage = "The Internet connection appears to be offline." } enum RowType { case timezoneHeader case cityHeader case city case timezone } struct TimezoneMetadata { let timezone: Timezone let tages: Set = Set() } class PreferencesViewController: ParentViewController { private var isActivityInProgress = false { didSet { OperationQueue.main.addOperation { self.isActivityInProgress ? self.progressIndicator.startAnimation(nil) : self.progressIndicator.stopAnimation(nil) self.availableTimezoneTableView.isEnabled = !self.isActivityInProgress self.addButton.isEnabled = !self.isActivityInProgress } } } private var selectedTimeZones: [Data] { return DataStore.shared().timezones() } private lazy var startupManager = StartupManager() private var filteredArray: [Any] = [] private var timezoneArray: [String] = [] private var timezoneFilteredArray: [String] = [] private var dataTask: URLSessionDataTask? = .none private lazy var notimezoneView: NoTimezoneView? = { NoTimezoneView(frame: tableview.frame) }() // Sorting private var arePlacesSortedInAscendingOrder = false private var arePlacesSortedInAscendingTimezoneOrder = false private var isTimezoneSortOptionSelected = false private var isTimezoneNameSortOptionSelected = false private var isLabelOptionSelected = false @IBOutlet private var placeholderLabel: NSTextField! @IBOutlet private var timezoneTableView: NSTableView! @IBOutlet private var availableTimezoneTableView: NSTableView! @IBOutlet private var timezonePanel: Panelr! @IBOutlet private var stackView: NSStackView! @IBOutlet private var progressIndicator: NSProgressIndicator! @IBOutlet private var addButton: NSButton! @IBOutlet private var recorderControl: SRRecorderControl! @IBOutlet private var timezoneSortButton: NSButton! @IBOutlet private var timezoneNameSortButton: NSButton! @IBOutlet private var labelSortButton: NSButton! @IBOutlet private var deleteButton: NSButton! @IBOutlet private var addTimezoneButton: NSButton! @IBOutlet private var searchField: NSSearchField! @IBOutlet private var messageLabel: NSTextField! @IBOutlet private var headerView: NSView! @IBOutlet private var tableview: NSView! @IBOutlet private var additionalSortOptions: NSView! @IBOutlet var startAtLoginLabel: NSTextField! @IBOutlet var startupCheckbox: NSButton! @IBOutlet var headerLabel: NSTextField! @IBOutlet var sortToggle: NSButton! private var themeDidChangeNotification: NSObjectProtocol? private var selectionsDataSource: PreferencesDataSource! private var finalArray: [RowType] = [] override func viewDidLoad() { super.viewDidLoad() NotificationCenter.default.addObserver(self, selector: #selector(refreshTimezoneTableView), name: NSNotification.Name.customLabelChanged, object: nil) refreshTimezoneTableView() setup() setupShortcutObserver() darkModeChanges() themeDidChangeNotification = NotificationCenter.default.addObserver(forName: .themeDidChangeNotification, object: nil, queue: OperationQueue.main) { _ in self.setup() } searchField.placeholderString = "Enter city, state, country or timezone name" setupTimezoneDatasource() selectionsDataSource = PreferencesDataSource(callbackDelegate: self) timezoneTableView.dataSource = selectionsDataSource timezoneTableView.delegate = selectionsDataSource reloadSearchResults() } private func calculateArray() { finalArray = [] func addTimezonesIfNeeded(_ data: [String]) { if !data.isEmpty { finalArray.append(.timezoneHeader) } data.forEach { (_) in finalArray.append(.timezone) } } if searchField.stringValue.isEmpty { addTimezonesIfNeeded(timezoneArray) } else { if !filteredArray.isEmpty { finalArray.append(.cityHeader) } filteredArray.forEach { (_) in finalArray.append(.city) } addTimezonesIfNeeded(timezoneFilteredArray) } } deinit { // We still need to remove observers set using NotificationCenter block: APIs if let themeDidChangeNotif = themeDidChangeNotification { NotificationCenter.default.removeObserver(themeDidChangeNotif) } } private func darkModeChanges() { if #available(macOS 10.14, *) { addTimezoneButton.image = NSImage(named: .addDynamicIcon) sortToggle.image = NSImage(named: .sortToggleIcon) sortToggle.alternateImage = NSImage(named: .sortToggleAlternateIcon) deleteButton.image = NSImage(named: NSImage.Name("Remove Dynamic"))! } } private func setupLocalizedText() { startAtLoginLabel.stringValue = "Start Clocker at Login" headerLabel.stringValue = "Selected Timezones" timezoneSortButton.title = "Sort by Time Difference" timezoneNameSortButton.title = "Sort by Name" labelSortButton.title = "Sort by Label" } @objc func refreshTimezoneTableView() { OperationQueue.main.addOperation { self.build() } } private func refreshMainTable() { OperationQueue.main.addOperation { self.refresh() } } private func refresh() { if DataStore.shared().shouldDisplay(ViewType.showAppInForeground) { updateFloatingWindow() } else { guard let panel = PanelController.panel() else { return } panel.updateDefaultPreferences() panel.updateTableContent() } } private func updateFloatingWindow() { let current = FloatingWindowController.shared() current.updateDefaultPreferences() current.updateTableContent() } private func build() { if DataStore.shared().timezones() == [] { housekeeping() return } if selectedTimeZones.isEmpty == false { headerView.isHidden = false if tableview.subviews.count > 1, let zeroView = notimezoneView, tableview.subviews.contains(zeroView) { zeroView.removeFromSuperview() timezoneTableView.enclosingScrollView?.isHidden = false } timezoneTableView.reloadData() } else { housekeeping() } cleanup() } private func housekeeping() { timezoneTableView.enclosingScrollView?.isHidden = true headerView.isHidden = true showNoTimezoneState() cleanup() return } private func cleanup() { timezoneTableView.scrollRowToVisible(selectedTimeZones.count - 1) updateMenubarTitles() // Update the menubar titles, the custom labels might have changed. } private func updateMenubarTitles() { let defaultTimezones = DataStore.shared().timezones() UserDefaults.standard.set([], forKey: CLMenubarFavorites) let menubarTimes = defaultTimezones.compactMap { (data) -> TimezoneData? in if let model = TimezoneData.customObject(from: data), model.isFavourite == 1 { return model } return nil } let archivedObjects = menubarTimes.map { (timezone) -> Data in NSKeyedArchiver.archivedData(withRootObject: timezone) } UserDefaults.standard.set(archivedObjects, forKey: CLMenubarFavorites) // Update appereance if in compact menubar mode if let appDelegate = NSApplication.shared.delegate as? AppDelegate { appDelegate.setupMenubarTimer() } } private func setup() { setupAccessibilityIdentifiers() deleteButton.isEnabled = false [placeholderLabel, additionalSortOptions].forEach { $0.isHidden = true } messageLabel.stringValue = CLEmptyString timezoneTableView.registerForDraggedTypes([.dragSession]) progressIndicator.usesThreadedAnimation = true setupLocalizedText() setupColor() startupCheckbox.integerValue = DataStore.shared().retrieve(key: CLStartAtLogin) as? Int ?? 0 } private func setupColor() { let themer = Themer.shared() headerLabel.textColor = themer.mainTextColor() startAtLoginLabel.textColor = Themer.shared().mainTextColor() [timezoneNameSortButton, labelSortButton, timezoneSortButton].forEach { $0?.attributedTitle = NSAttributedString(string: $0?.title ?? CLEmptyString, attributes: [ NSAttributedString.Key.foregroundColor: Themer.shared().mainTextColor(), NSAttributedString.Key.font: NSFont(name: "Avenir-Light", size: 13)!, ]) } addTimezoneButton.image = themer.addImage() deleteButton.image = themer.removeImage() sortToggle.image = themer.additionalPreferencesImage() sortToggle.alternateImage = themer.additionalPreferencesHighlightedImage() } private func setupShortcutObserver() { let defaults = NSUserDefaultsController.shared recorderControl.bind(NSBindingName.value, to: defaults, withKeyPath: "values.globalPing", options: nil) recorderControl.delegate = self } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change _: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) { if let path = keyPath, path == "values.globalPing" { let hotKeyCenter = PTHotKeyCenter.shared() let oldHotKey = hotKeyCenter?.hotKey(withIdentifier: path) hotKeyCenter?.unregisterHotKey(oldHotKey) guard let newObject = object as? NSObject, let newShortcut = newObject.value(forKeyPath: path) as? [AnyHashable: Any] else { assertionFailure("Unable to recognize shortcuts") return } let newHotKey: PTHotKey = PTHotKey(identifier: keyPath, keyCombo: newShortcut, target: self, action: #selector(ping(_:))) hotKeyCenter?.register(newHotKey) } } @objc func ping(_ sender: Any) { guard let delegate = NSApplication.shared.delegate as? AppDelegate else { return } delegate.togglePanel(sender) } private func showNoTimezoneState() { if let zeroView = notimezoneView { notimezoneView?.wantsLayer = true tableview.addSubview(zeroView) Logger.log(object: ["Showing Empty View": "YES"], for: "Showing Empty View") } } private func setupAccessibilityIdentifiers() { timezoneTableView.setAccessibilityIdentifier("TimezoneTableView") availableTimezoneTableView.setAccessibilityIdentifier("AvailableTimezoneTableView") searchField.setAccessibilityIdentifier("AvailableSearchField") timezoneSortButton.setAccessibility("SortByDifference") labelSortButton.setAccessibility("SortByLabelButton") timezoneNameSortButton.setAccessibility("SortByTimezoneName") } override var acceptsFirstResponder: Bool { return true } } extension PreferencesViewController: NSTableViewDataSource, NSTableViewDelegate { func numberOfRows(in _: NSTableView) -> Int { return numberOfSearchResults() } func tableView(_ tableView: NSTableView, isGroupRow row: Int) -> Bool { let currentRowType = finalArray[row] return currentRowType == .timezoneHeader || currentRowType == .cityHeader } func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { print("Should Select Row") let currentRowType = finalArray[row] return !(currentRowType == .timezoneHeader || currentRowType == .cityHeader) } func tableView(_ tableView: NSTableView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet { // print("Selection Indexes for Proposed Selection: \(proposedSelectionIndexes.first!)") return proposedSelectionIndexes } func tableView(_ tableView: NSTableView, viewFor _: NSTableColumn?, row: Int) -> NSView? { let currentRowType = finalArray[row] switch currentRowType { case .timezoneHeader, .cityHeader: return headerCell(tableView, currentRowType) case .timezone: return timezoneCell(tableView, currentRowType, row) case .city: return cityCell(tableView, currentRowType, row) } } private func timezoneCell(_ tableView: NSTableView, _ rowType: RowType, _ row: Int) -> NSView? { if let message = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "resultCell"), owner: self) as? SearchResultTableViewCell { let datasource = searchField.stringValue.isEmpty ? timezoneArray : timezoneFilteredArray guard !datasource.isEmpty else { return nil } let index = searchField.stringValue.isEmpty ? row - 1 : row message.sourceName.stringValue = datasource[index % datasource.count] return message } return nil } private func cityCell(_ tableView: NSTableView, _ rowType: RowType, _ row: Int) -> NSView? { if let cityCell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "resultCell"), owner: self) as? SearchResultTableViewCell { guard let timezoneData = filteredArray[row % filteredArray.count] as? TimezoneData else { assertionFailure() return nil } cityCell.sourceName.stringValue = timezoneData.formattedAddress ?? "Error" return cityCell } return nil } private func headerCell(_ tableView: NSTableView, _ headerType: RowType) -> NSView? { if let message = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "headerCell"), owner: self) as? HeaderTableViewCell { message.headerField.stringValue = headerType == .timezoneHeader ? "Timezones" : "Places" return message } return nil } private func _markAsFavorite(_ dataObject: TimezoneData) { guard let menubarTitles = DataStore.shared().retrieve(key: CLMenubarFavorites) as? [Data] else { return } var mutableArray = menubarTitles let archivedObject = NSKeyedArchiver.archivedData(withRootObject: dataObject) mutableArray.append(archivedObject) UserDefaults.standard.set(mutableArray, forKey: CLMenubarFavorites) if dataObject.customLabel != nil { Logger.log(object: ["label": dataObject.customLabel ?? "Error"], for: "favouriteSelected") } if let appDelegate = NSApplication.shared.delegate as? AppDelegate { appDelegate.setupMenubarTimer() } if mutableArray.count > 1 { showAlertIfMoreThanOneTimezoneHasBeenAddedToTheMenubar() } } private func _unfavourite(_ dataObject: TimezoneData) { guard let menubarTimers = DataStore.shared().retrieve(key: CLMenubarFavorites) as? [Data] else { assertionFailure("Menubar timers is unexpectedly nil") return } Logger.log(object: ["label": dataObject.customLabel ?? "Error"], for: "favouriteRemoved") let filteredMenubars = menubarTimers.filter { guard let current = NSKeyedUnarchiver.unarchiveObject(with: $0) as? TimezoneData else { return false } return current.isEqual(dataObject) == false } UserDefaults.standard.set(filteredMenubars, forKey: CLMenubarFavorites) if let appDelegate = NSApplication.shared.delegate as? AppDelegate, let menubarFavourites = DataStore.shared().retrieve(key: CLMenubarFavorites) as? [Data], menubarFavourites.isEmpty, DataStore.shared().shouldDisplay(.showMeetingInMenubar) == false { appDelegate.invalidateMenubarTimer(true) } if let appDelegate = NSApplication.shared.delegate as? AppDelegate { appDelegate.setupMenubarTimer() } } private func showAlertIfMoreThanOneTimezoneHasBeenAddedToTheMenubar() { let isUITestRunning = ProcessInfo.processInfo.arguments.contains(CLUITestingLaunchArgument) // If we have seen displayed the message before, abort! let haveWeSeenThisMessageBefore = UserDefaults.standard.bool(forKey: CLLongStatusBarWarningMessage) if haveWeSeenThisMessageBefore, !isUITestRunning { return } // If the user is already using the compact mode, abort. if DataStore.shared().shouldDisplay(.menubarCompactMode), !isUITestRunning { return } // Time to display the alert. NSApplication.shared.activate(ignoringOtherApps: true) let infoText = """ Multiple timezones occupy space and if macOS determines Clocker is occupying too much space, it'll hide Clocker entirely! Enable Menubar Compact Mode to fit in more timezones in less space. """ let alert = NSAlert() alert.showsSuppressionButton = true alert.messageText = "More than one location added to the menubar 😅" alert.informativeText = infoText alert.addButton(withTitle: "Enable Compact Mode") alert.addButton(withTitle: "Cancel") let response = alert.runModal() if response.rawValue == 1000 { OperationQueue.main.addOperation { UserDefaults.standard.set(0, forKey: CLMenubarCompactMode) if alert.suppressionButton?.state == NSControl.StateValue.on { UserDefaults.standard.set(true, forKey: CLLongStatusBarWarningMessage) } self.updateStatusBarAppearance() Logger.log(object: ["Context": ">1 Menubar Timezone in Preferences"], for: "Switched to Compact Mode") } } } } extension PreferencesViewController { @objc private func search() { let searchString = searchField.stringValue if searchString.isEmpty { dataTask?.cancel() resetSearchView() return } if dataTask?.state == .running { dataTask?.cancel() } OperationQueue.main.addOperation { if self.availableTimezoneTableView.isHidden { self.availableTimezoneTableView.isHidden = false } self.placeholderLabel.isHidden = false if NetworkManager.isConnected() == false { self.placeholderLabel.placeholderString = PreferencesConstants.noInternetConnectivityError return } self.isActivityInProgress = true self.placeholderLabel.placeholderString = "Searching for \(searchString)" self.dataTask = NetworkManager.task(with: self.generateSearchURL(), completionHandler: { [weak self] response, error in guard let self = self else { return } OperationQueue.main.addOperation { if let errorPresent = error { self.presentError(errorPresent.localizedDescription) return } guard let data = response else { assertionFailure("Data was unexpectedly nil") return } let searchResults = self.decode(from: data) if searchResults?.status == "ZERO_RESULTS" { self.placeholderLabel.placeholderString = "No results! 😔 Try entering the exact name." self.findLocalSearchResultsForTimezones() self.reloadSearchResults() self.isActivityInProgress = false return } self.appendResultsToFilteredArray(searchResults!.results) self.findLocalSearchResultsForTimezones() self.prepareUIForPresentingResults() } }) } } private func findLocalSearchResultsForTimezones() { timezoneFilteredArray = [] let lowercasedSearchString = searchField.stringValue.lowercased() timezoneFilteredArray = timezoneArray.filter { $0.lowercased().contains(lowercasedSearchString) } } private func generateSearchURL() -> String { let userPreferredLanguage = Locale.preferredLanguages.first ?? "en-US" var searchString = searchField.stringValue let words = searchString.components(separatedBy: CharacterSet.whitespacesAndNewlines) searchString = words.joined(separator: CLEmptyString) let url = "https://maps.googleapis.com/maps/api/geocode/json?address=\(searchString)&key=\(CLGeocodingKey)&language=\(userPreferredLanguage)" return url } private func presentError(_ errorMessage: String) { if errorMessage == PreferencesConstants.offlineErrorMessage { placeholderLabel.placeholderString = PreferencesConstants.noInternetConnectivityError } else { placeholderLabel.placeholderString = PreferencesConstants.tryAgainMessage } isActivityInProgress = false } 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.filteredArray.append(TimezoneData(with: totalPackage)) } } private func prepareUIForPresentingResults() { placeholderLabel.placeholderString = CLEmptyString isActivityInProgress = false reloadSearchResults() } private func reloadSearchResults() { print("Reloading Search Results") calculateArray() availableTimezoneTableView.reloadData() } // 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 } } // 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 resetSearchView() { if dataTask?.state == .running { dataTask?.cancel() } isActivityInProgress = false placeholderLabel.placeholderString = CLEmptyString } private func getTimezone(for latitude: Double, and longitude: Double) { if placeholderLabel.isHidden { placeholderLabel.isHidden = false } if NetworkManager.isConnected() == false || ProcessInfo.processInfo.arguments.contains("mockTimezoneDown") { resetStateAndShowDisconnectedMessage() return } searchField.placeholderString = "Fetching data might take some time!" placeholderLabel.placeholderString = "Retrieving timezone data" availableTimezoneTableView.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 { self.reloadSearchResults() return } if error == nil, let json = response, let timezone = self.decodeTimezone(from: json) { if self.availableTimezoneTableView.selectedRow >= 0 { self.installTimezone(timezone) } self.updateViewState() } else { OperationQueue.main.addOperation { if error?.localizedDescription == "The Internet connection appears to be offline." { self.placeholderLabel.placeholderString = PreferencesConstants.noInternetConnectivityError } else { self.placeholderLabel.placeholderString = PreferencesConstants.tryAgainMessage } self.isActivityInProgress = false } } } } } private func installTimezone(_ timezone: Timezone) { guard let dataObject = self.filteredArray[self.availableTimezoneTableView.selectedRow % filteredArray.count] as? TimezoneData else { assertionFailure("Data was unexpectedly nil") return } var filteredAddress = "Error" if let address = dataObject.formattedAddress { filteredAddress = address.filteredName() } let newTimeZone = [ CLTimezoneID: timezone.timeZoneId, CLTimezoneName: filteredAddress, CLPlaceIdentifier: dataObject.placeID!, "latitude": dataObject.latitude!, "longitude": dataObject.longitude!, "nextUpdate": CLEmptyString, CLCustomLabel: filteredAddress, ] as [String: Any] let timezoneObject = TimezoneData(with: newTimeZone) let operationsObject = TimezoneDataOperations(with: timezoneObject) operationsObject.saveObject() Logger.log(object: ["PlaceName": filteredAddress, "Timezone": timezone.timeZoneId], for: "Filtered Address") } private func resetStateAndShowDisconnectedMessage() { OperationQueue.main.addOperation { self.showMessage() } } private func showMessage() { placeholderLabel.placeholderString = PreferencesConstants.noInternetConnectivityError isActivityInProgress = false filteredArray = [] reloadSearchResults() } /// Returns true if there's an error. private func handleEdgeCase(for response: Data?) -> Bool { guard let json = response, let jsonUnserialized = try? JSONSerialization.jsonObject(with: json, options: .allowFragments), let unwrapped = jsonUnserialized as? [String: Any] else { setErrorPlaceholders() return false } if let status = unwrapped["status"] as? String, status == "ZERO_RESULTS" { setErrorPlaceholders() return true } return false } private func setErrorPlaceholders() { placeholderLabel.placeholderString = "No timezone found! Try entering an exact name." searchField.placeholderString = "Enter a city, state or country name" isActivityInProgress = false } private func updateViewState() { filteredArray = [] reloadSearchResults() refreshTimezoneTableView() refreshMainTable() timezonePanel.close() placeholderLabel.placeholderString = CLEmptyString searchField.placeholderString = "Enter a city, state or country name" availableTimezoneTableView.isHidden = false isActivityInProgress = false } private func setupTimezoneDatasource() { timezoneArray = [] timezoneArray.append("UTC") timezoneArray.append("Anywhere on Earth") timezoneArray.append(contentsOf: NSTimeZone.knownTimeZoneNames) } @IBAction func addTimeZone(_: NSButton) { filteredArray = [] view.window?.beginSheet(timezonePanel, completionHandler: nil) } @IBAction func addToFavorites(_: NSButton) { isActivityInProgress = true if availableTimezoneTableView.selectedRow == -1 { messageLabel.stringValue = PreferencesConstants.noTimezoneSelectedErrorMessage Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { _ in OperationQueue.main.addOperation { self.messageLabel.stringValue = CLEmptyString } } isActivityInProgress = false return } if selectedTimeZones.count >= 100 { messageLabel.stringValue = PreferencesConstants.maxTimezonesErrorMessage Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { _ in OperationQueue.main.addOperation { self.messageLabel.stringValue = CLEmptyString } } isActivityInProgress = false return } if searchField.stringValue.isEmpty { addTimezoneIfSearchStringIsEmpty() } else { addTimezoneIfSearchStringIsNotEmpty() } } private func addTimezoneIfSearchStringIsEmpty() { let currentRowType = finalArray[availableTimezoneTableView.selectedRow] switch currentRowType { case .timezoneHeader, .cityHeader: isActivityInProgress = false return case .timezone: cleanupAfterInstallingTimezone() default: return } } private func addTimezoneIfSearchStringIsNotEmpty() { let currentRowType = finalArray[availableTimezoneTableView.selectedRow] switch currentRowType { case .timezoneHeader, .cityHeader: isActivityInProgress = false return case .timezone: cleanupAfterInstallingTimezone() case .city: cleanupAfterInstallingCity() } } private func cleanupAfterInstallingCity() { guard let dataObject = filteredArray[availableTimezoneTableView.selectedRow % filteredArray.count] as? TimezoneData else { assertionFailure("Data was unexpectedly nil") return } if messageLabel.stringValue.isEmpty { searchField.stringValue = CLEmptyString guard let latitude = dataObject.latitude, let longitude = dataObject.longitude else { assertionFailure("Data was unexpectedly nil") return } getTimezone(for: latitude, and: longitude) } } private func cleanupAfterInstallingTimezone() { let data = TimezoneData() data.setLabel(CLEmptyString) if searchField.stringValue.isEmpty == false { let currentSelection = timezoneFilteredArray[availableTimezoneTableView.selectedRow % timezoneFilteredArray.count] let metaInfo = metadata(for: currentSelection) data.timezoneID = metaInfo.0 data.formattedAddress = metaInfo.1 } else { let currentSelection = timezoneArray[availableTimezoneTableView.selectedRow - 1] let metaInfo = metadata(for: currentSelection) data.timezoneID = metaInfo.0 data.formattedAddress = metaInfo.1 } data.selectionType = .timezone let operationObject = TimezoneDataOperations(with: data) operationObject.saveObject() filteredArray = [] timezoneFilteredArray = [] reloadSearchResults() refreshTimezoneTableView() refreshMainTable() timezonePanel.close() placeholderLabel.placeholderString = CLEmptyString searchField.stringValue = CLEmptyString searchField.placeholderString = "Enter a city, state or country name" availableTimezoneTableView.isHidden = false isActivityInProgress = false } private func metadata(for selection: String) -> (String, String) { if selection == "Anywhere on Earth" { return ("GMT-1200", selection) } else if selection == "UTC" { return ("GMT", selection) } else { return (selection, selection) } } @IBAction func closePanel(_: NSButton) { filteredArray = [] timezoneFilteredArray = [] searchField.stringValue = CLEmptyString placeholderLabel.placeholderString = CLEmptyString searchField.placeholderString = "Enter a city, state or country name" reloadSearchResults() timezonePanel.close() isActivityInProgress = false addTimezoneButton.state = .off // The table might be hidden because of an early exit especially // if we are not able to fetch an associated timezone // For eg. Europe doesn't have an associated timezone availableTimezoneTableView.isHidden = false } @IBAction func removeFromFavourites(_: NSButton) { // If the user is editing a row, and decides to delete the row then we have a crash if timezoneTableView.editedRow != -1 || timezoneTableView.editedColumn != -1 { return } if timezoneTableView.selectedRow == -1, selectedTimeZones.count <= timezoneTableView.selectedRow { assertionFailure("Data was unexpectedly nil") return } let currentObject = selectedTimeZones[timezoneTableView.selectedRow] guard let model = TimezoneData.customObject(from: currentObject) else { assertionFailure("Data was unexpectedly nil") return } if model.isFavourite == 1 { removeFromMenubarFavourites(object: model) } var newDefaults = selectedTimeZones let objectsToRemove = timezoneTableView.selectedRowIndexes.map { (index) -> Data in selectedTimeZones[index] } newDefaults = newDefaults.filter { !objectsToRemove.contains($0) } DataStore.shared().setTimezones(newDefaults) timezoneTableView.reloadData() refreshTimezoneTableView() refreshMainTable() if selectedTimeZones.isEmpty { UserDefaults.standard.set(nil, forKey: CLMenubarFavorites) } updateStatusBarAppearance() updateStatusItem() } // TODO: This probably does not need to be used private func updateStatusItem() { guard let statusItem = (NSApplication.shared.delegate as? AppDelegate)?.statusItemForPanel() else { return } statusItem.performTimerWork() } private func updateStatusBarAppearance() { guard let statusItem = (NSApplication.shared.delegate as? AppDelegate)?.statusItemForPanel() else { return } statusItem.setupStatusItem() } private func removeFromMenubarFavourites(object: TimezoneData?) { guard let model = object else { assertionFailure("Data was unexpectedly nil") return } if model.isFavourite == 1 { if let menubarTitles = DataStore.shared().retrieve(key: CLMenubarFavorites) as? [Data] { let updated = menubarTitles.filter { (data) -> Bool in let current = TimezoneData.customObject(from: data) return current != model } UserDefaults.standard.set(updated, forKey: CLMenubarFavorites) } } } @IBAction func filterTimezoneArray(_: Any?) { let lowercasedSearchString = searchField.stringValue.lowercased() timezoneFilteredArray = timezoneArray.filter { $0.lowercased().contains(lowercasedSearchString) } } @IBAction func filterArray(_: Any?) { messageLabel.stringValue = CLEmptyString filteredArray = [] if searchField.stringValue.count > 50 { isActivityInProgress = false messageLabel.stringValue = PreferencesConstants.maxCharactersAllowed reloadSearchResults() Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { _ in OperationQueue.main.addOperation { self.messageLabel.stringValue = CLEmptyString } } return } if searchField.stringValue.isEmpty == false { dataTask?.cancel() NSObject.cancelPreviousPerformRequests(withTarget: self) perform(#selector(search), with: nil, afterDelay: 0.5) } else { resetSearchView() } reloadSearchResults() } } extension PreferencesViewController { @IBAction func loginPreferenceChanged(_ sender: NSButton) { startupManager.toggleLogin(sender) } } // Sorting extension PreferencesViewController { @IBAction func sortOptions(_: NSButton) { additionalSortOptions.isHidden.toggle() } @IBAction func sortByTime(_ sender: NSButton) { let sortedByTime = selectedTimeZones.sorted { (obj1, obj2) -> Bool in let system = NSTimeZone.system guard let object1 = TimezoneData.customObject(from: obj1), let object2 = TimezoneData.customObject(from: obj2) else { assertionFailure("Data was unexpectedly nil") return false } let timezone1 = NSTimeZone(name: object1.timezoneID!) let timezone2 = NSTimeZone(name: object2.timezoneID!) let difference1 = system.secondsFromGMT() - timezone1!.secondsFromGMT let difference2 = system.secondsFromGMT() - timezone2!.secondsFromGMT return arePlacesSortedInAscendingTimezoneOrder ? difference1 > difference2 : difference1 < difference2 } sender.image = arePlacesSortedInAscendingTimezoneOrder ? NSImage(named: NSImage.Name("NSDescendingSortIndicator"))! : NSImage(named: NSImage.Name("NSAscendingSortIndicator"))! arePlacesSortedInAscendingTimezoneOrder.toggle() DataStore.shared().setTimezones(sortedByTime) updateAfterSorting() } @IBAction func sortByLabel(_ sender: NSButton) { let sortedLabels = selectedTimeZones.sorted { (obj1, obj2) -> Bool in guard let object1 = TimezoneData.customObject(from: obj1), let object2 = TimezoneData.customObject(from: obj2) else { assertionFailure("Data was unexpectedly nil") return false } return isLabelOptionSelected ? object1.customLabel! > object2.customLabel! : object1.customLabel! < object2.customLabel! } sender.image = isLabelOptionSelected ? NSImage(named: NSImage.Name("NSDescendingSortIndicator"))! : NSImage(named: NSImage.Name("NSAscendingSortIndicator"))! isLabelOptionSelected.toggle() DataStore.shared().setTimezones(sortedLabels) updateAfterSorting() } @IBAction func sortByFormattedAddress(_ sender: NSButton) { let sortedByAddress = selectedTimeZones.sorted { (obj1, obj2) -> Bool in guard let object1 = TimezoneData.customObject(from: obj1), let object2 = TimezoneData.customObject(from: obj2) else { assertionFailure("Data was unexpectedly nil") return false } return isTimezoneNameSortOptionSelected ? object1.formattedAddress! > object2.formattedAddress! : object1.formattedAddress! < object2.formattedAddress! } sender.image = isTimezoneNameSortOptionSelected ? NSImage(named: NSImage.Name("NSDescendingSortIndicator"))! : NSImage(named: NSImage.Name("NSAscendingSortIndicator"))! isTimezoneNameSortOptionSelected.toggle() DataStore.shared().setTimezones(sortedByAddress) updateAfterSorting() } private func updateAfterSorting() { let newDefaults = selectedTimeZones DataStore.shared().setTimezones(newDefaults) refreshTimezoneTableView() refreshMainTable() } } extension PreferencesViewController: SRRecorderControlDelegate {} // Helpers extension PreferencesViewController { private func numberOfSearchResults() -> Int { return finalArray.count } private func insert(timezone: TimezoneData, at index: Int) { let encodedObject = NSKeyedArchiver.archivedData(withRootObject: timezone) var newDefaults = selectedTimeZones newDefaults[index] = encodedObject DataStore.shared().setTimezones(newDefaults) } } class SearchResultTableViewCell: NSTableCellView { @IBOutlet var sourceName: NSTextField! } class HeaderTableViewCell: NSTableCellView { @IBOutlet var headerField: NSTextField! } extension PreferencesViewController: PreferenceSelectionUpdates { func markAsFavorite(_ dataObject: TimezoneData) { _markAsFavorite(dataObject) } func unfavourite(_ dataObject: TimezoneData) { _unfavourite(dataObject) } func refreshTimezoneTable() { refreshTimezoneTableView() } func refreshMainTableView() { refreshMainTable() } func tableViewSelectionDidChange(_ status: Bool) { deleteButton.isEnabled = !status } func table(didClick tableColumn: NSTableColumn) { if tableColumn.identifier.rawValue == "favouriteTimezone" { return } let sortedTimezones = selectedTimeZones.sorted { (obj1, obj2) -> Bool in guard let object1 = TimezoneData.customObject(from: obj1), let object2 = TimezoneData.customObject(from: obj2) else { assertionFailure("Data was unexpectedly nil") return false } if tableColumn.identifier.rawValue == "formattedAddress" { return arePlacesSortedInAscendingOrder ? object1.formattedAddress! > object2.formattedAddress! : object1.formattedAddress! < object2.formattedAddress! } else { return arePlacesSortedInAscendingOrder ? object1.customLabel! > object2.customLabel! : object1.customLabel! < object2.customLabel! } } let indicatorImage = arePlacesSortedInAscendingOrder ? NSImage(named: NSImage.Name("NSDescendingSortIndicator"))! : NSImage(named: NSImage.Name("NSAscendingSortIndicator"))! timezoneTableView.setIndicatorImage(indicatorImage, in: tableColumn) arePlacesSortedInAscendingOrder.toggle() DataStore.shared().setTimezones(sortedTimezones) updateAfterSorting() } }