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
8 changes: 8 additions & 0 deletions Dayflow/Dayflow/App/PauseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ final class PauseManager: ObservableObject {
func pause(for duration: PauseDuration, source: PauseSource) {
// Stop any existing timer
stopTimer()
RecordingResumeNotificationCoordinator.shared.clearPendingAutoResume()

// Store for analytics
currentPauseDuration = duration
Expand Down Expand Up @@ -136,6 +137,13 @@ final class PauseManager: ObservableObject {
isPausedIndefinitely = false
currentPauseDuration = nil

switch source {
case .timerExpired, .wakeFromSleep:
RecordingResumeNotificationCoordinator.shared.markPendingAutoResume(source: source)
case .userClickedMenuBar, .userClickedMainApp:
RecordingResumeNotificationCoordinator.shared.clearPendingAutoResume()
}

// Start recording
AppState.shared.isRecording = true

Expand Down
127 changes: 116 additions & 11 deletions Dayflow/Dayflow/Core/Notifications/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,24 @@ import AppKit
import Foundation
@preconcurrency import UserNotifications

enum NotificationPermissionState: Equatable {
case notDetermined
case denied
case authorized

var isAuthorized: Bool {
self == .authorized
}
}

@MainActor
final class NotificationService: NSObject, ObservableObject {
static let shared = NotificationService()

private let center = UNUserNotificationCenter.current()

@Published private(set) var permissionGranted: Bool = false
@Published private(set) var permissionState: NotificationPermissionState = .notDetermined

override private init() {
super.init()
Expand All @@ -30,7 +41,7 @@ final class NotificationService: NSObject, ObservableObject {

// Check current permission status
Task {
await checkPermissionStatus()
await refreshPermissionStatus()

// Reschedule if reminders are enabled
if NotificationPreferences.isEnabled {
Expand All @@ -44,9 +55,7 @@ final class NotificationService: NSObject, ObservableObject {
func requestPermission() async -> Bool {
do {
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
await MainActor.run {
self.permissionGranted = granted
}
_ = await refreshPermissionStatus()
print("[NotificationService] requestPermission granted=\(granted)")
return granted
} catch {
Expand All @@ -55,6 +64,28 @@ final class NotificationService: NSObject, ObservableObject {
}
}

@discardableResult
func refreshPermissionStatus() async -> NotificationPermissionState {
let settings = await center.notificationSettings()
let state = Self.permissionState(for: settings)
permissionState = state
permissionGranted = state.isAuthorized
return state
}

func openNotificationSettings() {
let candidateURLs = [
"x-apple.systempreferences:com.apple.Notifications-Settings.extension",
"x-apple.systempreferences:com.apple.preference.notifications",
]

for candidate in candidateURLs {
if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
return
}
}
}

/// Schedule all reminders based on current preferences
func scheduleReminders() {
// First, cancel all existing journal reminders
Expand Down Expand Up @@ -166,15 +197,26 @@ final class NotificationService: NSObject, ObservableObject {
}
}

// MARK: - Private Methods
func scheduleRecordingResumedNotification(source: ResumeSource) {
Task {
let settings = await center.notificationSettings()
let permissionState = Self.permissionState(for: settings)

private func checkPermissionStatus() async {
let settings = await center.notificationSettings()
await MainActor.run {
self.permissionGranted = Self.canScheduleNotifications(for: settings.authorizationStatus)
guard permissionState.isAuthorized else {
print(
"[NotificationService] Skipping recording resumed notification: "
+ "source=\(source.rawValue) permission_status=\(Self.authorizationStatusName(settings.authorizationStatus)) "
+ "alert_setting=\(Self.notificationSettingName(settings.alertSetting))"
)
return
}

enqueueRecordingResumedNotification(source: source)
}
}

// MARK: - Private Methods

private func enqueueDailyRecapReadyNotification(
forDay day: String, settings: UNNotificationSettings
) {
Expand Down Expand Up @@ -234,6 +276,34 @@ final class NotificationService: NSObject, ObservableObject {
}
}

private func enqueueRecordingResumedNotification(source: ResumeSource) {
let identifier = "recording.resumed"
let content = UNMutableNotificationContent()
content.title = "Recording resumed"
content.body = recordingResumedBody(for: source)
content.sound = .default
content.categoryIdentifier = "recording_resumed"
content.userInfo = ["source": source.rawValue]

let request = UNNotificationRequest(
identifier: "\(identifier).\(source.rawValue).\(Date().timeIntervalSince1970)",
content: content,
trigger: nil
)

center.add(request) { error in
if let error {
print(
"[NotificationService] Failed to schedule recording resumed notification: \(error)")
return
}
print(
"[NotificationService] Scheduled recording resumed notification "
+ "source=\(source.rawValue)"
)
}
}

private static func authorizationStatusName(_ status: UNAuthorizationStatus) -> String {
switch status {
case .notDetermined:
Expand All @@ -258,6 +328,20 @@ final class NotificationService: NSObject, ObservableObject {
}
}

private static func permissionState(for settings: UNNotificationSettings) -> NotificationPermissionState
{
switch settings.authorizationStatus {
case .authorized, .provisional:
return settings.alertSetting == .enabled ? .authorized : .denied
case .denied:
return .denied
case .notDetermined:
return .notDetermined
@unknown default:
return .notDetermined
}
}

private static func notificationSettingName(_ setting: UNNotificationSetting) -> String {
switch setting {
case .notSupported:
Expand All @@ -271,6 +355,17 @@ final class NotificationService: NSObject, ObservableObject {
}
}

private func recordingResumedBody(for source: ResumeSource) -> String {
switch source {
case .timerExpired:
return "Dayflow resumed recording after your pause ended."
case .wakeFromSleep:
return "Dayflow resumed recording after your Mac woke up."
case .userClickedMenuBar, .userClickedMainApp:
return "Dayflow is recording again."
}
}

private func scheduleNotification(
identifier: String,
title: String,
Expand Down Expand Up @@ -333,8 +428,9 @@ extension NotificationService: UNUserNotificationCenterDelegate {

let isJournalNotification = identifier.hasPrefix("journal.")
let isDailyRecapNotification = identifier.hasPrefix("daily.")
let isRecordingNotification = identifier.hasPrefix("recording.")

guard isJournalNotification || isDailyRecapNotification else {
guard isJournalNotification || isDailyRecapNotification || isRecordingNotification else {
completionHandler()
return
}
Expand All @@ -347,7 +443,7 @@ extension NotificationService: UNUserNotificationCenterDelegate {
activateAppForNotificationTap()
print(
"[NotificationService] didReceive journal notification handled identifier=\(identifier)")
} else {
} else if isDailyRecapNotification {
AppDelegate.pendingNavigationToDailyDay = day
AppDelegate.pendingNavigationToJournal = false

Expand All @@ -373,6 +469,9 @@ extension NotificationService: UNUserNotificationCenterDelegate {
print("[NotificationService] didReceive daily notification navigation target_day=unknown")
}

activateAppForNotificationTap()
} else {
print("[NotificationService] didReceive recording notification tap identifier=\(identifier)")
activateAppForNotificationTap()
}
}
Expand Down Expand Up @@ -407,6 +506,12 @@ extension NotificationService: UNUserNotificationCenterDelegate {
return
}

if identifier.hasPrefix("recording.") {
print("[NotificationService] willPresent options=banner,sound identifier=\(identifier)")
completionHandler([.banner, .sound])
return
}

print("[NotificationService] willPresent: unknown notification identifier, skipping")
completionHandler([])
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

@MainActor
final class RecordingResumeNotificationCoordinator {
static let shared = RecordingResumeNotificationCoordinator()

private var pendingAutoResumeSource: ResumeSource?

private init() {}

func markPendingAutoResume(source: ResumeSource) {
pendingAutoResumeSource = source
}

func consumePendingAutoResumeSource() -> ResumeSource? {
let source = pendingAutoResumeSource
pendingAutoResumeSource = nil
return source
}

func clearPendingAutoResume() {
pendingAutoResumeSource = nil
}
}
14 changes: 14 additions & 0 deletions Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ final class ScreenRecorder: NSObject, @unchecked Sendable {
guard let self else { return }
self.wantsRecording = rec

if !rec {
Task { @MainActor in
RecordingResumeNotificationCoordinator.shared.clearPendingAutoResume()
}
}

// Clear paused state when user disables recording
if !rec && self.state == .paused {
self.transition(to: .idle, context: "user disabled recording")
Expand Down Expand Up @@ -261,6 +267,14 @@ final class ScreenRecorder: NSObject, @unchecked Sendable {
self.startCaptureTimer()
self.transition(to: .capturing, context: "capture started")

Task { @MainActor in
if let source =
RecordingResumeNotificationCoordinator.shared.consumePendingAutoResumeSource()
{
NotificationService.shared.scheduleRecordingResumedNotification(source: source)
}
}

// Take first screenshot immediately
Task { await self.captureScreenshot() }
}
Expand Down
23 changes: 23 additions & 0 deletions Dayflow/Dayflow/Views/UI/Settings/OtherSettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ final class OtherSettingsViewModel: ObservableObject {
UserDefaults.standard.set(showTimelineAppIcons, forKey: "showTimelineAppIcons")
}
}
@Published private(set) var notificationPermissionState: NotificationPermissionState = .notDetermined
@Published private(set) var isUpdatingNotificationPermission = false
@Published var outputLanguageOverride: String
@Published var isOutputLanguageOverrideSaved: Bool = true

Expand Down Expand Up @@ -72,6 +74,27 @@ final class OtherSettingsViewModel: ObservableObject {
analyticsEnabled = AnalyticsService.shared.isOptedIn
}

func refreshNotificationPermissionState() {
Task { @MainActor in
notificationPermissionState = await NotificationService.shared.refreshPermissionStatus()
}
}

func requestNotificationPermission() {
guard !isUpdatingNotificationPermission else { return }
isUpdatingNotificationPermission = true

Task { @MainActor in
_ = await NotificationService.shared.requestPermission()
notificationPermissionState = await NotificationService.shared.refreshPermissionStatus()
isUpdatingNotificationPermission = false
}
}

func openNotificationSettings() {
NotificationService.shared.openNotificationSettings()
}

func exportTimelineRange() {
guard !isExportingTimelineRange else { return }

Expand Down
Loading