Skip to content

Commit 13d3bca

Browse files
committed
Added sticky notifications. Allowed multiple notifications to overlay UI. Improved aesthetics of notification banner
1 parent 99041cf commit 13d3bca

File tree

7 files changed

+194
-106
lines changed

7 files changed

+194
-106
lines changed

CodeEdit/Features/About/Views/BlurButtonStyle.swift

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ struct BlurButtonStyle: ButtonStyle {
3939
case .dark:
4040
ZStack {
4141
Color.gray.opacity(0.001)
42-
if !isSecondary {
42+
if isSecondary {
43+
Rectangle()
44+
.fill(.regularMaterial)
45+
} else {
4346
Rectangle()
4447
.fill(.regularMaterial)
4548
.blendMode(.plusLighter)
@@ -50,11 +53,9 @@ struct BlurButtonStyle: ButtonStyle {
5053
case .light:
5154
ZStack {
5255
Color.gray.opacity(0.001)
53-
if !isSecondary {
54-
Rectangle()
55-
.fill(.regularMaterial)
56-
.blendMode(.darken)
57-
}
56+
Rectangle()
57+
.fill(.regularMaterial)
58+
.blendMode(.darken)
5859
Color.gray.opacity(isSecondary ? 0.05 : 0.15)
5960
.blendMode(.plusDarker)
6061
Color.gray.opacity(configuration.isPressed ? 0.10 : 0.00)

CodeEdit/Features/InspectorArea/FileInspector/FileInspectorView.swift

Lines changed: 5 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,6 @@ struct FileInspectorView: View {
5959
widthOptions
6060
wrapLinesToggle
6161
}
62-
Section {
63-
Button("Add Test Notification") {
64-
addTestNotification()
65-
}
66-
Button("Add Test Notification After Delay") {
67-
addTestNotificationAfterDelay()
68-
}
69-
}
7062
}
7163
} else {
7264
NoSelectionInspectorView()
@@ -89,36 +81,20 @@ struct FileInspectorView: View {
8981
}
9082
}
9183

92-
func addTestNotification () {
93-
NotificationManager.shared.post(
94-
iconSymbol: "bell.badge.fill",
95-
iconColor: .red,
96-
title: "New Notification Created",
97-
description: "Successfully created new notification",
98-
actionButtonTitle: "Action",
99-
action: {
100-
print("Action taken")
101-
}
102-
)
103-
}
104-
105-
func addTestNotificationAfterDelay () {
106-
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
107-
addTestNotification()
108-
}
109-
}
110-
11184
@ViewBuilder private var fileNameField: some View {
85+
@State var isValid: Bool = true
86+
11287
if let file {
11388
TextField("Name", text: $fileName)
11489
.background(
115-
file.validateFileName(for: fileName) ? Color.clear : Color(errorRed)
90+
isValid ? Color.clear : Color(errorRed)
11691
)
11792
.onSubmit {
11893
if file.validateFileName(for: fileName) {
11994
let destinationURL = file.url
12095
.deletingLastPathComponent()
12196
.appendingPathComponent(fileName)
97+
isValid = true
12298
DispatchQueue.main.async { [weak workspace] in
12399
do {
124100
if let newItem = try workspace?.workspaceFileManager?.move(
@@ -136,6 +112,7 @@ struct FileInspectorView: View {
136112
}
137113
}
138114
} else {
115+
isValid = false
139116
fileName = file.labelFileName()
140117
}
141118
}

CodeEdit/Features/Notifications/NotificationManager.swift

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,17 @@ final class NotificationManager: NSObject, ObservableObject {
2222
/// Collection of all notifications, both read and unread
2323
@Published private(set) var notifications: [CENotification] = []
2424

25-
/// Currently displayed notification in the overlay
25+
/// Currently displayed notifications in the overlay
2626
@Published private(set) var activeNotification: CENotification?
27+
@Published private(set) var activeNotifications: [CENotification] = []
2728

28-
private var timer: Timer?
29+
private var timers: [UUID: Timer] = [:]
2930
private let displayDuration: TimeInterval = 5.0
3031
private var isPaused: Bool = false
3132
private var isAppActive: Bool = true
33+
private var hiddenStickyNotifications: [CENotification] = []
34+
private var hiddenNonStickyNotifications: [CENotification] = []
35+
private var dismissedNotificationIds: Set<UUID> = [] // Track dismissed notifications
3236

3337
override private init() {
3438
super.init()
@@ -69,9 +73,9 @@ final class NotificationManager: NSObject, ObservableObject {
6973
notifications.filter { !$0.isRead }.count
7074
}
7175

72-
/// Whether there is currently a notification being displayed in the overlay
76+
/// Whether there are currently notifications being displayed in the overlay
7377
var hasActiveNotification: Bool {
74-
activeNotification != nil
78+
!activeNotifications.isEmpty
7579
}
7680

7781
/// Posts a new notification
@@ -209,53 +213,53 @@ final class NotificationManager: NSObject, ObservableObject {
209213

210214
/// Shows a notification in the app's overlay UI
211215
private func showTemporaryNotification(_ notification: CENotification) {
212-
activeNotification = notification
216+
activeNotifications.insert(notification, at: 0) // Add to start of array
213217

214218
guard !notification.isSticky else { return }
215219

216-
startHideTimer()
220+
startHideTimer(for: notification)
217221
}
218222

219-
/// Starts the timer to automatically hide non-sticky notifications
220-
private func startHideTimer() {
221-
timer?.invalidate()
222-
timer = nil
223+
/// Starts the timer to automatically hide a non-sticky notification
224+
private func startHideTimer(for notification: CENotification) {
225+
timers[notification.id]?.invalidate()
226+
timers[notification.id] = nil
223227

224228
guard !isPaused else { return }
225229

226-
timer = Timer.scheduledTimer(withTimeInterval: displayDuration, repeats: false) { [weak self] _ in
227-
self?.hideActiveNotification()
230+
timers[notification.id] = Timer.scheduledTimer(
231+
withTimeInterval: displayDuration,
232+
repeats: false
233+
) { [weak self] _ in
234+
self?.hideNotification(notification)
228235
}
229236
}
230237

231-
/// Pauses the auto-hide timer (used when hovering over notification)
238+
/// Pauses all auto-hide timers
232239
func pauseTimer() {
233240
isPaused = true
234-
timer?.invalidate()
235-
timer = nil
241+
timers.values.forEach { $0.invalidate() }
236242
}
237243

238-
/// Resumes the auto-hide timer
244+
/// Resumes all auto-hide timers
239245
func resumeTimer() {
240246
isPaused = false
241-
if activeNotification != nil && !activeNotification!.isSticky {
242-
startHideTimer()
243-
}
247+
activeNotifications
248+
.filter { !$0.isSticky }
249+
.forEach { startHideTimer(for: $0) }
244250
}
245251

246-
/// Hides the currently active notification
247-
func hideActiveNotification() {
248-
activeNotification = nil
249-
timer?.invalidate()
250-
timer = nil
252+
/// Hides a specific notification
253+
private func hideNotification(_ notification: CENotification) {
254+
timers[notification.id]?.invalidate()
255+
timers[notification.id] = nil
256+
activeNotifications.removeAll(where: { $0.id == notification.id })
251257
}
252258

253259
/// Dismisses a specific notification
254-
/// - Parameter notification: The notification to dismiss
255260
func dismissNotification(_ notification: CENotification) {
256-
if activeNotification?.id == notification.id {
257-
hideActiveNotification()
258-
}
261+
hideNotification(notification)
262+
dismissedNotificationIds.insert(notification.id) // Track dismissed notification
259263
notifications.removeAll(where: { $0.id == notification.id })
260264
}
261265

@@ -276,6 +280,23 @@ final class NotificationManager: NSObject, ObservableObject {
276280
dismissNotification(notification)
277281
}
278282
}
283+
284+
/// Hides all notifications from the overlay view
285+
func hideOverlayNotifications() {
286+
dismissedNotificationIds.removeAll() // Clear dismissed tracking when hiding
287+
hiddenStickyNotifications = activeNotifications.filter { $0.isSticky }
288+
hiddenNonStickyNotifications = activeNotifications.filter { !$0.isSticky }
289+
activeNotifications.removeAll()
290+
}
291+
292+
/// Restores only sticky notifications to the overlay
293+
func restoreOverlayStickies() {
294+
// Only restore sticky notifications that weren't dismissed
295+
let nonDismissedStickies = hiddenStickyNotifications.filter { !dismissedNotificationIds.contains($0.id) }
296+
activeNotifications.insert(contentsOf: nonDismissedStickies, at: 0)
297+
hiddenStickyNotifications.removeAll()
298+
dismissedNotificationIds.removeAll() // Clear tracking after restore
299+
}
279300
}
280301

281302
// MARK: - UNUserNotificationCenterDelegate

CodeEdit/Features/Notifications/Views/NotificationBannerView.swift

Lines changed: 83 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ struct NotificationBannerView: View {
1414
@Environment(\.isSingleListItem)
1515
private var isSingleListItem
1616

17+
@Environment(\.colorScheme)
18+
private var colorScheme
19+
1720
let notification: CENotification
1821
let namespace: Namespace.ID
1922
let onDismiss: () -> Void
@@ -66,36 +69,50 @@ struct NotificationBannerView: View {
6669
.foregroundColor(.secondary)
6770
}
6871
.frame(maxWidth: .infinity, alignment: .leading)
72+
.mask(
73+
LinearGradient(
74+
gradient: Gradient(
75+
colors: [
76+
.black,
77+
.black,
78+
!notification.isSticky && isHovering ? .clear : .black,
79+
!notification.isSticky && isHovering ? .clear : .black
80+
]
81+
),
82+
startPoint: .leading,
83+
endPoint: .trailing
84+
)
85+
)
6986
}
70-
HStack(spacing: 8) {
71-
Button(action: onDismiss, label: {
72-
Text("Dismiss")
73-
.frame(maxWidth: .infinity)
74-
})
75-
.buttonStyle(.secondaryBlur)
76-
.controlSize(.small)
77-
Button(action: onAction, label: {
78-
Text(notification.actionButtonTitle)
79-
.frame(maxWidth: .infinity)
80-
})
81-
.buttonStyle(.secondaryBlur)
82-
.controlSize(.small)
87+
if notification.isSticky {
88+
HStack(spacing: 8) {
89+
Button(action: onDismiss, label: {
90+
Text("Dismiss")
91+
.frame(maxWidth: .infinity)
92+
})
93+
.buttonStyle(.secondaryBlur)
94+
.controlSize(.small)
95+
Button(action: onAction, label: {
96+
Text(notification.actionButtonTitle)
97+
.frame(maxWidth: .infinity)
98+
})
99+
.buttonStyle(.secondaryBlur)
100+
.controlSize(.small)
101+
}
102+
.transition(.opacity.combined(with: .move(edge: .top)))
83103
}
84104
}
85105
.padding(10)
86-
.matchedGeometryEffect(id: "content-\(notification.id)", in: namespace)
87106
}
88107

89108
private var backgroundContainer: some View {
90109
RoundedRectangle(cornerRadius: cornerRadius)
91110
.fill(.regularMaterial)
92-
.matchedGeometryEffect(id: "background-\(notification.id)", in: namespace)
93111
}
94112

95113
private var borderOverlay: some View {
96114
RoundedRectangle(cornerRadius: cornerRadius)
97115
.stroke(Color(nsColor: .separatorColor), lineWidth: 2)
98-
.matchedGeometryEffect(id: "border-\(notification.id)", in: namespace)
99116
}
100117

101118
private var dragGesture: some Gesture {
@@ -128,27 +145,63 @@ struct NotificationBannerView: View {
128145

129146
var body: some View {
130147
VStack {
131-
if shouldShowBackground {
132-
content
133-
.background(backgroundContainer)
134-
.overlay(borderOverlay)
135-
.cornerRadius(cornerRadius)
136-
.shadow(
137-
color: Color(.black.withAlphaComponent(0.2)),
138-
radius: 5,
139-
x: 0,
140-
y: 2
141-
)
142-
} else {
143-
content
148+
content
149+
.background(backgroundContainer)
150+
.overlay(borderOverlay)
151+
.cornerRadius(cornerRadius)
152+
.shadow(
153+
color: Color(.black.withAlphaComponent(colorScheme == .dark ? 0.2 : 0.1)),
154+
radius: 5,
155+
x: 0,
156+
y: 2
157+
)
158+
}
159+
.overlay(alignment: .bottomTrailing) {
160+
if !notification.isSticky && isHovering {
161+
Button(action: onAction, label: {
162+
Text(notification.actionButtonTitle)
163+
})
164+
.buttonStyle(.secondaryBlur)
165+
.controlSize(.small)
166+
.padding(10)
167+
.transition(.opacity)
168+
}
169+
}
170+
.overlay(alignment: .topLeading) {
171+
if !notification.isSticky && isHovering {
172+
Button(action: onDismiss) {
173+
Image(systemName: "xmark")
174+
.font(.system(size: 10))
175+
.foregroundColor(.secondary)
176+
.frame(width: 20, height: 20, alignment: .center)
177+
.background(.regularMaterial)
178+
.overlay(
179+
RoundedRectangle(cornerRadius: 10)
180+
.stroke(Color(nsColor: .separatorColor), lineWidth: 2)
181+
)
182+
.cornerRadius(10)
183+
.shadow(
184+
color: Color(.black.withAlphaComponent(colorScheme == .dark ? 0.2 : 0.1)),
185+
radius: 5,
186+
x: 0,
187+
y: 2
188+
)
189+
}
190+
.buttonStyle(.borderless)
191+
.padding(.top, -5)
192+
.padding(.leading, -5)
193+
.transition(.opacity)
144194
}
145195
}
146196
.frame(width: 300)
147197
.offset(x: offset)
148198
.opacity(opacity)
149199
.simultaneousGesture(dragGesture)
150200
.onHover { hovering in
151-
isHovering = hovering
201+
withAnimation(.easeOut(duration: 0.2)) {
202+
isHovering = hovering
203+
}
204+
152205
if hovering {
153206
NotificationManager.shared.pauseTimer()
154207
} else {

0 commit comments

Comments
 (0)