diff --git a/tedee example.xcodeproj/project.pbxproj b/tedee example.xcodeproj/project.pbxproj index 7750572..ebbc7af 100644 --- a/tedee example.xcodeproj/project.pbxproj +++ b/tedee example.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ D3B374722B8F5E1B00EDA7A5 /* ContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B3746C2B8F5E1B00EDA7A5 /* ContentViewModel.swift */; }; D3B374732B8F5E1B00EDA7A5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B3746D2B8F5E1B00EDA7A5 /* ContentView.swift */; }; D3B374942B8F700A00EDA7A5 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B374932B8F700A00EDA7A5 /* Configuration.swift */; }; + D3B374952B8F800B00EDA7A5 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3B374952B8F800A00EDA7A5 /* NotificationManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -37,6 +38,7 @@ D3B3746C2B8F5E1B00EDA7A5 /* ContentViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = ""; }; D3B3746D2B8F5E1B00EDA7A5 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; D3B374932B8F700A00EDA7A5 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = ""; }; + D3B374952B8F800A00EDA7A5 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -76,6 +78,7 @@ D3B3746C2B8F5E1B00EDA7A5 /* ContentViewModel.swift */, D3B3746D2B8F5E1B00EDA7A5 /* ContentView.swift */, D3B374932B8F700A00EDA7A5 /* Configuration.swift */, + D3B374952B8F800A00EDA7A5 /* NotificationManager.swift */, ); path = "tedee example"; sourceTree = ""; @@ -169,6 +172,7 @@ D3B3746F2B8F5E1B00EDA7A5 /* tedee_exampleApp.swift in Sources */, D3B374942B8F700A00EDA7A5 /* Configuration.swift in Sources */, D3B374732B8F5E1B00EDA7A5 /* ContentView.swift in Sources */, + D3B374952B8F800B00EDA7A5 /* NotificationManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -308,6 +312,7 @@ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth is required to make connection to tedee Lock"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -338,6 +343,7 @@ INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Bluetooth is required to make connection to tedee Lock"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central"; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/tedee example/ContentView.swift b/tedee example/ContentView.swift index a4f6f4a..6f7b48a 100644 --- a/tedee example/ContentView.swift +++ b/tedee example/ContentView.swift @@ -89,18 +89,18 @@ struct ContentView: View { Text("Pull") } .buttonStyle(.bordered) - + Spacer() - + Button { viewModel.openLock() } label: { Text("Open") } .buttonStyle(.bordered) - + Spacer() - + Button { viewModel.closeLock() } label: { @@ -119,6 +119,33 @@ struct ContentView: View { Spacer() } } + + Section { + VStack { + Text("Persistent Notification") + .font(.headline) + HStack { + Button { + Task { + try? await NotificationManager.shared.showPersistentNotification() + } + } label: { + Text("Show Notification") + } + .buttonStyle(.bordered) + + Spacer() + + Button { + NotificationManager.shared.removePersistentNotification() + } label: { + Text("Hide Notification") + .foregroundStyle(.red) + } + .buttonStyle(.bordered) + } + } + } Section { VStack(alignment: .leading) { diff --git a/tedee example/ContentViewModel.swift b/tedee example/ContentViewModel.swift index c578643..7053b7b 100644 --- a/tedee example/ContentViewModel.swift +++ b/tedee example/ContentViewModel.swift @@ -153,7 +153,7 @@ final class ContentViewModel { } } } - + Task { @MainActor in for await notification in TedeeLockManager.shared.notificationsStream { if self.serialNumber.serialNumber == notification.serialNumber.serialNumber { @@ -169,4 +169,39 @@ final class ContentViewModel { } } } + + @MainActor + func handleNotificationAction(_ actionIdentifier: String) async { + comunicationList.append(ComunicationListItem("notification action: \(actionIdentifier)")) + + // Auto-connect if not connected + if connectionStatus != .connected { + comunicationList.append(ComunicationListItem("auto-connecting to lock...")) + await connect() + + // Wait for connection to establish + try? await Task.sleep(for: .seconds(2)) + + // Check if connected + if connectionStatus != .connected { + comunicationList.append(ComunicationListItem("failed to auto-connect")) + return + } + } + + // Execute action based on identifier + switch actionIdentifier { + case NotificationManager.openActionIdentifier: + comunicationList.append(ComunicationListItem("executing open from notification")) + await openLock() + case NotificationManager.closeActionIdentifier: + comunicationList.append(ComunicationListItem("executing close from notification")) + await closeLock() + case NotificationManager.pullSpringActionIdentifier: + comunicationList.append(ComunicationListItem("executing pull spring from notification")) + await pullLock() + default: + break + } + } } diff --git a/tedee example/NotificationManager.swift b/tedee example/NotificationManager.swift new file mode 100644 index 0000000..6942339 --- /dev/null +++ b/tedee example/NotificationManager.swift @@ -0,0 +1,123 @@ +// +// NotificationManager.swift +// tedee example +// +// Created for persistent lock control notification +// + +import Foundation +import UserNotifications +import UIKit + +@MainActor +class NotificationManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate { + static let shared = NotificationManager() + + // Action identifiers + static let openActionIdentifier = "OPEN_LOCK" + static let closeActionIdentifier = "CLOSE_LOCK" + static let pullSpringActionIdentifier = "PULL_SPRING" + + // Notification identifier + private let persistentNotificationIdentifier = "tedee_lock_control" + + // Callback for handling actions + var onActionReceived: ((String) async -> Void)? + + private override init() { + super.init() + UNUserNotificationCenter.current().delegate = self + } + + // Request notification permissions + func requestAuthorization() async throws { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) + if !granted { + throw NSError(domain: "NotificationManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Notification permission denied"]) + } + } + + // Setup notification categories with actions + func setupNotificationCategories() { + let openAction = UNNotificationAction( + identifier: Self.openActionIdentifier, + title: "🔓 Apri", + options: [.foreground] + ) + + let closeAction = UNNotificationAction( + identifier: Self.closeActionIdentifier, + title: "🔒 Chiudi", + options: [.foreground] + ) + + let pullSpringAction = UNNotificationAction( + identifier: Self.pullSpringActionIdentifier, + title: "🔄 Pull Spring", + options: [.foreground] + ) + + let category = UNNotificationCategory( + identifier: "LOCK_CONTROL", + actions: [openAction, closeAction, pullSpringAction], + intentIdentifiers: [], + options: [] + ) + + UNUserNotificationCenter.current().setNotificationCategories([category]) + } + + // Show persistent notification + func showPersistentNotification() async throws { + let content = UNMutableNotificationContent() + content.title = "Tedee Lock Control" + content.body = "Controllo rapido del tuo lock" + content.categoryIdentifier = "LOCK_CONTROL" + content.sound = nil + + // Create a trigger that fires immediately + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) + + let request = UNNotificationRequest( + identifier: persistentNotificationIdentifier, + content: content, + trigger: trigger + ) + + try await UNUserNotificationCenter.current().add(request) + } + + // Remove persistent notification + func removePersistentNotification() { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [persistentNotificationIdentifier]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [persistentNotificationIdentifier]) + } + + // MARK: - UNUserNotificationCenterDelegate + + // Handle notification when app is in foreground + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + return [.banner, .list] + } + + // Handle notification action response + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let actionIdentifier = response.actionIdentifier + + // Call the action handler on main actor + await MainActor.run { + Task { + await self.onActionReceived?(actionIdentifier) + + // Re-show the notification after action + try? await self.showPersistentNotification() + } + } + } +} diff --git a/tedee example/tedee_exampleApp.swift b/tedee example/tedee_exampleApp.swift index 4750680..5a2cd23 100644 --- a/tedee example/tedee_exampleApp.swift +++ b/tedee example/tedee_exampleApp.swift @@ -9,9 +9,34 @@ import SwiftUI @main struct tedee_exampleApp: App { + @StateObject private var viewModel = ContentViewModel() + var body: some Scene { WindowGroup { - ContentView(viewModel: ContentViewModel()) + ContentView(viewModel: viewModel) + .task { + // Initialize notification manager on main thread + let notificationManager = NotificationManager.shared + + // Setup notification categories + notificationManager.setupNotificationCategories() + + // Request authorization + do { + try await notificationManager.requestAuthorization() + + // Show persistent notification + try await notificationManager.showPersistentNotification() + } catch { + print("Notification setup error: \(error)") + } + + // Setup notification action handler + NotificationManager.shared.onActionReceived = { actionIdentifier in + await self.viewModel.handleNotificationAction(actionIdentifier) + } + } } } } +