From 87141e381bdc8cb7bd6c930c2e55095ca58c0c89 Mon Sep 17 00:00:00 2001 From: Connor G Meehan Date: Mon, 27 Jul 2020 09:41:05 +1000 Subject: [PATCH] Up next calendar widget (#348) * WIP Implementation of up next widget * Seperated button view and event source logic * Adjusted default parameters * Added the ability to view multiple events * Added ability to click touchbar item and go to calendar * renamed nthEvent to maxToShow and changed default * Updated CFBundleVersion * Renamed UpNext class and fix ups * Added "autoResize" property (same functionality as dock) * Added EKEventStore listener to reduce perfomance impact * Log cleanup * Made button blue for current/past events * Added handling of unauthorised access to calendar --- MTMR.xcodeproj/project.pbxproj | 4 + MTMR/Info.plist | 2 +- MTMR/ItemsParsing.swift | 11 + MTMR/TouchBarController.swift | 4 + MTMR/Widgets/UpNextScrubberTouchBarItem.swift | 256 ++++++++++++++++++ README.md | 18 ++ 6 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 MTMR/Widgets/UpNextScrubberTouchBarItem.swift diff --git a/MTMR.xcodeproj/project.pbxproj b/MTMR.xcodeproj/project.pbxproj index 855eff5..e14e8aa 100644 --- a/MTMR.xcodeproj/project.pbxproj +++ b/MTMR.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ B0F8771A207AC1EA00D6E430 /* TouchBarSupport.m in Sources */ = {isa = PBXBuildFile; fileRef = B0F87719207AC1EA00D6E430 /* TouchBarSupport.m */; }; BAF5AB5724317B4300B04904 /* BasicView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF5AB5624317B4300B04904 /* BasicView.swift */; }; BAF5AB5924317CAF00B04904 /* SwipeItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF5AB5824317CAF00B04904 /* SwipeItem.swift */; }; + F29F6A2524BC7148004FF8E4 /* UpNextScrubberTouchBarItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F29F6A2424BC7148004FF8E4 /* UpNextScrubberTouchBarItem.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -163,6 +164,7 @@ B0F8771B207AC92700D6E430 /* TouchBarSupport.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TouchBarSupport.h; sourceTree = ""; }; BAF5AB5624317B4300B04904 /* BasicView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicView.swift; sourceTree = ""; }; BAF5AB5824317CAF00B04904 /* SwipeItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeItem.swift; sourceTree = ""; }; + F29F6A2424BC7148004FF8E4 /* UpNextScrubberTouchBarItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpNextScrubberTouchBarItem.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -322,6 +324,7 @@ 4CFF5E5B22E623DD00BFB1EE /* YandexWeatherBarItem.swift */, B08126F0217BE19000A98970 /* WidgetProtocol.swift */, B0F54A792295AC7D00B4C509 /* DarkModeBarItem.swift */, + F29F6A2424BC7148004FF8E4 /* UpNextScrubberTouchBarItem.swift */, ); path = Widgets; sourceTree = ""; @@ -488,6 +491,7 @@ 6027D1BA2080E52A004FFDC7 /* VolumeViewController.swift in Sources */, 4CDC6E5022FCA93F0069ADD4 /* ShellScriptTouchBarItem.swift in Sources */, 607EEA4B2087835F009DA5F0 /* WeatherBarItem.swift in Sources */, + F29F6A2524BC7148004FF8E4 /* UpNextScrubberTouchBarItem.swift in Sources */, B0F3112520C9E35F0076BB88 /* SupportNSTouchBar.swift in Sources */, 4CFF5E5C22E623DD00BFB1EE /* YandexWeatherBarItem.swift in Sources */, 6042B6AA2083E27000C525C8 /* DeprecatedCarbonAPI.c in Sources */, diff --git a/MTMR/Info.plist b/MTMR/Info.plist index e1bbf12..ead0085 100644 --- a/MTMR/Info.plist +++ b/MTMR/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.26.1 CFBundleVersion - 405 + 425 LSApplicationCategoryType public.app-category.utilities LSMinimumSystemVersion diff --git a/MTMR/ItemsParsing.swift b/MTMR/ItemsParsing.swift index 851cf15..a16beef 100644 --- a/MTMR/ItemsParsing.swift +++ b/MTMR/ItemsParsing.swift @@ -227,6 +227,7 @@ enum ItemType: Decodable { case network(flip: Bool) case darkMode case swipe(direction: String, fingers: Int, minOffset: Float, sourceApple: SourceProtocol?, sourceBash: SourceProtocol?) + case upnext(from: Double, to: Double, maxToShow: Int, autoResize: Bool) private enum CodingKeys: String, CodingKey { case type @@ -258,6 +259,7 @@ enum ItemType: Decodable { case direction case fingers case minOffset + case maxToShow } enum ItemTypeRaw: String, Decodable { @@ -281,6 +283,7 @@ enum ItemType: Decodable { case network case darkMode case swipe + case upnext } init(from decoder: Decoder) throws { @@ -378,6 +381,14 @@ enum ItemType: Decodable { let fingers = try container.decode(Int.self, forKey: .fingers) let minOffset = try container.decodeIfPresent(Float.self, forKey: .minOffset) ?? 0.0 self = .swipe(direction: direction, fingers: fingers, minOffset: minOffset, sourceApple: sourceApple, sourceBash: sourceBash) + + case .upnext: + let from = try container.decodeIfPresent(Double.self, forKey: .from) ?? 0 // Lower bounds of period of time in hours to search for events + let to = try container.decodeIfPresent(Double.self, forKey: .to) ?? 12 // Upper bounds of period of time in hours to search for events + let maxToShow = try container.decodeIfPresent(Int.self, forKey: .maxToShow) ?? 3 // 1 indexed array. Get the 1st, 2nd, 3rd event to display multiple notifications + let autoResize = try container.decodeIfPresent(Bool.self, forKey: .autoResize) ?? false + let interval = try container.decodeIfPresent(Double.self, forKey: .refreshInterval) ?? 60.0 + self = .upnext(from: from, to: to, maxToShow: maxToShow, autoResize: autoResize) } } } diff --git a/MTMR/TouchBarController.swift b/MTMR/TouchBarController.swift index e0af17e..e3a5898 100644 --- a/MTMR/TouchBarController.swift +++ b/MTMR/TouchBarController.swift @@ -59,6 +59,8 @@ extension ItemType { return DarkModeBarItem.identifier case .swipe(direction: _, fingers: _, minOffset: _, sourceApple: _, sourceBash: _): return "com.toxblh.mtmr.swipe." + case .upnext(from: _, to: _, maxToShow: _, autoResize: _): + return "com.connorgmeehan.mtmrup.next." } } } @@ -301,6 +303,8 @@ class TouchBarController: NSObject, NSTouchBarDelegate { barItem = DarkModeBarItem(identifier: identifier) case let .swipe(direction: direction, fingers: fingers, minOffset: minOffset, sourceApple: sourceApple, sourceBash: sourceBash): barItem = SwipeItem(identifier: identifier, direction: direction, fingers: fingers, minOffset: minOffset, sourceApple: sourceApple, sourceBash: sourceBash) + case let .upnext(from: from, to: to, maxToShow: maxToShow, autoResize: autoResize): + barItem = UpNextScrubberTouchBarItem(identifier: identifier, interval: 60, from: from, to: to, maxToShow: maxToShow, autoResize: autoResize) } if let action = self.action(forItem: item), let item = barItem as? CustomButtonTouchBarItem { diff --git a/MTMR/Widgets/UpNextScrubberTouchBarItem.swift b/MTMR/Widgets/UpNextScrubberTouchBarItem.swift new file mode 100644 index 0000000..fb0f1ea --- /dev/null +++ b/MTMR/Widgets/UpNextScrubberTouchBarItem.swift @@ -0,0 +1,256 @@ +// +// UpNextScrubberTouchBarItems.swift +// MTMR +// +// Created by Connor Meehan on 13/7/20. +// Copyright © 2020 Anton Palgunov. All rights reserved. +// +// + +import Foundation +import EventKit + +class UpNextScrubberTouchBarItem: NSCustomTouchBarItem { + // Dependencies + private let scrollView = NSScrollView() + private let activity: NSBackgroundActivityScheduler // Update scheduler + private var eventSources : [IUpNextSource] = [] + private var items: [UpNextItem] = [] + + // Settings + private var futureSearchCutoff: Double + private var pastSearchCutoff: Double + private var maxToShow: Int + private var widthConstraint: NSLayoutConstraint? + private var autoResize: Bool = false + + /// <#Description#> + /// - Parameters: + /// - identifier: Unique identifier of widget + /// - interval: Update view interval in seconds + /// - from: Relative to current time, how far back we search for events in hours + /// - to: Relative to current time, how far forward we search for events in hours + /// - maxToShow: Which event to show (1 is first, 2 is second, and so on) + init(identifier: NSTouchBarItem.Identifier, interval: TimeInterval, from: Double, to: Double, maxToShow: Int, autoResize: Bool) { + // Initialise member properties + activity = NSBackgroundActivityScheduler(identifier: "\(identifier.rawValue).updateCheck") + pastSearchCutoff = from * 3600 + futureSearchCutoff = to * 3600 + self.maxToShow = maxToShow + self.autoResize = autoResize + UpNextItem.df.dateFormat = "HH:mm" + // Error handling + if (maxToShow <= 0) { + fatalError("Error on UpNext bar item. maxToShow property must be greater than 0.") + } + // Init super + super.init(identifier: identifier) + view = scrollView + // Add event sources + // Can optionally pass an update view callback to an event source to redraw element + self.eventSources.append(UpNextCalenderSource(updateCallback: self.updateView)) + // Fallback interactivity via interval + activity.interval = interval + activity.repeats = true + activity.qualityOfService = .utility + activity.schedule { (completion: NSBackgroundActivityScheduler.CompletionHandler) in + self.updateView() + completion(NSBackgroundActivityScheduler.Result.finished) + } + updateView() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateView() -> Void { + items = [] + var upcomingEvents = self.getUpcomingEvents() + upcomingEvents.sort(by: {$0.startDate.compare($1.startDate) == .orderedAscending}) + var index = 1 + DispatchQueue.main.async { + for event in upcomingEvents { + // Create UpNextItem + let item = UpNextItem(event: event) + item.backgroundColor = self.getBackgroundColor(startDate: event.startDate) + // Bind tap event + item.tapClosure = { [weak self] in + self?.switchToApp(event: event) + } + // Add to view + self.items.append(item) + // Check if should display any more + if (index == self.maxToShow) { + break; + } + index += 1 + } + self.reloadData() + self.updateSize() + } + } + + private func reloadData() { + let stackView = NSStackView(views: items.compactMap { $0.view }) + stackView.spacing = 5 + stackView.orientation = .horizontal + let visibleRect = self.scrollView.documentVisibleRect + self.scrollView.documentView = stackView + stackView.scroll(visibleRect.origin) + } + + func updateSize() { + if self.autoResize { + self.widthConstraint?.isActive = false + + let width = self.scrollView.documentView?.fittingSize.width ?? 0 + self.widthConstraint = self.scrollView.widthAnchor.constraint(equalToConstant: width) + self.widthConstraint!.isActive = true + } + } + + + private func getUpcomingEvents() -> [UpNextEventModel] { + var upcomingEvents: [UpNextEventModel] = [] + + // Calculate the range we're going to search for events in + let dateLowerBounds = Date(timeIntervalSinceNow: self.pastSearchCutoff) + let dateUpperBounds = Date(timeIntervalSinceNow: self.futureSearchCutoff) + + // Get all events from all sources + for eventSource in self.eventSources { + if (eventSource.hasPermission) { + let events = eventSource.getUpcomingEvents(dateLowerBounds: dateLowerBounds, dateUpperBounds: dateUpperBounds) + upcomingEvents.append(contentsOf: events) + } + } + + return upcomingEvents + } + + public func switchToApp(event: UpNextEventModel) { + var bundleIdentifier: String + switch(event.sourceType) { + case .iCalendar: + bundleIdentifier = UpNextCalenderSource.bundleIdentifier + } + + NSWorkspace.shared.launchApplication(withBundleIdentifier: bundleIdentifier, options: [.default], additionalEventParamDescriptor: nil, launchIdentifier: nil) + + // NB: if you can't open app which on another space, try to check mark + // "When switching to an application, switch to a Space with open windows for the application" + // in Mission control settings + } + + + func getBackgroundColor(startDate: Date) -> NSColor { + let distance = startDate.timeIntervalSinceReferenceDate/60 - Date().timeIntervalSinceReferenceDate/60 // Get time difference in minutes + if (distance < 0 as TimeInterval) { // If it's in the past, draw as blue + return NSColor.systemBlue + } else if (distance < 30 as TimeInterval) { // Less than 30 minutes, backround is red + return NSColor.systemRed + } + return NSColor.clear + } +} + +private class UpNextItem : CustomButtonTouchBarItem { + static public let df = DateFormatter() + + init(event: UpNextEventModel) { + let identifier = UpNextItem.getIdentifier(event: event) + let title = UpNextItem.getTitle(event: event) + super.init(identifier: NSTouchBarItem.Identifier(rawValue: identifier), title: title) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private static func getTitle(event: UpNextEventModel) -> String { + var title = "" + let startDateString = UpNextItem.df.string(for: event.startDate) + switch event.sourceType { + case .iCalendar: + title = String.init(format: "🗓 %@ - %@ ", event.title, startDateString!) + } + return title + } + + private static func getIdentifier(event: UpNextEventModel) -> String { + var identifier : String + switch event.sourceType { + case .iCalendar: + identifier = "com.mtmr.iCalendarEvent" + } + return identifier + "." + event.title + } +} + +enum UpNextSourceType { + case iCalendar +} + +// Model for events to be displayed in dock +struct UpNextEventModel { + let title: String + let startDate: Date + let sourceType: UpNextSourceType +} + + +// Interface for any event source +protocol IUpNextSource { + static var bundleIdentifier: String { get } + var hasPermission : Bool { get } + var updateCallback : () -> Void { get set } + + init(updateCallback: @escaping () -> Void) + func getUpcomingEvents(dateLowerBounds: Date, dateUpperBounds: Date) -> [UpNextEventModel] +} + +class UpNextCalenderSource : IUpNextSource { + static public let bundleIdentifier: String = "com.apple.iCal" + + public var hasPermission: Bool = false + private var eventStore : EKEventStore + internal var updateCallback: () -> Void + + required init(updateCallback: @escaping () -> Void = {}) { + self.updateCallback = updateCallback + eventStore = EKEventStore() + NotificationCenter.default.addObserver(forName: .EKEventStoreChanged, object: eventStore, queue: nil, using: handleUpdate) + let authStatus = EKEventStore.authorizationStatus(for: .event) + if (authStatus != .authorized) { + eventStore.requestAccess(to: .event){ granted, error in + self.hasPermission = granted; + self.handleUpdate() + if(!granted) { + NSLog("Error: MTMR UpNextBarWidget not given calendar access.") + return + } + } + } else { + self.handleUpdate() + } + + } + public func handleUpdate() { + self.handleUpdate(note: Notification(name: Notification.Name("refresh view"))) + } + public func handleUpdate(note: Notification) { + self.updateCallback() + } + + public func getUpcomingEvents(dateLowerBounds: Date, dateUpperBounds: Date) -> [UpNextEventModel] { + var upcomingEvents: [UpNextEventModel] = [] + let calendars = self.eventStore.calendars(for: .event) + let predicate = self.eventStore.predicateForEvents(withStart: dateLowerBounds, end: dateUpperBounds, calendars: calendars) + let events = self.eventStore.events(matching: predicate) + for event in events { + upcomingEvents.append(UpNextEventModel(title: event.title, startDate: event.startDate, sourceType: UpNextSourceType.iCalendar)) + } + return upcomingEvents + } +} diff --git a/README.md b/README.md index e2b2332..e9babc9 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The pre-installed configuration contains less or more than you'll probably want, - darkMode - pomodoro - network +- upnext (Calendar events) > Media Keys @@ -343,6 +344,23 @@ To close a group, use the button: }, ``` +#### `upnext` + +> Calender next event plugin +Displays upcoming events from MacOS Calendar. Does not display current event. + +```js +{ + "type": "upnext", + "from": 0, // Lower bound of search range for next event in hours. Default 0 (current time)(can be negative to view events in the past) + "to": 12, // Upper bounds of search range for next event in hours. Default 12 (12 hours in the future) + "maxToShow": 3 // Limits the maximum number of events displayed. Default 3 (the first 3 upcoming events) + "autoResize": false // If true, widget will expand to display all events. Default false (scrollable view within "width") +}, +``` + + + ## Actions: - `hidKey`