[push/opt-in-prompts] Implement push notifications client integration
Build the full client half of docs/api/push-notifications-integration-guide.md: OS permission + APNs device-token registration and pipeline wiring, profile notifications/biometric-login toggles on the Ver Perfil screen reflecting server truth, order-tracking opt-in fallback prompt, profile-cache refresh on every mutation, a Notification Service Extension for rich/image push, the Push Notifications capability, targeting-attributes sync, campaign open tracking, and tap-to-order deep linking with foreground notification display.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
|
||||
31
Darwin/NotificationServiceExtension/Info.plist
Normal file
31
Darwin/NotificationServiceExtension/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>NotificationService</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "<group>"; };
|
||||
49F90C312A52156300F06D93 /* Entitlements.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Entitlements.plist; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
496BDBEC2B89A47800C09264 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
496BDBEB2B89A47800C09264 /* PediFoods.app */,
|
||||
588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -75,6 +122,8 @@
|
||||
493609562A6B7EAE00C401E2 /* PediFoods */,
|
||||
49F90C2A2A52156200F06D93 /* App */,
|
||||
496BDBEC2B89A47800C09264 /* Products */,
|
||||
39B295835451899F1689190A /* NotificationServiceExtension */,
|
||||
8F798D6B741A681CBA9CC824 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -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 = "<group>";
|
||||
};
|
||||
8F798D6B741A681CBA9CC824 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C127D57D66FAAE472B202C61 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C127D57D66FAAE472B202C61 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78EA2CD32C41A0B833E06B89 /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 #%@" : {
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CustomerProfile>? {
|
||||
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<CustomerProfile> {
|
||||
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<CustomerProfile> = 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<CustomerProfile> {
|
||||
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<CustomerProfile> = 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<EmptyResult> {
|
||||
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<EmptyResult> {
|
||||
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<PushCampaignOpenedResult> {
|
||||
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<CustomerProfile> {
|
||||
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
||||
}
|
||||
@@ -234,7 +325,9 @@ final class ApiService {
|
||||
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
||||
let envelope: ApiEnvelope<EmptyResult> = 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<CustomerProfile> = 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<EmptyResult> = try await sendEnvelope(req)
|
||||
if result.error == false {
|
||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
||||
await refreshProfileCache()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
215
Sources/PediFoods/Services/PushNotificationCoordinator.swift
Normal file
215
Sources/PediFoods/Services/PushNotificationCoordinator.swift
Normal file
@@ -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
|
||||
@@ -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 ?? "")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Bool>, 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 {
|
||||
|
||||
Reference in New Issue
Block a user