|
|
|
// Copyright © 2015 Abhishek Banthia
|
|
|
|
|
|
|
|
import Cocoa
|
|
|
|
import CoreLocation
|
|
|
|
import CoreLoggerKit
|
|
|
|
import CoreModelKit
|
|
|
|
|
|
|
|
class TimezoneDataOperations: NSObject {
|
|
|
|
private let dataObject: TimezoneData
|
|
|
|
private let store: DataStore
|
|
|
|
private lazy var nsCalendar = Calendar.autoupdatingCurrent
|
|
|
|
private static var gregorianCalendar = NSCalendar(calendarIdentifier: NSCalendar.Identifier.gregorian)
|
|
|
|
private static var swiftyCalendar = Calendar(identifier: .gregorian)
|
|
|
|
private static let currentLocale = Locale.current.identifier
|
|
|
|
|
|
|
|
init(with timezone: TimezoneData, store: DataStore) {
|
|
|
|
dataObject = timezone
|
|
|
|
self.store = store
|
|
|
|
super.init()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
extension TimezoneDataOperations {
|
|
|
|
func time(with sliderValue: Int) -> String {
|
|
|
|
guard let newDate = TimezoneDataOperations.gregorianCalendar?.date(byAdding: .minute,
|
|
|
|
value: sliderValue,
|
|
|
|
to: Date(),
|
|
|
|
options: .matchFirst)
|
|
|
|
else {
|
|
|
|
assertionFailure("Data was unexpectedly nil")
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
if dataObject.timezoneFormat(store.timezoneFormat()) == DateFormat.epochTime {
|
|
|
|
let timezone = TimeZone(identifier: dataObject.timezone())
|
|
|
|
let offset = timezone?.secondsFromGMT(for: newDate) ?? 0
|
|
|
|
let value = Int(Date().timeIntervalSince1970 + Double(offset))
|
|
|
|
return "\(value)"
|
|
|
|
}
|
|
|
|
|
|
|
|
let dateFormatter = DateFormatterManager.dateFormatterWithFormat(with: .none,
|
|
|
|
format: dataObject.timezoneFormat(store.timezoneFormat()),
|
|
|
|
timezoneIdentifier: dataObject.timezone(),
|
|
|
|
locale: Locale.autoupdatingCurrent)
|
|
|
|
|
|
|
|
return dateFormatter.string(from: newDate)
|
|
|
|
}
|
|
|
|
|
|
|
|
func nextDaylightSavingsTransitionIfAvailable(with sliderValue: Int) -> String? {
|
|
|
|
let currentTimezone = TimeZone(identifier: dataObject.timezone())
|
|
|
|
guard let nextDaylightSavingsTransition = currentTimezone?.nextDaylightSavingTimeTransition else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let newDate = TimezoneDataOperations.gregorianCalendar?.date(byAdding: .minute,
|
|
|
|
value: sliderValue,
|
|
|
|
to: Date(),
|
|
|
|
options: .matchFirst)
|
|
|
|
else {
|
|
|
|
assertionFailure("Data was unexpectedly nil")
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
let calendar = Calendar.autoupdatingCurrent
|
|
|
|
let numberOfDays = nextDaylightSavingsTransition.days(from: newDate, calendar: calendar)
|
|
|
|
|
|
|
|
// We'd like to show upcoming DST changes within the 7 day range.
|
|
|
|
// Using 8 as a fail-safe as timezones behind CDT can sometimes be wrongly attributed
|
|
|
|
if numberOfDays > 8 || numberOfDays < 0 {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if numberOfDays == 0 {
|
|
|
|
let hoursLeft = nextDaylightSavingsTransition.hours(from: newDate)
|
|
|
|
let suffix = hoursLeft == 1 ? "hour" : "hours"
|
|
|
|
return "Heads up! DST transition will occur in \(hoursLeft) \(suffix)."
|
|
|
|
}
|
|
|
|
|
|
|
|
let suffix = numberOfDays == 1 ? "day" : "days"
|
|
|
|
return "Heads up! DST transition will occur in \(numberOfDays) \(suffix)."
|
|
|
|
}
|
|
|
|
|
|
|
|
func compactMenuTitle() -> String {
|
|
|
|
var subtitle = CLEmptyString
|
|
|
|
|
|
|
|
let shouldDayBeShown = store.shouldShowDayInMenubar()
|
|
|
|
let shouldLabelBeShownAlongWithTime = !store.shouldDisplay(.placeInMenubar)
|
|
|
|
|
|
|
|
if shouldDayBeShown, shouldLabelBeShownAlongWithTime {
|
|
|
|
let substring = date(with: 0, displayType: .menu)
|
|
|
|
subtitle.append(substring)
|
|
|
|
}
|
|
|
|
|
|
|
|
let shouldDateBeShown = store.shouldShowDateInMenubar()
|
|
|
|
if shouldDateBeShown, shouldLabelBeShownAlongWithTime {
|
|
|
|
let date = Date().formatter(with: "MMM d", timeZone: dataObject.timezone())
|
|
|
|
subtitle.isEmpty ? subtitle.append("\(date)") : subtitle.append(" \(date)")
|
|
|
|
}
|
|
|
|
|
|
|
|
return subtitle.isEmpty ? dataObject.formattedTimezoneLabel() : subtitle
|
|
|
|
}
|
|
|
|
|
|
|
|
func compactMenuSubtitle() -> String {
|
|
|
|
var subtitle = CLEmptyString
|
|
|
|
|
|
|
|
let shouldDayBeShown = store.shouldShowDayInMenubar()
|
|
|
|
let shouldLabelsNotBeShownAlongWithTime = store.shouldDisplay(.placeInMenubar)
|
|
|
|
|
|
|
|
if shouldDayBeShown, shouldLabelsNotBeShownAlongWithTime {
|
|
|
|
let substring = date(with: 0, displayType: .menu)
|
|
|
|
subtitle.append(substring)
|
|
|
|
}
|
|
|
|
|
|
|
|
let shouldDateBeShown = store.shouldShowDateInMenubar()
|
|
|
|
if shouldDateBeShown, shouldLabelsNotBeShownAlongWithTime {
|
|
|
|
let date = Date().formatter(with: "MMM d", timeZone: dataObject.timezone())
|
|
|
|
subtitle.isEmpty ? subtitle.append("\(date)") : subtitle.append(" \(date)")
|
|
|
|
}
|
|
|
|
|
|
|
|
subtitle.isEmpty ? subtitle.append(time(with: 0)) : subtitle.append(" \(time(with: 0))")
|
|
|
|
|
|
|
|
return subtitle
|
|
|
|
}
|
|
|
|
|
|
|
|
func menuTitle() -> String {
|
|
|
|
var menuTitle = CLEmptyString
|
|
|
|
|
|
|
|
let dataStore = store
|
|
|
|
|
|
|
|
let shouldCityBeShown = dataStore.shouldDisplay(.placeInMenubar)
|
|
|
|
let shouldDayBeShown = dataStore.shouldShowDayInMenubar()
|
|
|
|
let shouldDateBeShown = dataStore.shouldShowDateInMenubar()
|
|
|
|
|
|
|
|
if shouldCityBeShown {
|
|
|
|
if let address = dataObject.formattedAddress, address.isEmpty == false {
|
|
|
|
if let label = dataObject.customLabel {
|
|
|
|
label.isEmpty == false ? menuTitle.append(label) : menuTitle.append(address)
|
|
|
|
} else {
|
|
|
|
menuTitle.append(address)
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
if let label = dataObject.customLabel {
|
|
|
|
label.isEmpty == false ? menuTitle.append(label) : menuTitle.append(dataObject.timezone())
|
|
|
|
} else {
|
|
|
|
menuTitle.append(dataObject.timezone())
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if shouldDayBeShown {
|
|
|
|
var substring = date(with: 0, displayType: .menu)
|
|
|
|
|
|
|
|
if substring.count > 3 {
|
|
|
|
let endIndex = substring.index(substring.startIndex, offsetBy: 2)
|
|
|
|
substring = String(substring[substring.startIndex ... endIndex])
|
|
|
|
}
|
|
|
|
|
|
|
|
if menuTitle.isEmpty == false {
|
|
|
|
menuTitle.append(" \(substring.capitalized)")
|
|
|
|
} else {
|
|
|
|
menuTitle.append(substring.capitalized)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if shouldDateBeShown {
|
|
|
|
let date = Date().formatter(with: "MMM d", timeZone: dataObject.timezone())
|
|
|
|
if menuTitle.isEmpty == false {
|
|
|
|
menuTitle.append(" \(date)")
|
|
|
|
} else {
|
|
|
|
menuTitle.append("\(date)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
menuTitle.isEmpty == false ? menuTitle.append(" \(time(with: 0))") : menuTitle.append(time(with: 0))
|
|
|
|
|
|
|
|
return menuTitle
|
|
|
|
}
|
|
|
|
|
|
|
|
private func timezoneDate(with sliderValue: Int, _ calendar: Calendar) -> Date {
|
|
|
|
let source = timezoneDateByAdding(minutesToAdd: sliderValue, calendar)
|
|
|
|
let sourceTimezone = TimeZone.current
|
|
|
|
let destinationTimezone = TimeZone(identifier: dataObject.timezone())
|
|
|
|
|
|
|
|
let sourceGMTOffset = Double(sourceTimezone.secondsFromGMT(for: source))
|
|
|
|
let destinationGMTOffset = Double(destinationTimezone?.secondsFromGMT(for: source) ?? 0)
|
|
|
|
let interval = destinationGMTOffset - sourceGMTOffset
|
|
|
|
|
|
|
|
return Date(timeInterval: interval, since: source)
|
|
|
|
}
|
|
|
|
|
|
|
|
// calendar.dateByAdding takes a 0.1% or 0.2% according to TimeProfiler
|
|
|
|
// Let's not use it unless neccesary!
|
|
|
|
private func timezoneDateByAdding(minutesToAdd: Int, _ calendar: Calendar?) -> Date {
|
|
|
|
if minutesToAdd == 0 {
|
|
|
|
return Date()
|
|
|
|
}
|
|
|
|
|
|
|
|
return calendar?.date(byAdding: .minute,
|
|
|
|
value: minutesToAdd,
|
|
|
|
to: Date()) ?? Date()
|
|
|
|
}
|
|
|
|
|
|
|
|
func date(with sliderValue: Int, displayType: TimezoneData.DateDisplayType) -> String {
|
|
|
|
guard let relativeDayPreference = store.retrieve(key: CLRelativeDateKey) as? NSNumber else {
|
|
|
|
assertionFailure("Data was unexpectedly nil")
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
if relativeDayPreference.intValue == 3 {
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
var currentCalendar = Calendar(identifier: .gregorian)
|
|
|
|
currentCalendar.locale = Locale.autoupdatingCurrent
|
|
|
|
|
|
|
|
let convertedDate = timezoneDate(with: sliderValue, currentCalendar)
|
|
|
|
|
|
|
|
if displayType == .panel {
|
|
|
|
// Yesterday, tomorrow, etc
|
|
|
|
if relativeDayPreference.intValue == 0 {
|
|
|
|
let localFormatter = DateFormatterManager.localizedSimpleFormatter("EEEE")
|
|
|
|
let local = localFormatter.date(from: localeDate(with: "EEEE"))
|
|
|
|
|
|
|
|
// Gets local week day number and timezone's week day number for comparison
|
|
|
|
let weekDay = currentCalendar.component(.weekday, from: local!)
|
|
|
|
let timezoneWeekday = currentCalendar.component(.weekday, from: convertedDate)
|
|
|
|
|
|
|
|
if weekDay == timezoneWeekday + 1 {
|
|
|
|
return "Yesterday\(timeDifference())"
|
|
|
|
} else if weekDay == timezoneWeekday {
|
|
|
|
return "Today\(timeDifference())"
|
|
|
|
} else if weekDay + 1 == timezoneWeekday || weekDay - 6 == timezoneWeekday {
|
|
|
|
return "Tomorrow\(timeDifference())"
|
|
|
|
} else {
|
|
|
|
return "\(weekdayText(from: convertedDate))\(timeDifference())"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Day name: Thursday, Friday etc
|
|
|
|
if relativeDayPreference.intValue == 1 {
|
|
|
|
return "\(weekdayText(from: convertedDate))\(timeDifference())"
|
|
|
|
}
|
|
|
|
|
|
|
|
// Date in mmm/dd
|
|
|
|
if relativeDayPreference.intValue == 2 {
|
|
|
|
return "\(todaysDate(with: sliderValue))\(timeDifference())"
|
|
|
|
}
|
|
|
|
|
|
|
|
let errorDictionary: [String: Any] = ["Timezone": dataObject.timezone(),
|
|
|
|
"Current Locale": Locale.autoupdatingCurrent.identifier,
|
|
|
|
"Slider Value": sliderValue,
|
|
|
|
"Today's Date": Date()]
|
|
|
|
Logger.log(object: errorDictionary, for: "Unable to get date")
|
|
|
|
|
|
|
|
return "Error"
|
|
|
|
|
|
|
|
} else {
|
|
|
|
return "\(shortWeekdayText(convertedDate))"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns shortened weekday given a date
|
|
|
|
// For eg. Thu or Thursday, Tues for Tuesday etc
|
|
|
|
private func shortWeekdayText(_ date: Date) -> String {
|
|
|
|
let localizedFormatter = DateFormatterManager.localizedSimpleFormatter("E")
|
|
|
|
return localizedFormatter.string(from: date)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Returns proper weekday given a date
|
|
|
|
// For eg. Thursday, Sunday, Friday etc
|
|
|
|
private func weekdayText(from date: Date) -> String {
|
|
|
|
let dateFormatter = DateFormatterManager.localizedFormatter(with: "EEEE", for: TimeZone.current.identifier)
|
|
|
|
return dateFormatter.string(from: date)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Exposed to public for tests!
|
|
|
|
public func timeDifference() -> String {
|
|
|
|
let localFormatter = DateFormatterManager.localizedSimpleFormatter("d MMM yyyy HH:mm:ss")
|
|
|
|
let local = localFormatter.date(from: localeDate(with: "d MMM yyyy HH:mm:ss"))!
|
|
|
|
let newDate = timezoneDateByAdding(minutesToAdd: 0, TimezoneDataOperations.swiftyCalendar)
|
|
|
|
|
|
|
|
let dateFormatter = DateFormatterManager.localizedFormatter(with: "d MMM yyyy HH:mm:ss", for: dataObject.timezone())
|
|
|
|
|
|
|
|
guard let timezoneDate = localFormatter.date(from: dateFormatter.string(from: newDate)) else {
|
|
|
|
let unableToConvertDateParameters = [
|
|
|
|
"New Date": newDate,
|
|
|
|
"Timezone": dataObject.timezone(),
|
|
|
|
"Locale": dateFormatter.locale.identifier,
|
|
|
|
] as [String: Any]
|
|
|
|
Logger.log(object: unableToConvertDateParameters, for: "Date conversion failure - New Date is nil")
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
let timeDifference = local.timeAgo(since: timezoneDate)
|
|
|
|
|
|
|
|
if timeDifference.isEmpty {
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
if (local as NSDate).earlierDate(timezoneDate) == local {
|
|
|
|
var replaceAgo = CLEmptyString
|
|
|
|
replaceAgo.append(", ")
|
|
|
|
let agoString = timezoneDate.timeAgo(since: local, numericDates: true)
|
|
|
|
replaceAgo.append(agoString.replacingOccurrences(of: "ago", with: CLEmptyString))
|
|
|
|
|
|
|
|
if !TimezoneDataOperations.currentLocale.contains("en") {
|
|
|
|
if TimezoneDataOperations.currentLocale.contains("de") {
|
|
|
|
replaceAgo = replaceAgo.replacingOccurrences(of: "Vor ", with: CLEmptyString)
|
|
|
|
replaceAgo.append(" vor")
|
|
|
|
}
|
|
|
|
|
|
|
|
return replaceAgo
|
|
|
|
}
|
|
|
|
|
|
|
|
let minuteDifference = calculateTimeDifference(with: local as NSDate, timezoneDate: timezoneDate as NSDate)
|
|
|
|
minuteDifference == 0 ? replaceAgo.append("ahead") : replaceAgo.append("\(minuteDifference)m ahead")
|
|
|
|
return replaceAgo.lowercased()
|
|
|
|
}
|
|
|
|
|
|
|
|
var replaceAgo = CLEmptyString
|
|
|
|
replaceAgo.append(", ")
|
|
|
|
|
|
|
|
let replaced = timeDifference.replacingOccurrences(of: "ago", with: CLEmptyString)
|
|
|
|
replaceAgo.append(replaced)
|
|
|
|
|
|
|
|
if !TimezoneDataOperations.currentLocale.contains("en") {
|
|
|
|
if TimezoneDataOperations.currentLocale.contains("de") {
|
|
|
|
replaceAgo = replaceAgo.replacingOccurrences(of: "Vor ", with: CLEmptyString)
|
|
|
|
replaceAgo.append(" zurück")
|
|
|
|
}
|
|
|
|
|
|
|
|
return replaceAgo
|
|
|
|
}
|
|
|
|
|
|
|
|
let minuteDifference = calculateTimeDifference(with: local as NSDate, timezoneDate: timezoneDate as NSDate)
|
|
|
|
|
|
|
|
minuteDifference == 0 ? replaceAgo.append("behind") : replaceAgo.append("\(minuteDifference)m behind")
|
|
|
|
return replaceAgo.lowercased()
|
|
|
|
}
|
|
|
|
|
|
|
|
private func initializeSunriseSunset(with sliderValue: Int) {
|
|
|
|
let currentDate = nsCalendar.date(byAdding: .minute,
|
|
|
|
value: sliderValue,
|
|
|
|
to: Date())
|
|
|
|
|
|
|
|
guard let lat = dataObject.latitude,
|
|
|
|
let long = dataObject.longitude
|
|
|
|
else {
|
|
|
|
assertionFailure("Data was unexpectedly nil.")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
let coordinates = CLLocationCoordinate2D(latitude: lat, longitude: long)
|
|
|
|
|
|
|
|
guard let dateForCalculation = currentDate, let solar = Solar(for: dateForCalculation, coordinate: coordinates) else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if let sunrise = solar.sunrise, let sunset = solar.sunset {
|
|
|
|
dataObject.sunriseTime = sunrise
|
|
|
|
dataObject.sunsetTime = sunset
|
|
|
|
dataObject.isSunriseOrSunset = solar.isNighttime
|
|
|
|
} else {
|
|
|
|
Logger.log(object: ["Unable to fetch sunrise/sunset": dataObject.formattedTimezoneLabel()], for: "Sunrise/Sunset Error")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private func calculateTimeDifference(with localDate: NSDate, timezoneDate: NSDate) -> Int {
|
|
|
|
let earliest = localDate.earlierDate(timezoneDate as Date)
|
|
|
|
let latest = earliest == localDate as Date ? timezoneDate : localDate
|
|
|
|
|
|
|
|
// if timeAgo < 24h => compare DateTime else compare Date only
|
|
|
|
let upToHours: Set<Calendar.Component> = [.second, .minute, .hour]
|
|
|
|
let difference = nsCalendar.dateComponents(upToHours, from: earliest, to: latest as Date)
|
|
|
|
return difference.minute!
|
|
|
|
}
|
|
|
|
|
|
|
|
func formattedSunriseTime(with sliderValue: Int) -> String {
|
|
|
|
/* We have to call this everytime so that we get an updated value everytime! */
|
|
|
|
|
|
|
|
if dataObject.selectionType == .timezone || (dataObject.latitude == nil || dataObject.longitude == nil) {
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
initializeSunriseSunset(with: sliderValue)
|
|
|
|
|
|
|
|
if let sunrise = dataObject.sunriseTime, let sunset = dataObject.sunsetTime {
|
|
|
|
let correct = dataObject.isSunriseOrSunset ? sunrise : sunset
|
|
|
|
|
|
|
|
let dateFormatter = DateFormatter()
|
|
|
|
dateFormatter.locale = Locale(identifier: "en_US")
|
|
|
|
dateFormatter.timeZone = TimeZone(identifier: dataObject.timezone())
|
|
|
|
dateFormatter.dateFormat = dataObject.timezoneFormat(store.timezoneFormat())
|
|
|
|
return dateFormatter.string(from: correct)
|
|
|
|
}
|
|
|
|
|
|
|
|
return CLEmptyString
|
|
|
|
}
|
|
|
|
|
|
|
|
func todaysDate(with sliderValue: Int, locale: Locale = Locale(identifier: "en-US")) -> String {
|
|
|
|
let newDate = TimezoneDataOperations.gregorianCalendar?.date(byAdding: .minute,
|
|
|
|
value: sliderValue,
|
|
|
|
to: Date(),
|
|
|
|
options: .matchFirst)
|
|
|
|
|
|
|
|
let date = newDate!.formatter(with: "MMM d", timeZone: dataObject.timezone(), locale: locale)
|
|
|
|
|
|
|
|
return date
|
|
|
|
}
|
|
|
|
|
|
|
|
private func localDate() -> String {
|
|
|
|
let dateFormatter = DateFormatterManager.dateFormatter(with: .medium, for: TimeZone.autoupdatingCurrent.identifier)
|
|
|
|
return dateFormatter.string(from: Date())
|
|
|
|
}
|
|
|
|
|
|
|
|
private func localeDate(with format: String) -> String {
|
|
|
|
let dateFormatter = DateFormatterManager.localizedFormatter(with: format, for: TimeZone.autoupdatingCurrent.identifier)
|
|
|
|
return dateFormatter.string(from: Date())
|
|
|
|
}
|
|
|
|
|
|
|
|
func saveObject(at index: Int = -1) {
|
|
|
|
var defaults = store.timezones()
|
|
|
|
guard let encodedObject = NSKeyedArchiver.clocker_archive(with:dataObject as Any) else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
index == -1 ? defaults.append(encodedObject) : defaults.insert(encodedObject, at: index)
|
|
|
|
store.setTimezones(defaults)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
extension Date {
|
|
|
|
func formatter(with format: String, timeZone: String, locale: Locale = Locale(identifier: "en-US")) -> String {
|
|
|
|
let dateFormatter = DateFormatterManager.dateFormatterWithFormat(with: .medium,
|
|
|
|
format: format,
|
|
|
|
timezoneIdentifier: timeZone,
|
|
|
|
locale: locale)
|
|
|
|
return dateFormatter.string(from: self)
|
|
|
|
}
|
|
|
|
}
|