Skip to content

Commit c577590

Browse files
authored
Merge pull request #297 from aapis/feature/1.12/notifications
New feature: Notifications & notification interval customization
2 parents bf551d0 + a72de74 commit c577590

File tree

16 files changed

+250
-24
lines changed

16 files changed

+250
-24
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//
2+
// NotificationHelper.swift
3+
// KlockWork
4+
//
5+
// Created by Ryan Priebe on 2024-10-11.
6+
// Copyright © 2024 YegCollective. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
import UserNotifications
11+
12+
final class NotificationHelper {
13+
/// Remove delivered notifications with the same ID
14+
/// - Parameter identifier: String
15+
/// - Returns: Void
16+
static public func clean(identifier: String? = nil) -> Void {
17+
if let id = identifier {
18+
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
19+
} else {
20+
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
21+
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
22+
}
23+
}
24+
25+
/// Create a new notification
26+
/// - Parameters:
27+
/// - title: String
28+
/// - task: LogTask
29+
/// - minutesBefore: Int
30+
/// - identifier: String
31+
/// - repeats: Bool(false)
32+
static public func create(title: String, task: LogTask, minutesBefore: Int = 5, repeats: Bool = false) -> Void {
33+
if task.due == nil {
34+
return
35+
}
36+
37+
let currHour = Calendar.autoupdatingCurrent.component(.hour, from: task.due!)
38+
let currMin = Calendar.autoupdatingCurrent.component(.minute, from: task.due!)
39+
40+
// Don't create notifications for items whose due time is the very end of the day. Mainly to prevent
41+
// notification spam at midnight.
42+
if currHour == 23 && currMin == 59 {
43+
return
44+
}
45+
46+
var dc = DateComponents()
47+
dc.hour = currHour
48+
if currMin > minutesBefore {
49+
dc.minute = currMin - minutesBefore
50+
} else {
51+
dc.minute = currMin
52+
}
53+
54+
let notificationCenter = UNUserNotificationCenter.current()
55+
let content = UNMutableNotificationContent()
56+
content.title = task.content!
57+
content.body = task.notificationBody
58+
content.sound = UNNotificationSound.default
59+
60+
let trigger = UNCalendarNotificationTrigger(dateMatching: dc, repeats: repeats)
61+
let request = UNNotificationRequest(identifier: task.id?.uuidString ?? "no-id", content: content, trigger: trigger)
62+
self.clean(identifier: task.id?.uuidString ?? "no-id")
63+
64+
notificationCenter.add(request) { error in
65+
if let error = error {
66+
print("Error scheduling notification: \(error)")
67+
} else {
68+
task.hasScheduledNotification = false
69+
}
70+
}
71+
}
72+
73+
/// Request notification auth so we can send user notifications
74+
static public func requestAuthorization() {
75+
let center = UNUserNotificationCenter.current()
76+
77+
center.getNotificationSettings { settings in
78+
switch settings.authorizationStatus {
79+
case .notDetermined:
80+
center.requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
81+
if granted {
82+
print("Notification access granted.")
83+
} else {
84+
print("Notification access denied.\(String(describing: error?.localizedDescription))")
85+
}
86+
}
87+
return
88+
case .denied:
89+
print("Notification access denied")
90+
return
91+
case .authorized:
92+
print("Notification access granted.")
93+
return
94+
default:
95+
return
96+
}
97+
}
98+
}
99+
100+
/// Create notifications based on the user's chosen notification interval
101+
/// - Parameters:
102+
/// - interval: Int
103+
/// - task: LogTask
104+
/// - Returns: Void
105+
static public func createInterval(interval: Int, task: LogTask) -> Void {
106+
switch interval {
107+
case 1:
108+
NotificationHelper.create(title: "In 1 hour", task: task, minutesBefore: 60)
109+
case 2:
110+
NotificationHelper.create(title: "In 1 hour", task: task, minutesBefore: 60)
111+
NotificationHelper.create(title: "In 15 minutes", task: task, minutesBefore: 15)
112+
case 3:
113+
NotificationHelper.create(title: "In 1 hour", task: task, minutesBefore: 60)
114+
NotificationHelper.create(title: "In 15 minutes", task: task, minutesBefore: 15)
115+
NotificationHelper.create(title: "In 5 minutes", task: task)
116+
case 4:
117+
NotificationHelper.create(title: "In 15 minutes", task: task, minutesBefore: 15)
118+
case 5:
119+
NotificationHelper.create(title: "In 5 minutes", task: task)
120+
case 6:
121+
NotificationHelper.create(title: "In 15 minutes", task: task, minutesBefore: 15)
122+
NotificationHelper.create(title: "In 5 minutes", task: task)
123+
default:
124+
print("[warning] User has not set notifications.interval yet")
125+
}
126+
}
127+
}

