Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Timer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
40A85EFA25A64C3100DA10CC /* alert-sound-2.caf in Resources */ = {isa = PBXBuildFile; fileRef = 40A85EF825A64C3100DA10CC /* alert-sound-2.caf */; };
41A37D0724426E360078EA40 /* Credits.rtf in Resources */ = {isa = PBXBuildFile; fileRef = 41A37D0624426E360078EA40 /* Credits.rtf */; };
4C30BBFC1CA7C56500C45EBF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C30BBFB1CA7C56500C45EBF /* AppDelegate.swift */; };
4C30BBFF1CA7C56500C45EBF /* AppDelegate+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C30BC001CA7C56500C45EBF /* AppDelegate+Notifications.swift */; };
4C30BBFE1CA7C56500C45EBF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4C30BBFD1CA7C56500C45EBF /* Assets.xcassets */; };
4C30BC091CA7C81C00C45EBF /* MVWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C30BC081CA7C81C00C45EBF /* MVWindow.swift */; };
4C30BC0B1CA7C96700C45EBF /* MVMainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C30BC0A1CA7C96700C45EBF /* MVMainView.swift */; };
Expand Down Expand Up @@ -61,6 +62,7 @@
41A37D0624426E360078EA40 /* Credits.rtf */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; path = Credits.rtf; sourceTree = "<group>"; };
4C30BBF81CA7C56500C45EBF /* Timer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Timer.app; sourceTree = BUILT_PRODUCTS_DIR; };
4C30BBFB1CA7C56500C45EBF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
4C30BC001CA7C56500C45EBF /* AppDelegate+Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+Notifications.swift"; sourceTree = "<group>"; };
4C30BBFD1CA7C56500C45EBF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
4C30BC021CA7C56500C45EBF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
4C30BC081CA7C81C00C45EBF /* MVWindow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MVWindow.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -140,6 +142,7 @@
isa = PBXGroup;
children = (
4C30BBFB1CA7C56500C45EBF /* AppDelegate.swift */,
4C30BC001CA7C56500C45EBF /* AppDelegate+Notifications.swift */,
4C6F0F2B1CACF4B500E9A6F7 /* MVTimerController.swift */,
4C30BC081CA7C81C00C45EBF /* MVWindow.swift */,
4C30BC0A1CA7C96700C45EBF /* MVMainView.swift */,
Expand Down Expand Up @@ -329,6 +332,7 @@
4C6F0F2C1CACF4B500E9A6F7 /* MVTimerController.swift in Sources */,
4C30BC091CA7C81C00C45EBF /* MVWindow.swift in Sources */,
4C30BBFC1CA7C56500C45EBF /* AppDelegate.swift in Sources */,
4C30BBFF1CA7C56500C45EBF /* AppDelegate+Notifications.swift in Sources */,
4C30BC161CA7D94500C45EBF /* MVClockView.swift in Sources */,
4C30BC101CA7CFBB00C45EBF /* MVTitlebarAccessoryViewController.swift in Sources */,
AA000001AAAA000100000001 /* TimerLogic.swift in Sources */,
Expand Down
75 changes: 75 additions & 0 deletions Timer/AppDelegate+Notifications.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import AppKit
import UserNotifications

extension AppDelegate {
nonisolated func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound])
}

nonisolated func userNotificationCenter(
_: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
let actionIdentifier = response.actionIdentifier
let controllerIdentifier = response.notification.request.content.userInfo[
MVNotificationUserInfoKeys.controllerIdentifier
] as? String

completionHandler()

DispatchQueue.main.async { [weak self] in
self?.handleNotificationAction(actionIdentifier, controllerIdentifier: controllerIdentifier)
}
}

func registerNotificationCategories() {
let restartAction = UNNotificationAction(
identifier: MVNotificationIdentifiers.restartTimerActionIdentifier,
title: "Restart",
options: []
)
let addFiveMinutesAction = UNNotificationAction(
identifier: MVNotificationIdentifiers.addFiveMinutesActionIdentifier,
title: "+5 min",
options: []
)
let stopAction = UNNotificationAction(
identifier: MVNotificationIdentifiers.stopTimerActionIdentifier,
title: "Stop",
options: [.destructive]
)
let category = UNNotificationCategory(
identifier: MVNotificationIdentifiers.timerCompleteCategoryIdentifier,
actions: [restartAction, addFiveMinutesAction, stopAction],
intentIdentifiers: [],
options: []
)

UNUserNotificationCenter.current().setNotificationCategories([category])
}

private func handleNotificationAction(_ actionIdentifier: String, controllerIdentifier: String?) {
let controller = self.controller(matching: controllerIdentifier)

switch actionIdentifier {
case MVNotificationIdentifiers.restartTimerActionIdentifier:
controller.restartLastTimer()

case MVNotificationIdentifiers.addFiveMinutesActionIdentifier:
controller.addTime(seconds: CGFloat(5 * 60))

case MVNotificationIdentifiers.stopTimerActionIdentifier:
controller.stopTimer()

case UNNotificationDefaultActionIdentifier:
controller.window?.makeKeyAndOrderFront(nil)
NSApplication.shared.activate(ignoringOtherApps: true)

default:
break
}
}
}
34 changes: 20 additions & 14 deletions Timer/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.addBadgeToDock(controller: controller)

