diff --git a/Darwin/Entitlements.plist b/Darwin/Entitlements.plist index 70084b4..e267110 100644 --- a/Darwin/Entitlements.plist +++ b/Darwin/Entitlements.plist @@ -2,6 +2,8 @@ + aps-environment + development com.apple.developer.devicecheck.appattest-environment development diff --git a/Darwin/NotificationServiceExtension/Info.plist b/Darwin/NotificationServiceExtension/Info.plist new file mode 100644 index 0000000..4f01c63 --- /dev/null +++ b/Darwin/NotificationServiceExtension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/Darwin/NotificationServiceExtension/NotificationService.swift b/Darwin/NotificationServiceExtension/NotificationService.swift new file mode 100644 index 0000000..7576d91 --- /dev/null +++ b/Darwin/NotificationServiceExtension/NotificationService.swift @@ -0,0 +1,58 @@ +import UserNotifications + +/// Rich (image) push — see docs/api/push-notifications-integration-guide.md §4.3. +/// Only fires when Atomenta's push payload sets `"mutable-content": 1`, which +/// it does by default on any push carrying an `imageUrl`. +/// `@unchecked Sendable`: the extension process only ever runs one +/// `didReceive`/`serviceExtensionTimeWillExpire` cycle at a time — there's no +/// real concurrent access to `contentHandler`/`bestAttemptContent` to guard +/// against, just a background download completion handing back to this +/// instance. +final class NotificationService: UNNotificationServiceExtension, @unchecked Sendable { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + let mutableContent = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() + bestAttemptContent = mutableContent + + guard let imageURLString = request.content.userInfo["imageUrl"] as? String, + let imageURL = URL(string: imageURLString) else { + deliver() + return + } + + URLSession.shared.downloadTask(with: imageURL) { [weak self] location, _, _ in + if let location, let attachment = Self.attachment(fromDownloadedFile: location) { + self?.bestAttemptContent?.attachments = [attachment] + } + self?.deliver() + }.resume() + } + + /// The system calls this if `didReceive` doesn't finish within its time + /// budget — must still deliver the best content built so far, since + /// `contentHandler` is contractually required to run exactly once. + override func serviceExtensionTimeWillExpire() { + deliver() + } + + private func deliver() { + guard let bestAttemptContent else { return } + contentHandler?(bestAttemptContent) + contentHandler = nil + } + + private static func attachment(fromDownloadedFile location: URL) -> UNNotificationAttachment? { + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("jpg") + do { + try FileManager.default.moveItem(at: location, to: tmpURL) + return try UNNotificationAttachment(identifier: "image", url: tmpURL) + } catch { + return nil + } + } +} diff --git a/Darwin/PediFoods.xcodeproj/project.pbxproj b/Darwin/PediFoods.xcodeproj/project.pbxproj index 9a9e402..b2c4f2e 100644 --- a/Darwin/PediFoods.xcodeproj/project.pbxproj +++ b/Darwin/PediFoods.xcodeproj/project.pbxproj @@ -7,15 +7,28 @@ objects = { /* Begin PBXBuildFile section */ + 002E82AE37F71856D2AD0524 /* NotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeaderOnCopy, ); }; }; + 1457598D2491B8729EEBB739 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E34FD2046E40CE1E302DB11 /* NotificationService.swift */; }; 491F27822DA55B72004926EE /* PediFoods in Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; }; 491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; }; 499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; }; 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; }; + 837DB5EB5FA4527A71F02C70 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78EA2CD32C41A0B833E06B89 /* Foundation.framework */; }; B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; }; - B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */; }; + B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + E60A9FE80EBD27A663AF17E0 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 49F90C202A52156200F06D93 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4FB6BE053007051C3D26B06E; + remoteInfo = NotificationServiceExtension; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -28,6 +41,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + C28D30C677D0B233FE310699 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 002E82AE37F71856D2AD0524 /* NotificationServiceExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -41,7 +65,11 @@ 49F90C2B2A52156200F06D93 /* Main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Main.swift; path = Sources/Main.swift; sourceTree = SOURCE_ROOT; }; 49F90C2F2A52156300F06D93 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = ""; }; - B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; }; + 4E34FD2046E40CE1E302DB11 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 78EA2CD32C41A0B833E06B89 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Sources/LaunchScreen.storyboard; sourceTree = SOURCE_ROOT; }; + ED678A4BFF090312B354648E /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -54,13 +82,32 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CAFA309ED97AD448069C6F95 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 837DB5EB5FA4527A71F02C70 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 39B295835451899F1689190A /* NotificationServiceExtension */ = { + isa = PBXGroup; + children = ( + 4E34FD2046E40CE1E302DB11 /* NotificationService.swift */, + ED678A4BFF090312B354648E /* Info.plist */, + ); + name = NotificationServiceExtension; + path = NotificationServiceExtension; + sourceTree = ""; + }; 496BDBEC2B89A47800C09264 /* Products */ = { isa = PBXGroup; children = ( 496BDBEB2B89A47800C09264 /* PediFoods.app */, + 588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */, ); name = Products; sourceTree = ""; @@ -75,6 +122,8 @@ 493609562A6B7EAE00C401E2 /* PediFoods */, 49F90C2A2A52156200F06D93 /* App */, 496BDBEC2B89A47800C09264 /* Products */, + 39B295835451899F1689190A /* NotificationServiceExtension */, + 8F798D6B741A681CBA9CC824 /* Frameworks */, ); sourceTree = ""; }; @@ -82,7 +131,7 @@ isa = PBXGroup; children = ( 49F90C2B2A52156200F06D93 /* Main.swift */, - B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */, + B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */, 49F90C2F2A52156300F06D93 /* Assets.xcassets */, 49F90C312A52156300F06D93 /* Entitlements.plist */, 4900101C2BACEA710000DE33 /* Info.plist */, @@ -90,6 +139,22 @@ name = App; sourceTree = ""; }; + 8F798D6B741A681CBA9CC824 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C127D57D66FAAE472B202C61 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; + C127D57D66FAAE472B202C61 /* iOS */ = { + isa = PBXGroup; + children = ( + 78EA2CD32C41A0B833E06B89 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -101,10 +166,12 @@ 499CD43C2AC5B799001AE8D8 /* Frameworks */, 499CD43E2AC5B799001AE8D8 /* Resources */, 499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */, + C28D30C677D0B233FE310699 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + A3838B46B6FB8B79EE9B0F9F /* PBXTargetDependency */, ); name = "PediFoods App"; packageProductDependencies = ( @@ -115,6 +182,23 @@ productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */; productType = "com.apple.product-type.application"; }; + 4FB6BE053007051C3D26B06E /* NotificationServiceExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A20C22A5541093DB9CFD38F4 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */; + buildPhases = ( + 29CCE086EDBB10F49FF72DDA /* Sources */, + CAFA309ED97AD448069C6F95 /* Frameworks */, + 7A8A5D1C5EC61B8ACACF33CF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationServiceExtension; + productName = NotificationServiceExtension; + productReference = 588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -124,6 +208,15 @@ BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1430; LastUpgradeCheck = 1630; + TargetAttributes = { + 499CD4382AC5B799001AE8D8 = { + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + }; + }; + }; }; buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */; compatibilityVersion = "Xcode 14.0"; @@ -145,6 +238,7 @@ projectRoot = ""; targets = ( 499CD4382AC5B799001AE8D8 /* PediFoods App */, + 4FB6BE053007051C3D26B06E /* NotificationServiceExtension */, ); }; /* End PBXProject section */ @@ -154,15 +248,30 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */, + B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */, 499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */, 496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; + 7A8A5D1C5EC61B8ACACF33CF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 29CCE086EDBB10F49FF72DDA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1457598D2491B8729EEBB739 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 499CD43A2AC5B799001AE8D8 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -173,12 +282,24 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + A3838B46B6FB8B79EE9B0F9F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = NotificationServiceExtension; + target = 4FB6BE053007051C3D26B06E /* NotificationServiceExtension */; + targetProxy = E60A9FE80EBD27A663AF17E0 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 499CD4422AC5B799001AE8D8 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */; buildSettings = { - DEVELOPMENT_TEAM = K4E5BZMM4V; + CODE_SIGN_ENTITLEMENTS = Entitlements.plist; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; ENABLE_PREVIEWS = YES; INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -186,6 +307,8 @@ INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dev Prov Prof"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; TARGETED_DEVICE_FAMILY = 1; @@ -196,9 +319,12 @@ isa = XCBuildConfiguration; baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */; buildSettings = { + CODE_SIGN_ENTITLEMENTS = Entitlements.plist; CODE_SIGN_IDENTITY = "Apple Distribution"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = K4E5BZMM4V; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V; ENABLE_PREVIEWS = YES; INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods"; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -207,6 +333,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; PROVISIONING_PROFILE_SPECIFIER = "com.br.pedifoods.app AppStore"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dist XC Prov Prof"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; TARGETED_DEVICE_FAMILY = 1; @@ -249,6 +376,55 @@ }; name = Release; }; + 602E0FA08B93FC360B6AF5D1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K4E5BZMM4V; + INFOPLIST_FILE = NotificationServiceExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app.NotificationService; + PRODUCT_NAME = NotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + F90A5B614A60B25B97F3541D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K4E5BZMM4V; + INFOPLIST_FILE = NotificationServiceExtension/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 0.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.br.pedifoods.app.NotificationService; + PRODUCT_NAME = NotificationService; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 6; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -270,6 +446,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A20C22A5541093DB9CFD38F4 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F90A5B614A60B25B97F3541D /* Release */, + 602E0FA08B93FC360B6AF5D1 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Sources/PediFoods/ContentView.swift b/Sources/PediFoods/ContentView.swift index 809c4de..f92f42d 100644 --- a/Sources/PediFoods/ContentView.swift +++ b/Sources/PediFoods/ContentView.swift @@ -15,6 +15,7 @@ struct ContentView: View { @State private var sessionExpiredObserver: NSObjectProtocol? @State var cartResetObserver: Any? @State var appResumeObserver: Any? + @State var pushOrderTapObserver: Any? @StateObject var snackbarCenter = SnackbarCenter.shared var body: some View { @@ -90,11 +91,13 @@ struct ContentView: View { attachCartResetObserverIfNeeded() attachAppResumeObserverIfNeeded() attachSessionExpiredObserverIfNeeded() + attachPushOrderTapObserverIfNeeded() } .onDisappear { detachCartResetObserver() detachAppResumeObserver() detachSessionExpiredObserver() + detachPushOrderTapObserver() } } @@ -241,6 +244,38 @@ struct ContentView: View { self.appResumeObserver = nil } + /// §6 of the push notifications guide — an `order_status` push tap + /// (reported by `PushNotificationCoordinator`) routes here to the + /// Profile tab's Orders list, pre-targeted at that order. + private func attachPushOrderTapObserverIfNeeded() { + guard pushOrderTapObserver == nil else { return } + pushOrderTapObserver = NotificationCenter.default.addObserver( + forName: .pushTappedOrderStatus, + object: nil, + queue: nil + ) { notification in + let orderId = notification.userInfo?["orderId"] as? String + let shortId = notification.userInfo?["shortId"] as? String + Task { @MainActor in + guard root == .main, let orderId else { return } + appState.pendingOrderDeepLink = OrderRouteContext( + orderId: orderId, + shortId: shortId, + paymentMethod: nil, + total: nil, + intent: .auto + ) + selectedTab = .profile + } + } + } + + private func detachPushOrderTapObserver() { + guard let pushOrderTapObserver else { return } + NotificationCenter.default.removeObserver(pushOrderTapObserver) + self.pushOrderTapObserver = nil + } + @MainActor private func hydrateAppState(with customer: CustomerProfile) { appState.profile.id = customer.id diff --git a/Sources/PediFoods/PediFoodsApp.swift b/Sources/PediFoods/PediFoodsApp.swift index 9b2eeea..61b1e27 100644 --- a/Sources/PediFoods/PediFoodsApp.swift +++ b/Sources/PediFoods/PediFoodsApp.swift @@ -18,7 +18,15 @@ public final class PediFoodsAppDelegate: Sendable { private init() {} public func onInit() { logger.debug("onInit") } - public func onLaunch() { logger.debug("onLaunch") } + public func onLaunch() { + logger.debug("onLaunch") + Task { @MainActor in + PushNotificationCoordinator.shared.startObservingDeviceToken() + PushNotificationCoordinator.shared.becomeNotificationCenterDelegate() + await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized() + await PushNotificationCoordinator.shared.syncCustomerAttributes() + } + } public func onResume() { logger.debug("onResume") NotificationCenter.default.post(name: .appDidResume, object: nil) diff --git a/Sources/PediFoods/Resources/Localizable.xcstrings b/Sources/PediFoods/Resources/Localizable.xcstrings index b1307e2..a6ad1e4 100644 --- a/Sources/PediFoods/Resources/Localizable.xcstrings +++ b/Sources/PediFoods/Resources/Localizable.xcstrings @@ -89,6 +89,9 @@ "10km" : { "comment" : "A label displayed next to the far end of the distance slider in the filters modal.", "isCommentAutoGenerated" : true + }, + "Abrir Ajustes" : { + }, "Acompanhamento em tempo real" : { @@ -148,6 +151,9 @@ "Adicione produtos para continuar." : { "comment" : "A message displayed when the cart is empty, encouraging the user to add products.", "isCommentAutoGenerated" : true + }, + "Agora não" : { + }, "AGUARDANDO PAGAMENTO" : { "comment" : "A status text indicating that the payment is pending.", @@ -212,6 +218,15 @@ "Até %lldkm" : { "comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.", "isCommentAutoGenerated" : true + }, + "Ativar" : { + + }, + "Ative as notificações" : { + + }, + "Ative as notificações para acompanhar em tempo real as atualizações do seu pedido." : { + }, "Atualizando status do pedido..." : { @@ -818,6 +833,12 @@ } } } + }, + "Notificações" : { + + }, + "Notificações desativadas" : { + }, "Novo cartão" : { @@ -879,6 +900,9 @@ }, "Pague mais rápido nas próximas compras" : { + }, + "Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone." : { + }, "Pedido #%@" : { diff --git a/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift b/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift index fc119c8..3dc5fa9 100644 --- a/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift +++ b/Sources/PediFoods/Services/ApiCustomerPayloadModels.swift @@ -38,6 +38,53 @@ struct CustomerIdentityUpdatePayload: Encodable { } } +/// `POST /api/customer/:id` — partial update, sibling of `CustomerProfileUpdatePayload`. +/// See docs/api/push-notifications-integration-guide.md §2b. +struct CustomerNotificationsUpdatePayload: Encodable { + let notificationsEnabled: Bool +} + +/// `POST /api/customer/:id` — biometric-login preference. Persistence only for +/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan. +struct CustomerFaceIdUpdatePayload: Encodable { + let faceIdEnabled: Bool +} + +/// `PUT /api/customer/:id/push-token` — see docs/api/push-notifications-integration-guide.md §2. +struct CustomerPushTokenPayload: Encodable { + let pushToken: String + let deviceId: String + let deviceOS: String +} + +/// `PUT /api/customer/:id/attributes` — wholesale replace, not a merge. +/// See docs/api/push-notifications-integration-guide.md §2a. +struct CustomerAttributesUpdatePayload: Encodable { + let appVersion: String? + let attributes: [String: String]? + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(appVersion, forKey: .appVersion) + try container.encodeIfPresent(attributes, forKey: .attributes) + } + + enum CodingKeys: String, CodingKey { + case appVersion + case attributes + } +} + +/// `POST /api/customer/:id/push-campaigns/opened` — see +/// docs/api/push-notifications-integration-guide.md §6a. +struct PushCampaignOpenedPayload: Encodable { + let campaignId: String +} + +struct PushCampaignOpenedResult: Decodable { + let recorded: Bool +} + struct CustomerAddressPayload: Encodable { let label: String? let address: String? diff --git a/Sources/PediFoods/Services/ApiModels.swift b/Sources/PediFoods/Services/ApiModels.swift index dc73a2e..a156139 100644 --- a/Sources/PediFoods/Services/ApiModels.swift +++ b/Sources/PediFoods/Services/ApiModels.swift @@ -30,6 +30,8 @@ struct CustomerProfile: Decodable { let profilePicture: String? let favorites: [String]? let addressBook: [CustomerAddress]? + let notificationsEnabled: Bool? + let faceIdEnabled: Bool? enum CodingKeys: String, CodingKey { case id @@ -39,6 +41,8 @@ struct CustomerProfile: Decodable { case profilePicture case favorites case addressBook = "address_book" + case notificationsEnabled + case faceIdEnabled } } diff --git a/Sources/PediFoods/Services/ApiService.swift b/Sources/PediFoods/Services/ApiService.swift index 2eba839..19aa0e7 100644 --- a/Sources/PediFoods/Services/ApiService.swift +++ b/Sources/PediFoods/Services/ApiService.swift @@ -88,6 +88,17 @@ final class ApiService { AppContentCache.shared.invalidate(prefix: favoritesCachePrefix) } + /// Every successful profile mutation must leave `profile()`'s cache + /// holding the server's authoritative post-mutation state — never + /// patched locally from a write-response of possibly different shape, + /// and never left merely invalidated for some future caller to lazily + /// refetch (which may never happen, leaving stale data visible + /// indefinitely within the TTL). Always does a real GET. + @discardableResult + private func refreshProfileCache() async -> ApiEnvelope? { + try? await profile(forceRefresh: true) + } + private func scopedCacheSuffix() -> String { let jwt = tokenStore.jwt ?? "anonymous" if jwt.count <= 16 { return jwt } @@ -184,10 +195,90 @@ final class ApiService { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) let envelope: ProfilePatchEnvelope = try await send(req) - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + if envelope.error == false { + await refreshProfileCache() + } return envelope } + /// See docs/api/push-notifications-integration-guide.md §2b — only reachable + /// via `POST /api/customer/:id` today, not `PATCH /profile`. + func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// Persists the biometric-login preference only — no LocalAuthentication + /// wiring yet, that's a separate later plan. + func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body) + let envelope: ApiEnvelope = try await sendEnvelope(req) + if envelope.error == false { + await refreshProfileCache() + } + return envelope + } + + /// See docs/api/push-notifications-integration-guide.md §2. + func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + /// See docs/api/push-notifications-integration-guide.md §2a. Wholesale + /// replace, not a merge — callers must pass every `attributes` key they + /// still want kept, not just the changed ones. + func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + + /// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap + /// only, for `type: "campaign"` pushes — idempotent server-side. + func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope { + let currentProfile = try await profile(forceRefresh: true) + guard currentProfile.error == false, let customer = currentProfile.result else { + throw NetworkError.invalidResponse + } + + let payload = PushCampaignOpenedPayload(campaignId: campaignId) + let body = try JSONEncoder().encode(payload) + let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body) + return try await sendEnvelope(req) + } + func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope { return try await saveCustomerAddress(address, replacingAddressId: nil) } @@ -234,7 +325,9 @@ final class ApiService { func setDefaultAddress(addressId: String) async throws -> ApiEnvelope { let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true) let envelope: ApiEnvelope = try await sendEnvelope(req) - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + if envelope.error == false { + await refreshProfileCache() + } return envelope } @@ -259,9 +352,8 @@ final class ApiService { let body = try JSONEncoder().encode(payload) let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body) let envelope: ApiEnvelope = try await sendEnvelope(req) - let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())" - if envelope.error == false, envelope.result != nil { - AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours) + if envelope.error == false { + await refreshProfileCache() } else { invalidateFavoritesCache() } @@ -488,7 +580,7 @@ final class ApiService { let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body) let result: ApiEnvelope = try await sendEnvelope(req) if result.error == false { - AppContentCache.shared.invalidate(prefix: profileCachePrefix) + await refreshProfileCache() } return result } diff --git a/Sources/PediFoods/Services/PushNotificationCoordinator.swift b/Sources/PediFoods/Services/PushNotificationCoordinator.swift new file mode 100644 index 0000000..5404455 --- /dev/null +++ b/Sources/PediFoods/Services/PushNotificationCoordinator.swift @@ -0,0 +1,215 @@ +import Foundation + +/// Client-side half of docs/api/push-notifications-integration-guide.md. +/// Owns OS permission state, APNs device-token registration, the +/// "enable notifications" action shared by the profile toggle and the +/// order-tracking fallback prompt (§2b), foreground/tap notification +/// handling (§6), and campaign open tracking (§6a). +enum PushAuthorizationState { + case authorized + case denied + case notDetermined +} + +/// Posted when the user taps an `order_status` push (§6) so `ContentView` +/// can route to that order without this service depending on `AppState`. +extension Notification.Name { + static let pushTappedOrderStatus = Notification.Name("pushTappedOrderStatus") +} + +#if os(iOS) +import UIKit +import UserNotifications + +@MainActor +final class PushNotificationCoordinator: NSObject { + static let shared = PushNotificationCoordinator() + + private var deviceTokenObserver: NSObjectProtocol? + private var didBecomeDelegate = false + + private override init() {} + + /// Call once at app launch. Listens for the device token `PediFoodsAppDelegate` + /// posts after `registerForRemoteNotifications()` resolves, and forwards it + /// to Atomenta (§2). + func startObservingDeviceToken() { + guard deviceTokenObserver == nil else { return } + deviceTokenObserver = NotificationCenter.default.addObserver( + forName: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), + object: nil, + queue: .main + ) { notification in + guard let data = notification.userInfo?["deviceToken"] as? Data else { return } + let hexToken = data.map { String(format: "%02x", $0) }.joined() + Task { await PushNotificationCoordinator.shared.sendTokenToBackend(hexToken) } + } + } + + /// Call once at app launch, before the first notification could possibly + /// arrive — makes this the `UNUserNotificationCenterDelegate` so foreground + /// pushes actually display (§6) and taps get routed/tracked (§6, §6a). + func becomeNotificationCenterDelegate() { + guard didBecomeDelegate == false else { return } + didBecomeDelegate = true + UNUserNotificationCenter.current().delegate = self + } + + /// Re-registers silently (no OS prompt) if the user already granted + /// authorization in a previous session — tokens aren't guaranteed stable + /// across launches (§2, §3.3/§4.2 of the guide). Safe to call before login. + func refreshRegistrationIfAuthorized() async { + guard await currentAuthorizationState() == .authorized else { return } + UIApplication.shared.registerForRemoteNotifications() + } + + func currentAuthorizationState() async -> PushAuthorizationState { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return .authorized + case .denied: + return .denied + case .notDetermined: + return .notDetermined + @unknown default: + return .notDetermined + } + } + + /// Shows the OS permission dialog only if it hasn't been answered yet. + /// Always calls `registerForRemoteNotifications()` when authorized — + /// including when authorization was already granted in a past session — + /// so "enable" reliably produces a fresh device token this run instead of + /// relying solely on the once-per-launch refresh. + @discardableResult + private func requestAuthorizationIfNeeded() async -> Bool { + switch await currentAuthorizationState() { + case .authorized: + UIApplication.shared.registerForRemoteNotifications() + return true + case .denied: + return false + case .notDetermined: + let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])) ?? false + if granted { + UIApplication.shared.registerForRemoteNotifications() + } + return granted + } + } + + /// Shared body of §2b's two touchpoints: request OS permission (if + /// undetermined), then flip Atomenta's `notificationsEnabled` flag. + /// Returns the server's authoritative post-update profile — callers must + /// reflect `result.notificationsEnabled` from this, not assume `true` + /// just because the request succeeded. + func enableNotifications() async -> CustomerProfile? { + guard await requestAuthorizationIfNeeded() else { return nil } + do { + let response = try await ApiService().updateNotificationsEnabled(true) + return response.error == false ? response.result : nil + } catch { + logger.error("Failed to enable push notifications: \(error.localizedDescription)") + return nil + } + } + + /// See docs/api/push-notifications-integration-guide.md §2a. Best-effort, + /// silent on failure — Campaign `appVersion`/`attributes` targeting just + /// won't match this user until the next successful call. Call right after + /// login and once per app launch (covers an app update since last launch). + func syncCustomerAttributes() async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().updateCustomerAttributes(appVersion: currentAppVersion(), attributes: nil) + } catch { + logger.error("Failed to sync customer attributes: \(error.localizedDescription)") + } + } + + private func currentAppVersion() -> String { + (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "0.0.0" + } + + private func sendTokenToBackend(_ hexToken: String) async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().registerPushToken(hexToken, deviceId: GuestLocationStore.shared.deviceId, deviceOS: "ios") + } catch { + logger.error("Push token registration failed: \(error.localizedDescription)") + } + } + + /// §6a — fire on tap only, for `type: "campaign"` pushes. Idempotent + /// server-side, so no client-side "already reported" guard needed. + private func reportCampaignOpened(campaignId: String) async { + guard DefaultTokenStore().jwt != nil else { return } + do { + _ = try await ApiService().reportPushCampaignOpened(campaignId: campaignId) + } catch { + logger.error("Failed to report campaign open: \(error.localizedDescription)") + } + } + + /// §6 — routes on the tapped push's `data` payload. `order_status` gets + /// forwarded to `ContentView` via `NotificationCenter` (this service has + /// no `AppState` binding of its own); `campaign` self-reports its open. + fileprivate func handleTap(userInfo: [AnyHashable: Any]) { + guard let type = userInfo["type"] as? String else { return } + switch type { + case "campaign": + guard let campaignId = userInfo["campaignId"] as? String else { return } + Task { await reportCampaignOpened(campaignId: campaignId) } + case "order_status": + guard let orderId = userInfo["orderId"] as? String else { return } + let shortId = userInfo["shortId"] as? String + NotificationCenter.default.post( + name: .pushTappedOrderStatus, + object: nil, + userInfo: ["orderId": orderId, "shortId": shortId as Any] + ) + default: + break + } + } +} + +extension PushNotificationCoordinator: UNUserNotificationCenterDelegate { + /// Without a delegate, iOS silently drops push notifications while the + /// app is foregrounded — this is what makes them display as a banner too. + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound, .badge]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + Task { @MainActor in + PushNotificationCoordinator.shared.handleTap(userInfo: userInfo) + } + completionHandler() + } +} +#else +@MainActor +final class PushNotificationCoordinator { + static let shared = PushNotificationCoordinator() + private init() {} + + func startObservingDeviceToken() {} + func becomeNotificationCenterDelegate() {} + func refreshRegistrationIfAuthorized() async {} + func currentAuthorizationState() async -> PushAuthorizationState { .denied } + func enableNotifications() async -> CustomerProfile? { nil } + func syncCustomerAttributes() async {} +} +#endif diff --git a/Sources/PediFoods/Services/SessionStateStore.swift b/Sources/PediFoods/Services/SessionStateStore.swift index b1457da..8ac2fa4 100644 --- a/Sources/PediFoods/Services/SessionStateStore.swift +++ b/Sources/PediFoods/Services/SessionStateStore.swift @@ -88,6 +88,19 @@ enum SessionStateStore { private static let pendingCartOrderKeyPrefix = "session.cart.pending-order.v1." private static let orderReviewsKeyPrefix = "session.orders.reviews.v1." private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1." + private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1" + private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24 + + /// See docs/api/push-notifications-integration-guide.md §2b — avoid + /// re-prompting the order-tracking fallback alert on every screen visit. + static func shouldPromptPushOptIn() -> Bool { + guard let last = UserDefaults.standard.object(forKey: pushOptInPromptKey) as? Date else { return true } + return Date().timeIntervalSince(last) > pushOptInCooldown + } + + static func recordPushOptInPrompted() { + UserDefaults.standard.set(Date(), forKey: pushOptInPromptKey) + } static func makeUserKey(profileId: String?, email: String?) -> String? { let id = (profileId ?? "") diff --git a/Sources/PediFoods/State/AppState.swift b/Sources/PediFoods/State/AppState.swift index 3c117bf..1cd767b 100644 --- a/Sources/PediFoods/State/AppState.swift +++ b/Sources/PediFoods/State/AppState.swift @@ -10,6 +10,10 @@ struct AppState { var homeFilters = HomeFiltersState() var activeModal: AppModal? = nil var shouldNavigateToOrders: Bool = false + /// Set when a push tap (§6 of the push notifications guide) targets a + /// specific order — consumed once by `OrdersView`, which routes to it via + /// `OrderEntryDestinationView` and clears it. + var pendingOrderDeepLink: OrderRouteContext? = nil } enum FeatureFlagValue: Codable, Equatable { @@ -94,6 +98,8 @@ struct ProfileState { var phone: String = "" var profilePicture: String = "" var cpf: String = "" + var notificationsEnabled: Bool = false + var faceIdEnabled: Bool = false } struct AddressState { diff --git a/Sources/PediFoods/Views/Auth/OtpView.swift b/Sources/PediFoods/Views/Auth/OtpView.swift index 5808611..d524e95 100644 --- a/Sources/PediFoods/Views/Auth/OtpView.swift +++ b/Sources/PediFoods/Views/Auth/OtpView.swift @@ -250,6 +250,7 @@ struct OtpView: View { appState.session.jwt = response.result?.token let hasServerAddress = hydrateUserState(from: response.result?.customer) routeAfterLogin(hasServerAddress: hasServerAddress) + Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() } } } catch { await MainActor.run { @@ -281,6 +282,8 @@ struct OtpView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? "" appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false appState.favorites.storeIds = Set(customer.favorites ?? []) SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) diff --git a/Sources/PediFoods/Views/Main/AddressesView.swift b/Sources/PediFoods/Views/Main/AddressesView.swift index 9817781..2a19559 100644 --- a/Sources/PediFoods/Views/Main/AddressesView.swift +++ b/Sources/PediFoods/Views/Main/AddressesView.swift @@ -386,6 +386,8 @@ struct AddressesView: View { appState.profile.email = customer.email appState.profile.phone = customer.phoneNumber ?? appState.profile.phone appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) diff --git a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift index 21151f3..9d34469 100644 --- a/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift +++ b/Sources/PediFoods/Views/Main/CheckoutView+Logic.swift @@ -80,6 +80,8 @@ extension CheckoutView { appState.profile.phone = phoneNumber } appState.profile.profilePicture = customer.profilePicture ?? "" + appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false + appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false SessionStateStore.setActiveUserKey( SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email) ) diff --git a/Sources/PediFoods/Views/Main/OrderTrackingView.swift b/Sources/PediFoods/Views/Main/OrderTrackingView.swift index 5a4fcfc..ff746d5 100644 --- a/Sources/PediFoods/Views/Main/OrderTrackingView.swift +++ b/Sources/PediFoods/Views/Main/OrderTrackingView.swift @@ -25,6 +25,7 @@ struct OrderTrackingView: View { @State var reviewDraft: ReviewDraft? = nil @State var didSaveReviewForCurrentOrder = false @State var reviewSavedObserver: Any? + @State var showPushOptInAlert = false var body: some View { ScrollView(showsIndicators: false) { @@ -53,8 +54,15 @@ struct OrderTrackingView: View { } message: { Text(cancellationReasonText) } + .alert("Ative as notificações", isPresented: $showPushOptInAlert) { + Button("Agora não", role: .cancel) {} + Button("Ativar") { Task { await enablePushNotifications() } } + } message: { + Text("Ative as notificações para acompanhar em tempo real as atualizações do seu pedido.") + } .task { await loadInitialOrder() + await maybePromptPushOptIn() tracker.onOrderUpdated = { updated in order = updated if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false { @@ -62,6 +70,7 @@ struct OrderTrackingView: View { } isLoading = false errorMessage = nil + Task { await maybePromptPushOptIn() } } tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt) } @@ -827,6 +836,38 @@ struct OrderTrackingView: View { isLoading = false } + /// Touchpoint 2 of docs/api/push-notifications-integration-guide.md §2b — + /// last practical moment to recover an opted-out user before order-status + /// push (§6, `type: "order_status"`) goes silent for them for this order. + @MainActor + private func maybePromptPushOptIn() async { + guard showPushOptInAlert == false, isWaitingPayment == false, isCanceled == false else { return } + guard SessionStateStore.shouldPromptPushOptIn() else { return } + + let osAuthorized = await PushNotificationCoordinator.shared.currentAuthorizationState() == .authorized + var serverEnabled = false + if let profileResponse = try? await ApiService().profile(), profileResponse.error == false { + serverEnabled = profileResponse.result?.notificationsEnabled ?? false + } + guard osAuthorized == false || serverEnabled == false else { return } + + SessionStateStore.recordPushOptInPrompted() + showPushOptInAlert = true + } + + @MainActor + private func enablePushNotifications() async { + let profile = await PushNotificationCoordinator.shared.enableNotifications() + if profile?.notificationsEnabled != true { + SnackbarCenter.shared.show( + title: "Ative notificações nos Ajustes do iPhone para acompanhar seu pedido.", + style: .warning, + icon: "bell.slash.fill", + duration: 3.5 + ) + } + } + @MainActor private func refreshStoreContactPhone(for order: PublicOrderResult) async { guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else { diff --git a/Sources/PediFoods/Views/Main/OrdersView.swift b/Sources/PediFoods/Views/Main/OrdersView.swift index 4c0b62b..9f6d8b0 100644 --- a/Sources/PediFoods/Views/Main/OrdersView.swift +++ b/Sources/PediFoods/Views/Main/OrdersView.swift @@ -51,6 +51,12 @@ struct OrdersView: View { await loadOrdersIfNeeded() await refreshStoreRatings() } + .onAppear { + if let pending = appState.pendingOrderDeepLink { + appState.pendingOrderDeepLink = nil + selectedOrderRoute = pending + } + } .refreshable { // Decoupled from .refreshable's own cancellable wrapping Task — // see StoreDetailView's .refreshable for why. diff --git a/Sources/PediFoods/Views/Main/ProfileView.swift b/Sources/PediFoods/Views/Main/ProfileView.swift index 7666ed3..6df7aef 100644 --- a/Sources/PediFoods/Views/Main/ProfileView.swift +++ b/Sources/PediFoods/Views/Main/ProfileView.swift @@ -181,6 +181,11 @@ struct ProfileView: View { openOrders = true } } + .onChange(of: appState.pendingOrderDeepLink) { _, val in + if val != nil { + openOrders = true + } + } } private var header: some View { diff --git a/Sources/PediFoods/Views/Main/UserProfileView.swift b/Sources/PediFoods/Views/Main/UserProfileView.swift index 59853ee..5b28b47 100644 --- a/Sources/PediFoods/Views/Main/UserProfileView.swift +++ b/Sources/PediFoods/Views/Main/UserProfileView.swift @@ -7,6 +7,7 @@ import UIKit struct UserProfileView: View { @Binding var appState: AppState @Environment(\.dismiss) var dismiss + @Environment(\.openURL) private var openURL @State var name: String = "" @State var email: String = "" @@ -14,6 +15,11 @@ struct UserProfileView: View { @State var cpf: String = "" @State var profilePicture: String = "" @State var isSaving = false + @State private var notificationsEnabled = false + @State private var isTogglingNotifications = false + @State private var showNotificationsDeniedAlert = false + @State private var faceIdEnabled = false + @State private var isTogglingFaceId = false #if os(iOS) @State private var selectedPhotoItem: PhotosPickerItem? @@ -25,6 +31,7 @@ struct UserProfileView: View { screenHeader avatarSection formSection + preferencesSection saveButton } .padding(.horizontal, 20) @@ -34,6 +41,12 @@ struct UserProfileView: View { .background(AppColors.backgroundLight) .appHiddenNavigationBar() .navigationBarBackButtonHidden(true) + .alert("Notificações desativadas", isPresented: $showNotificationsDeniedAlert) { + Button("Agora não", role: .cancel) {} + Button("Abrir Ajustes") { openSystemSettings() } + } message: { + Text("Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone.") + } .onAppear { hydrateFromAppState() } @@ -135,6 +148,54 @@ struct UserProfileView: View { .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) } + private var preferencesSection: some View { + VStack(alignment: .leading, spacing: 14) { + toggleRow( + icon: "bell.fill", + title: "Notificações", + isOn: $notificationsEnabled, + isDisabled: isTogglingNotifications + ) + .onChange(of: notificationsEnabled) { _, newValue in + Task { await handleNotificationsToggle(newValue) } + } + + Divider() + + toggleRow( + icon: "faceid", + title: "Login com biometria", + isOn: $faceIdEnabled, + isDisabled: isTogglingFaceId + ) + .onChange(of: faceIdEnabled) { _, newValue in + Task { await handleFaceIdToggle(newValue) } + } + } + .padding(16) + .background(AppColors.surface) + .clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous)) + } + + private func toggleRow(icon: String, title: String, isOn: Binding, isDisabled: Bool) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(AppColors.primary) + .frame(width: 28) + + Text(title) + .font(AppTypography.body) + .foregroundStyle(AppColors.textPrimary) + + Spacer(minLength: 10) + + Toggle("", isOn: isOn) + .labelsHidden() + .disabled(isDisabled) + } + } + private var saveButton: some View { Button(isSaving ? "Salvando..." : "Salvar Alterações") { Task { await saveProfile() } @@ -180,6 +241,97 @@ struct UserProfileView: View { phone = formatPhoneForDisplay(appState.profile.phone) profilePicture = appState.profile.profilePicture cpf = formatCPF(appState.profile.cpf.filter(\.isNumber)) + notificationsEnabled = appState.profile.notificationsEnabled + faceIdEnabled = appState.profile.faceIdEnabled + } + + /// Touchpoint 1 of docs/api/push-notifications-integration-guide.md §2b — + /// request OS permission (if needed) before flipping the server-side flag; + /// revert the toggle and explain why if the OS denies it. + @MainActor + private func handleNotificationsToggle(_ enabled: Bool) async { + guard isTogglingNotifications == false, enabled != appState.profile.notificationsEnabled else { return } + isTogglingNotifications = true + defer { isTogglingNotifications = false } + + if enabled { + if let profile = await PushNotificationCoordinator.shared.enableNotifications() { + let serverValue = profile.notificationsEnabled ?? false + appState.profile.notificationsEnabled = serverValue + notificationsEnabled = serverValue + } else { + notificationsEnabled = false + showNotificationsDeniedAlert = true + } + return + } + + do { + let response = try await ApiService().updateNotificationsEnabled(false) + if response.error { + notificationsEnabled = true + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar suas notificações.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } else { + let serverValue = response.result?.notificationsEnabled ?? false + appState.profile.notificationsEnabled = serverValue + notificationsEnabled = serverValue + } + } catch { + notificationsEnabled = true + SnackbarCenter.shared.show( + title: "Não foi possível atualizar suas notificações.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } + } + + private func openSystemSettings() { +#if canImport(UIKit) + if let url = URL(string: UIApplication.openSettingsURLString) { + openURL(url) + } +#endif + } + + /// Preference only for now — the actual Face ID/Touch ID unlock flow + /// (LocalAuthentication) is a separate, later plan. + @MainActor + private func handleFaceIdToggle(_ enabled: Bool) async { + guard isTogglingFaceId == false, enabled != appState.profile.faceIdEnabled else { return } + isTogglingFaceId = true + defer { isTogglingFaceId = false } + + do { + let response = try await ApiService().updateFaceIdEnabled(enabled) + if response.error { + faceIdEnabled = appState.profile.faceIdEnabled + SnackbarCenter.shared.show( + title: response.message ?? "Não foi possível atualizar essa preferência.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } else { + let serverValue = response.result?.faceIdEnabled ?? enabled + appState.profile.faceIdEnabled = serverValue + faceIdEnabled = serverValue + } + } catch { + faceIdEnabled = appState.profile.faceIdEnabled + SnackbarCenter.shared.show( + title: "Não foi possível atualizar essa preferência.", + style: .error, + icon: "xmark.octagon.fill", + duration: 3.0 + ) + } } private func formatCPF(_ digits: String) -> String {