KlockWork.xcodeproj/project.pbxproj

+10
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@
110110
536040AA2CB9F5F10030D72D /* ActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536040A92CB9F5F10030D72D /* ActionType.swift */; };
111111
536040AC2CB9FC850030D72D /* FactorProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536040AB2CB9FC850030D72D /* FactorProxy.swift */; };
112112
5360429D2CBA35050030D72D /* RecordDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5360429C2CBA35020030D72D /* RecordDetail.swift */; };
113+
536043CA2CBE06A60030D72D /* LogTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536043C92CBE06A40030D72D /* LogTask.swift */; };
114+
536043CC2CBE0E6C0030D72D /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536043CB2CBE0E670030D72D /* NotificationSettings.swift */; };
113115
5363B6782A6BB2CC00C2FBB8 /* CompanyDashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5363B6772A6BB2CC00C2FBB8 /* CompanyDashboard.swift */; };
114116
5363B67A2A6BB75F00C2FBB8 /* CompanyBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5363B6792A6BB75F00C2FBB8 /* CompanyBlock.swift */; };
115117
5363B67C2A6BB78900C2FBB8 /* CompanyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5363B67B2A6BB78900C2FBB8 /* CompanyView.swift */; };
@@ -406,6 +408,8 @@
406408
536040A92CB9F5F10030D72D /* ActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionType.swift; sourceTree = "<group>"; };
407409
536040AB2CB9FC850030D72D /* FactorProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FactorProxy.swift; sourceTree = "<group>"; };
408410
5360429C2CBA35020030D72D /* RecordDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordDetail.swift; sourceTree = "<group>"; };
411+
536043C92CBE06A40030D72D /* LogTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogTask.swift; sourceTree = "<group>"; };
412+
536043CB2CBE0E670030D72D /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
409413
5363B6772A6BB2CC00C2FBB8 /* CompanyDashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanyDashboard.swift; sourceTree = "<group>"; };
410414
5363B6792A6BB75F00C2FBB8 /* CompanyBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanyBlock.swift; sourceTree = "<group>"; };
411415
5363B67B2A6BB78900C2FBB8 /* CompanyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanyView.swift; sourceTree = "<group>"; };
@@ -540,6 +544,7 @@
540544
Sources/Helpers/ClipboardHelper.swift,
541545
Sources/Helpers/DateHelper.swift,
542546
Sources/Helpers/FileHelper.swift,
547+
Sources/Helpers/NotificationHelper.swift,
543548
Sources/Helpers/SearchHelper.swift,
544549
Sources/Helpers/StringHelper.swift,
545550
Sources/Helpers/UrlHelper.swift,
@@ -575,6 +580,7 @@
575580
Sources/Helpers/ClipboardHelper.swift,
576581
Sources/Helpers/DateHelper.swift,
577582
Sources/Helpers/FileHelper.swift,
583+
Sources/Helpers/NotificationHelper.swift,
578584
Sources/Helpers/SearchHelper.swift,
579585
Sources/Helpers/StringHelper.swift,
580586
Sources/Helpers/UrlHelper.swift,
@@ -703,6 +709,7 @@
703709
531EB6E62A9FDA5000B3059C /* Models */ = {
704710
isa = PBXGroup;
705711
children = (
712+
536043C92CBE06A40030D72D /* LogTask.swift */,
706713
53013A972CAC601F0024BA30 /* Company.swift */,
707714
5331FCB22A9E6ECE005CDE0E /* Plan.swift */,
708715
531EB6E72A9FDA5A00B3059C /* Job.swift */,
@@ -1208,6 +1215,7 @@
12081215
53C036ED29F8A58600539C3C /* Tabs */ = {
12091216
isa = PBXGroup;
12101217
children = (
1218+
536043CB2CBE0E670030D72D /* NotificationSettings.swift */,
12111219
53967D8329EC6D14006616A0 /* TodaySettings.swift */,
12121220
53EFCE7829637F35004E45EC /* GeneralSettings.swift */,
12131221
53C036EE29F8A59900539C3C /* DashboardSettings.swift */,
@@ -1624,6 +1632,7 @@
16241632
5372C02A2A105AA1008CB120 /* FancyChip.swift in Sources */,
16251633
53450CEC2BF46ECF007F45FB /* CommandLineInterface.swift in Sources */,
16261634
537628B429665F4E00DE8ECF /* Data.xcdatamodeld in Sources */,
1635+
536043CC2CBE0E6C0030D72D /* NotificationSettings.swift in Sources */,
16271636
538A535A2C704E2B00711639 /* TermsDashboardSidebar.swift in Sources */,
16281637
53F5896429983FA300843B32 /* ProjectResult.swift in Sources */,
16291638
536040AA2CB9F5F10030D72D /* ActionType.swift in Sources */,
@@ -1678,6 +1687,7 @@
16781687
5354C8F82AA045F5001C1779 /* Planning.Feature.swift in Sources */,
16791688
5354C8E82AA03C56001C1779 /* Header.swift in Sources */,
16801689
5326AE9A2B27FFB8009F1349 /* CompanyPicker.swift in Sources */,
1690+
536043CA2CBE06A60030D72D /* LogTask.swift in Sources */,
16811691
536040AC2CB9FC850030D72D /* FactorProxy.swift in Sources */,
16821692
53152CC929690E7300A14E43 /* LosslessStringConvertible.swift in Sources */,
16831693
536040932CB89F8C0030D72D /* ExploreSidebar.swift in Sources */,

KlockWork/CoreData/Data.xcdatamodeld/Note.xcdatamodel/contents

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2-
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23173.10" systemVersion="24A5279h" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
2+
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23231" systemVersion="24A348" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
33
<entity name="AssessmentFactor" representedClassName="AssessmentFactor" syncable="YES" codeGenerationType="class">
44
<attribute name="action" attributeType="String" defaultValueString="Create"/>
55
<attribute name="alive" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
@@ -94,6 +94,7 @@
9494
<attribute name="content" optional="YES" attributeType="String"/>
9595
<attribute name="created" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
9696
<attribute name="due" attributeType="Date" defaultDateTimeInterval="-978282060" usesScalarValueType="NO"/>
97+
<attribute name="hasScheduledNotification" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
9798
<attribute name="id" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
9899
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
99100
<relationship name="owner" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Job" inverseName="tasks" inverseEntity="Job"/>

KlockWork/DLPrototype.swift

+7-7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import SwiftUI
1010
import KWCore
1111
import CoreSpotlight
12+
import UserNotifications
1213

1314
@main
1415
struct DLPrototype: App {
@@ -65,7 +66,6 @@ struct DLPrototype: App {
6566
.environmentObject(nav)
6667
}
6768

68-
// TODO: temp commented out, too early to include this
6969
MenuBarExtra("name", systemImage: "clock.fill") {
7070
let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String
7171
// @TODO: temp commented out until I build a compatible search view
@@ -76,9 +76,7 @@ struct DLPrototype: App {
7676
// }.keyboardShortcut("1")
7777

7878
Button("Search") {
79-
nav.setView(AnyView(Dashboard()))
80-
nav.setSidebar(AnyView(DashboardSidebar()))
81-
nav.setParent(.dashboard)
79+
self.nav.to(.dashboard)
8280
}
8381

8482
Divider()
@@ -119,7 +117,9 @@ struct DLPrototype: App {
119117
}
120118
}
121119

122-
self.onApplicationBoot()
120+
self.createAssessments()
121+
NotificationHelper.clean() // @TODO: shouldn't be necessary here
122+
NotificationHelper.requestAuthorization()
123123
}
124124

125125
func application(application: any App, continueUserActivity userActivity: NSUserActivity, restorationHandler: ([AnyObject]?) -> Void) -> Bool {
@@ -137,9 +137,9 @@ struct DLPrototype: App {
137137
print("[debug][Spotlight] Item tapped: \(uniqueIdentifier)")
138138
}
139139

140-
/// Fires when application has loaded and view appears
140+
/// Creates assessment factors
141141
/// - Returns: Void
142-
private func onApplicationBoot() -> Void {
142+
private func createAssessments() -> Void {
143143
// Create the default set of assessment factors if necessary (aka, if there are no AFs)
144144
let factors = CDAssessmentFactor(moc: self.nav.moc).all(limit: 1).first
145145
if factors == nil {
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//
2+
// LogTask.swift
3+
// KlockWork
4+
//
5+
// Created by Ryan Priebe on 2024-10-14.
6+
// Copyright © 2024 YegCollective. All rights reserved.
7+
//
8+
9+
import SwiftUI
10+
import KWCore
11+
12+
extension LogTask {
13+
var notificationBody: String {
14+
"\(self.content ?? "Unknown task") is due at \(self.due?.formatted() ?? "unclear, why do you ask?")"
15+
}
16+
}

KlockWork/Views/Dashboard/Widgets/UpcomingWork.swift

-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ struct UpcomingWork: View {
3838
ForEach(self.forecast, id: \.id) { row in row }
3939
}
4040
.background(self.state.session.appPage.primaryColour)
41-
// .frame(height: 250)
4241
.onAppear(perform: self.actionOnAppear)
4342
.onChange(of: self.maxDaysUpcomingWork) { self.actionOnAppear() }
4443
}

KlockWork/Views/Entities/Companies/CompanyBlock.swift

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ struct CompanyBlock: View {
5252
}
5353
}
5454
}
55+
.clipShape(.rect(cornerRadius: 5))
5556
.onAppear(perform: self.actionOnAppear)
5657
.useDefaultHover({inside in highlighted = inside})
5758
.help(self.company.isDefault ? "This is your default company." : "")

KlockWork/Views/Entities/Notes/NoteBlock.swift

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ struct NoteBlock: View {
4949
}
5050
}
5151
}
52+
.clipShape(.rect(cornerRadius: 5))
5253
.useDefaultHover({ inside in highlighted = inside})
5354
.buttonStyle(.plain)
5455
}

KlockWork/Views/Entities/Notes/NoteRowPlain.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import KWCore
1212
struct NoteRowPlain: View {
1313
public var note: Note
1414
public var moc: NSManagedObjectContext
15-
public var icon: String = "arrow.right"
15+
public var icon: String = "chevron.right"
1616

1717
@AppStorage("CreateEntitiesWidget.isSearchStackShowing") private var isSearching: Bool = false
1818

KlockWork/Views/Entities/Projects/ProjectRowPlain.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import KWCore
1111

1212
struct ProjectRowPlain: View {
1313
public var project: Project
14-
public var icon: String = "arrow.right"
14+
public var icon: String = "chevron.right"
1515

1616
var body: some View {
1717
VStack(alignment: .leading) {

KlockWork/Views/Entities/Tasks/TaskDetail.swift

+11-4
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import KWCore
1212
struct TaskDetail: View {
1313
@EnvironmentObject public var state: Navigation
1414
@Environment(\.dismiss) private var dismiss
15+
@AppStorage("notifications.interval") private var notificationInterval: Int = 0
1516
@State public var task: LogTask?
1617
@State private var content: String = ""
1718
@State private var published: Bool = false
1819
@State private var isPresented: Bool = false
20+
@State private var shouldCreateNotification: Bool = true
1921
@State private var due: Date = Date()
2022
private let page: PageConfiguration.AppPage = .explore
2123
private let eType: PageConfiguration.EntityType = .tasks
@@ -29,6 +31,7 @@ struct TaskDetail: View {
2931
Title(text: eType.enSingular, imageAsImage: eType.icon)
3032
FancyDivider()
3133
Toggle("Published", isOn: $published)
34+
Toggle("Create notification", isOn: $shouldCreateNotification)
3235
FancyDivider()
3336
DatePicker("Due", selection: $due)
3437
HStack(alignment: .center) {
@@ -89,29 +92,33 @@ extension TaskDetail {
8992
self.content = self.task?.content ?? ""
9093
self.published = self.task?.cancelledDate == nil && self.task?.completedDate == nil
9194
self.due = self.task?.due ?? Date()
95+
self.shouldCreateNotification = !(self.task?.hasScheduledNotification ?? false)
9296
}
9397

9498
/// Fires when enter/return hit while entering text in field or when add button tapped
9599
/// - Returns: Void
96100
private func actionOnSave() -> Void {
97101
if self.task != nil {
98102
self.task?.content = self.content
99-
self.task?.due = DateHelper.endOfDay(self.due) ?? Date()
103+
self.task?.due = self.due
100104
if self.published == false {
101105
self.task?.cancelledDate = Date()
102106
}
103107
self.task?.lastUpdate = Date()
104108
} else {
105-
CoreDataTasks(moc: self.state.moc).create(
109+
self.task = CoreDataTasks(moc: self.state.moc).createAndReturn(
106110
content: self.content,
107111
created: Date(),
108-
due: DateHelper.endOfTomorrow(Date()) ?? Date(),
112+
due: DateHelper.endOfDay(Date()) ?? Date(),
109113
job: self.state.session.job
110114
)
111115
}
112116

113-
self.content = ""
117+
if self.shouldCreateNotification && self.task != nil {
118+
NotificationHelper.createInterval(interval: self.notificationInterval, task: self.task!)
119+
}
114120

121+
self.content = ""
115122
PersistenceController.shared.save()
116123
self.dismiss()
117124
self.state.to(.tasks)

KlockWork/Views/Settings/Settings.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import KWCore
1212

1313
struct SettingsView: View {
1414
private enum SettingsTabs: Hashable {
15-
case general, today, advanced, dashboard, notedashboard
15+
case general, today, advanced, dashboard, notedashboard, notifications
1616
}
1717

1818
@StateObject public var ce: CoreDataCalendarEvent = CoreDataCalendarEvent(moc: PersistenceController.shared.container.viewContext)
@@ -41,6 +41,13 @@ struct SettingsView: View {
4141
Label("Dashboard", systemImage: "house")
4242
}
4343
.tag(SettingsTabs.dashboard)
44+
45+
NotificationSettings()
46+
.environmentObject(nav)
47+
.tabItem {
48+
Label("Notifications", systemImage: "bell")
49+
}
50+
.tag(SettingsTabs.notifications)
4451
}
4552
.padding(20)
4653
}

0 commit comments

Comments
 (0)