UNUserNotificationCenter.current().delegate = self
self.registerNotificationCategories()
Task {
do {
try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])
Expand Down Expand Up @@ -79,15 +80,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent

switch input.lowercased() {
case "stop":
clockView.paused = false
clockView.stop()
controller.stopTimer()

case "reset":
clockView.paused = false
clockView.stop()
clockView.seconds = 0
clockView.updateTimerTime()
clockView.inputSeconds = false
controller.resetTimer()

case "pause":
if clockView.timerTask != nil {
Expand Down Expand Up @@ -154,13 +150,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}

nonisolated func userNotificationCenter(
_: UNUserNotificationCenter,
willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler([.banner, .sound])
}

func addBadgeToDock(controller: MVTimerController) {
if self.currentlyInDock != controller {
self.removeBadgeFromDock()
Expand Down Expand Up @@ -199,6 +188,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
self.staysOnTop = UserDefaults.standard.bool(forKey: MVUserDefaultsKeys.staysOnTop)
}

func controller(matching identifier: String?) -> MVTimerController {
if let identifier,
let controller = self.controllers.first(where: { $0.identifier == identifier }) {
return controller
}

if let controller = self.controllers.first {
return controller
}

let controller = MVTimerController()
controller.window?.level = self.windowLevel
self.controllers.append(controller)
self.addBadgeToDock(controller: controller)
return controller
}
Comment thread
mookwoo marked this conversation as resolved.

private func observeNotifications() {
self.notificationTasks.append(
Task { [weak self] in
Expand Down
50 changes: 48 additions & 2 deletions Timer/MVTimerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import AVFoundation
import UserNotifications

final class MVTimerController: NSWindowController {
let identifier = UUID().uuidString

private weak var dockMenuItem: NSMenuItem?
let clockView = MVClockView()

private var audioPlayer: AVAudioPlayer? // player must be kept in memory
private var userAttentionRequest: Int?
private var soundURL = Bundle.main.url(forResource: "alert-sound", withExtension: "caf")

convenience init() {
Expand Down Expand Up @@ -71,13 +74,21 @@ final class MVTimerController: NSWindowController {
}

private func handleClockTimer() {
self.stopCompletionEffects()

let content = UNMutableNotificationContent()
content.title = "It's time! 🕘"
content.categoryIdentifier = MVNotificationIdentifiers.timerCompleteCategoryIdentifier
content.userInfo = [MVNotificationUserInfoKeys.controllerIdentifier: self.identifier]

let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
UNUserNotificationCenter.current().add(request) { error in
if let error {
NSLog("Notification scheduling failed: %@", error.localizedDescription)
}
}

NSApplication.shared.requestUserAttention(.criticalRequest)
self.userAttentionRequest = NSApplication.shared.requestUserAttention(.criticalRequest)

self.playAlarmSound()
}
Expand All @@ -100,4 +111,39 @@ final class MVTimerController: NSWindowController {
self.soundURL = nil
}
}

func restartLastTimer() {
guard let seconds = self.clockView.lastTimerSeconds, seconds > 0 else { return }
self.stopCompletionEffects()
self.clockView.startTimer(seconds: seconds)
}

func addTime(seconds: CGFloat) {
let currentSeconds = self.clockView.timerTask != nil || self.clockView.paused ? self.clockView.seconds : 0
self.stopCompletionEffects()
self.clockView.startTimer(seconds: currentSeconds + seconds)
}

func stopTimer() {
self.stopCompletionEffects()
self.clockView.paused = false
self.clockView.stop()
}

func resetTimer() {
self.stopTimer()
self.clockView.seconds = 0
self.clockView.updateTimerTime()
self.clockView.inputSeconds = false
}

private func stopCompletionEffects() {
self.audioPlayer?.stop()
self.audioPlayer = nil

if let userAttentionRequest {
NSApplication.shared.cancelUserAttentionRequest(userAttentionRequest)
self.userAttentionRequest = nil
}
}
}
11 changes: 11 additions & 0 deletions Timer/MVUserDefaultsKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,14 @@ enum MVUserDefaultsKeys {
static let staysOnTop = "staysOnTop"
static let soundIndex = "soundIndex"
}

enum MVNotificationIdentifiers {
static let timerCompleteCategoryIdentifier = "timerComplete"
static let restartTimerActionIdentifier = "restartTimer"
static let addFiveMinutesActionIdentifier = "addFiveMinutes"
static let stopTimerActionIdentifier = "stopTimer"
}
Comment thread
mookwoo marked this conversation as resolved.

enum MVNotificationUserInfoKeys {
static let controllerIdentifier = "controllerIdentifier"
}