[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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
</dict>
|
</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 = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* 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 */; };
|
491F27822DA55B72004926EE /* PediFoods in Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; };
|
||||||
491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 491F27812DA55B72004926EE /* PediFoods */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */; };
|
||||||
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49F90C2B2A52156200F06D93 /* Main.swift */; };
|
||||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 49F90C2F2A52156300F06D93 /* Assets.xcassets */; };
|
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 */; };
|
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 */
|
/* 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 */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = {
|
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
@@ -28,6 +41,17 @@
|
|||||||
name = "Embed Frameworks";
|
name = "Embed Frameworks";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
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 */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference 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; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -54,13 +82,32 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
CAFA309ED97AD448069C6F95 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
837DB5EB5FA4527A71F02C70 /* Foundation.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
39B295835451899F1689190A /* NotificationServiceExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4E34FD2046E40CE1E302DB11 /* NotificationService.swift */,
|
||||||
|
ED678A4BFF090312B354648E /* Info.plist */,
|
||||||
|
);
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
path = NotificationServiceExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
496BDBEC2B89A47800C09264 /* Products */ = {
|
496BDBEC2B89A47800C09264 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
496BDBEB2B89A47800C09264 /* PediFoods.app */,
|
496BDBEB2B89A47800C09264 /* PediFoods.app */,
|
||||||
|
588B094973FB7448596F3D09 /* NotificationServiceExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -75,6 +122,8 @@
|
|||||||
493609562A6B7EAE00C401E2 /* PediFoods */,
|
493609562A6B7EAE00C401E2 /* PediFoods */,
|
||||||
49F90C2A2A52156200F06D93 /* App */,
|
49F90C2A2A52156200F06D93 /* App */,
|
||||||
496BDBEC2B89A47800C09264 /* Products */,
|
496BDBEC2B89A47800C09264 /* Products */,
|
||||||
|
39B295835451899F1689190A /* NotificationServiceExtension */,
|
||||||
|
8F798D6B741A681CBA9CC824 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -82,7 +131,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
49F90C2B2A52156200F06D93 /* Main.swift */,
|
49F90C2B2A52156200F06D93 /* Main.swift */,
|
||||||
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
|
B185A1FC2FB67A12001C7771 /* LaunchScreen.storyboard */,
|
||||||
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
|
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
|
||||||
49F90C312A52156300F06D93 /* Entitlements.plist */,
|
49F90C312A52156300F06D93 /* Entitlements.plist */,
|
||||||
4900101C2BACEA710000DE33 /* Info.plist */,
|
4900101C2BACEA710000DE33 /* Info.plist */,
|
||||||
@@ -90,6 +139,22 @@
|
|||||||
name = App;
|
name = App;
|
||||||
sourceTree = "<group>";
|
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 */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -101,10 +166,12 @@
|
|||||||
499CD43C2AC5B799001AE8D8 /* Frameworks */,
|
499CD43C2AC5B799001AE8D8 /* Frameworks */,
|
||||||
499CD43E2AC5B799001AE8D8 /* Resources */,
|
499CD43E2AC5B799001AE8D8 /* Resources */,
|
||||||
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */,
|
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */,
|
||||||
|
C28D30C677D0B233FE310699 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
A3838B46B6FB8B79EE9B0F9F /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = "PediFoods App";
|
name = "PediFoods App";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -115,6 +182,23 @@
|
|||||||
productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */;
|
productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */;
|
||||||
productType = "com.apple.product-type.application";
|
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 */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -124,6 +208,15 @@
|
|||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1430;
|
LastSwiftUpdateCheck = 1430;
|
||||||
LastUpgradeCheck = 1630;
|
LastUpgradeCheck = 1630;
|
||||||
|
TargetAttributes = {
|
||||||
|
499CD4382AC5B799001AE8D8 = {
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.Push = {
|
||||||
|
enabled = 1;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */;
|
buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */;
|
||||||
compatibilityVersion = "Xcode 14.0";
|
compatibilityVersion = "Xcode 14.0";
|
||||||
@@ -145,6 +238,7 @@
|
|||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
targets = (
|
targets = (
|
||||||
499CD4382AC5B799001AE8D8 /* PediFoods App */,
|
499CD4382AC5B799001AE8D8 /* PediFoods App */,
|
||||||
|
4FB6BE053007051C3D26B06E /* NotificationServiceExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -154,15 +248,30 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
|
B185A1FD2FB67A12001C7771 /* LaunchScreen.storyboard in Resources */,
|
||||||
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
|
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
|
||||||
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
|
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
7A8A5D1C5EC61B8ACACF33CF /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
29CCE086EDBB10F49FF72DDA /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
1457598D2491B8729EEBB739 /* NotificationService.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
499CD43A2AC5B799001AE8D8 /* Sources */ = {
|
499CD43A2AC5B799001AE8D8 /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -173,12 +282,24 @@
|
|||||||
};
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
A3838B46B6FB8B79EE9B0F9F /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
name = NotificationServiceExtension;
|
||||||
|
target = 4FB6BE053007051C3D26B06E /* NotificationServiceExtension */;
|
||||||
|
targetProxy = E60A9FE80EBD27A663AF17E0 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
499CD4422AC5B799001AE8D8 /* Debug */ = {
|
499CD4422AC5B799001AE8D8 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
|
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
CODE_SIGN_ENTITLEMENTS = Entitlements.plist;
|
||||||
|
CODE_SIGN_STYLE = Manual;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
@@ -186,6 +307,8 @@
|
|||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@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";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -196,9 +319,12 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
|
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Entitlements.plist;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
|
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
DEVELOPMENT_TEAM = K4E5BZMM4V;
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = K4E5BZMM4V;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
@@ -207,6 +333,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "com.br.pedifoods.app AppStore";
|
PROVISIONING_PROFILE_SPECIFIER = "com.br.pedifoods.app AppStore";
|
||||||
|
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "PediFoods Dist XC Prov Prof";
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -249,6 +376,55 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
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 */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -270,6 +446,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
A20C22A5541093DB9CFD38F4 /* Build configuration list for PBXNativeTarget "NotificationServiceExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
F90A5B614A60B25B97F3541D /* Release */,
|
||||||
|
602E0FA08B93FC360B6AF5D1 /* Debug */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct ContentView: View {
|
|||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
@State var cartResetObserver: Any?
|
@State var cartResetObserver: Any?
|
||||||
@State var appResumeObserver: Any?
|
@State var appResumeObserver: Any?
|
||||||
|
@State var pushOrderTapObserver: Any?
|
||||||
@StateObject var snackbarCenter = SnackbarCenter.shared
|
@StateObject var snackbarCenter = SnackbarCenter.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -90,11 +91,13 @@ struct ContentView: View {
|
|||||||
attachCartResetObserverIfNeeded()
|
attachCartResetObserverIfNeeded()
|
||||||
attachAppResumeObserverIfNeeded()
|
attachAppResumeObserverIfNeeded()
|
||||||
attachSessionExpiredObserverIfNeeded()
|
attachSessionExpiredObserverIfNeeded()
|
||||||
|
attachPushOrderTapObserverIfNeeded()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
detachCartResetObserver()
|
detachCartResetObserver()
|
||||||
detachAppResumeObserver()
|
detachAppResumeObserver()
|
||||||
detachSessionExpiredObserver()
|
detachSessionExpiredObserver()
|
||||||
|
detachPushOrderTapObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +244,38 @@ struct ContentView: View {
|
|||||||
self.appResumeObserver = nil
|
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
|
@MainActor
|
||||||
private func hydrateAppState(with customer: CustomerProfile) {
|
private func hydrateAppState(with customer: CustomerProfile) {
|
||||||
appState.profile.id = customer.id
|
appState.profile.id = customer.id
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ public final class PediFoodsAppDelegate: Sendable {
|
|||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
public func onInit() { logger.debug("onInit") }
|
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() {
|
public func onResume() {
|
||||||
logger.debug("onResume")
|
logger.debug("onResume")
|
||||||
NotificationCenter.default.post(name: .appDidResume, object: nil)
|
NotificationCenter.default.post(name: .appDidResume, object: nil)
|
||||||
|
|||||||
@@ -89,6 +89,9 @@
|
|||||||
"10km" : {
|
"10km" : {
|
||||||
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
|
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Abrir Ajustes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Acompanhamento em tempo real" : {
|
"Acompanhamento em tempo real" : {
|
||||||
|
|
||||||
@@ -148,6 +151,9 @@
|
|||||||
"Adicione produtos para continuar." : {
|
"Adicione produtos para continuar." : {
|
||||||
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
|
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Agora não" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"AGUARDANDO PAGAMENTO" : {
|
"AGUARDANDO PAGAMENTO" : {
|
||||||
"comment" : "A status text indicating that the payment is pending.",
|
"comment" : "A status text indicating that the payment is pending.",
|
||||||
@@ -212,6 +218,15 @@
|
|||||||
"Até %lldkm" : {
|
"Até %lldkm" : {
|
||||||
"comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
|
"comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
|
||||||
"isCommentAutoGenerated" : true
|
"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..." : {
|
"Atualizando status do pedido..." : {
|
||||||
|
|
||||||
@@ -818,6 +833,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Notificações" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Notificações desativadas" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Novo cartão" : {
|
"Novo cartão" : {
|
||||||
|
|
||||||
@@ -879,6 +900,9 @@
|
|||||||
},
|
},
|
||||||
"Pague mais rápido nas próximas compras" : {
|
"Pague mais rápido nas próximas compras" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Pedido #%@" : {
|
"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 {
|
struct CustomerAddressPayload: Encodable {
|
||||||
let label: String?
|
let label: String?
|
||||||
let address: String?
|
let address: String?
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ struct CustomerProfile: Decodable {
|
|||||||
let profilePicture: String?
|
let profilePicture: String?
|
||||||
let favorites: [String]?
|
let favorites: [String]?
|
||||||
let addressBook: [CustomerAddress]?
|
let addressBook: [CustomerAddress]?
|
||||||
|
let notificationsEnabled: Bool?
|
||||||
|
let faceIdEnabled: Bool?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
@@ -39,6 +41,8 @@ struct CustomerProfile: Decodable {
|
|||||||
case profilePicture
|
case profilePicture
|
||||||
case favorites
|
case favorites
|
||||||
case addressBook = "address_book"
|
case addressBook = "address_book"
|
||||||
|
case notificationsEnabled
|
||||||
|
case faceIdEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,17 @@ final class ApiService {
|
|||||||
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
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 {
|
private func scopedCacheSuffix() -> String {
|
||||||
let jwt = tokenStore.jwt ?? "anonymous"
|
let jwt = tokenStore.jwt ?? "anonymous"
|
||||||
if jwt.count <= 16 { return jwt }
|
if jwt.count <= 16 { return jwt }
|
||||||
@@ -184,10 +195,90 @@ final class ApiService {
|
|||||||
let body = try JSONEncoder().encode(payload)
|
let body = try JSONEncoder().encode(payload)
|
||||||
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||||
let envelope: ProfilePatchEnvelope = try await send(req)
|
let envelope: ProfilePatchEnvelope = try await send(req)
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
if envelope.error == false {
|
||||||
|
await refreshProfileCache()
|
||||||
|
}
|
||||||
return envelope
|
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> {
|
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
||||||
}
|
}
|
||||||
@@ -234,7 +325,9 @@ final class ApiService {
|
|||||||
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||||
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
||||||
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
if envelope.error == false {
|
||||||
|
await refreshProfileCache()
|
||||||
|
}
|
||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,9 +352,8 @@ final class ApiService {
|
|||||||
let body = try JSONEncoder().encode(payload)
|
let body = try JSONEncoder().encode(payload)
|
||||||
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
if envelope.error == false {
|
||||||
if envelope.error == false, envelope.result != nil {
|
await refreshProfileCache()
|
||||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
|
||||||
} else {
|
} else {
|
||||||
invalidateFavoritesCache()
|
invalidateFavoritesCache()
|
||||||
}
|
}
|
||||||
@@ -488,7 +580,7 @@ final class ApiService {
|
|||||||
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||||
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||||
if result.error == false {
|
if result.error == false {
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
await refreshProfileCache()
|
||||||
}
|
}
|
||||||
return result
|
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 pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||||
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
||||||
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.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? {
|
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||||
let id = (profileId ?? "")
|
let id = (profileId ?? "")
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ struct AppState {
|
|||||||
var homeFilters = HomeFiltersState()
|
var homeFilters = HomeFiltersState()
|
||||||
var activeModal: AppModal? = nil
|
var activeModal: AppModal? = nil
|
||||||
var shouldNavigateToOrders: Bool = false
|
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 {
|
enum FeatureFlagValue: Codable, Equatable {
|
||||||
@@ -94,6 +98,8 @@ struct ProfileState {
|
|||||||
var phone: String = ""
|
var phone: String = ""
|
||||||
var profilePicture: String = ""
|
var profilePicture: String = ""
|
||||||
var cpf: String = ""
|
var cpf: String = ""
|
||||||
|
var notificationsEnabled: Bool = false
|
||||||
|
var faceIdEnabled: Bool = false
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AddressState {
|
struct AddressState {
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ struct OtpView: View {
|
|||||||
appState.session.jwt = response.result?.token
|
appState.session.jwt = response.result?.token
|
||||||
let hasServerAddress = hydrateUserState(from: response.result?.customer)
|
let hasServerAddress = hydrateUserState(from: response.result?.customer)
|
||||||
routeAfterLogin(hasServerAddress: hasServerAddress)
|
routeAfterLogin(hasServerAddress: hasServerAddress)
|
||||||
|
Task { await PushNotificationCoordinator.shared.syncCustomerAttributes() }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -281,6 +282,8 @@ struct OtpView: View {
|
|||||||
appState.profile.email = customer.email
|
appState.profile.email = customer.email
|
||||||
appState.profile.phone = customer.phoneNumber ?? ""
|
appState.profile.phone = customer.phoneNumber ?? ""
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
|
||||||
|
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
|
||||||
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
appState.favorites.storeIds = Set(customer.favorites ?? [])
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
|
|||||||
@@ -386,6 +386,8 @@ struct AddressesView: View {
|
|||||||
appState.profile.email = customer.email
|
appState.profile.email = customer.email
|
||||||
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
|
appState.profile.phone = customer.phoneNumber ?? appState.profile.phone
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
|
||||||
|
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ extension CheckoutView {
|
|||||||
appState.profile.phone = phoneNumber
|
appState.profile.phone = phoneNumber
|
||||||
}
|
}
|
||||||
appState.profile.profilePicture = customer.profilePicture ?? ""
|
appState.profile.profilePicture = customer.profilePicture ?? ""
|
||||||
|
appState.profile.notificationsEnabled = customer.notificationsEnabled ?? false
|
||||||
|
appState.profile.faceIdEnabled = customer.faceIdEnabled ?? false
|
||||||
SessionStateStore.setActiveUserKey(
|
SessionStateStore.setActiveUserKey(
|
||||||
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
SessionStateStore.makeUserKey(profileId: customer.id, email: customer.email)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ struct OrderTrackingView: View {
|
|||||||
@State var reviewDraft: ReviewDraft? = nil
|
@State var reviewDraft: ReviewDraft? = nil
|
||||||
@State var didSaveReviewForCurrentOrder = false
|
@State var didSaveReviewForCurrentOrder = false
|
||||||
@State var reviewSavedObserver: Any?
|
@State var reviewSavedObserver: Any?
|
||||||
|
@State var showPushOptInAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -53,8 +54,15 @@ struct OrderTrackingView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text(cancellationReasonText)
|
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 {
|
.task {
|
||||||
await loadInitialOrder()
|
await loadInitialOrder()
|
||||||
|
await maybePromptPushOptIn()
|
||||||
tracker.onOrderUpdated = { updated in
|
tracker.onOrderUpdated = { updated in
|
||||||
order = updated
|
order = updated
|
||||||
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
|
if let inlinePhone = updated.storePhone, inlinePhone.isEmpty == false {
|
||||||
@@ -62,6 +70,7 @@ struct OrderTrackingView: View {
|
|||||||
}
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
Task { await maybePromptPushOptIn() }
|
||||||
}
|
}
|
||||||
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
|
tracker.start(orderId: orderId, jwt: DefaultTokenStore().jwt)
|
||||||
}
|
}
|
||||||
@@ -827,6 +836,38 @@ struct OrderTrackingView: View {
|
|||||||
isLoading = false
|
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
|
@MainActor
|
||||||
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
|
private func refreshStoreContactPhone(for order: PublicOrderResult) async {
|
||||||
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
|
guard let storeId = order.storeId?.trimmingCharacters(in: .whitespacesAndNewlines), storeId.isEmpty == false else {
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ struct OrdersView: View {
|
|||||||
await loadOrdersIfNeeded()
|
await loadOrdersIfNeeded()
|
||||||
await refreshStoreRatings()
|
await refreshStoreRatings()
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if let pending = appState.pendingOrderDeepLink {
|
||||||
|
appState.pendingOrderDeepLink = nil
|
||||||
|
selectedOrderRoute = pending
|
||||||
|
}
|
||||||
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
// Decoupled from .refreshable's own cancellable wrapping Task —
|
// Decoupled from .refreshable's own cancellable wrapping Task —
|
||||||
// see StoreDetailView's .refreshable for why.
|
// see StoreDetailView's .refreshable for why.
|
||||||
|
|||||||
@@ -181,6 +181,11 @@ struct ProfileView: View {
|
|||||||
openOrders = true
|
openOrders = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: appState.pendingOrderDeepLink) { _, val in
|
||||||
|
if val != nil {
|
||||||
|
openOrders = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import UIKit
|
|||||||
struct UserProfileView: View {
|
struct UserProfileView: View {
|
||||||
@Binding var appState: AppState
|
@Binding var appState: AppState
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
@State var name: String = ""
|
@State var name: String = ""
|
||||||
@State var email: String = ""
|
@State var email: String = ""
|
||||||
@@ -14,6 +15,11 @@ struct UserProfileView: View {
|
|||||||
@State var cpf: String = ""
|
@State var cpf: String = ""
|
||||||
@State var profilePicture: String = ""
|
@State var profilePicture: String = ""
|
||||||
@State var isSaving = false
|
@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)
|
#if os(iOS)
|
||||||
@State private var selectedPhotoItem: PhotosPickerItem?
|
@State private var selectedPhotoItem: PhotosPickerItem?
|
||||||
@@ -25,6 +31,7 @@ struct UserProfileView: View {
|
|||||||
screenHeader
|
screenHeader
|
||||||
avatarSection
|
avatarSection
|
||||||
formSection
|
formSection
|
||||||
|
preferencesSection
|
||||||
saveButton
|
saveButton
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
@@ -34,6 +41,12 @@ struct UserProfileView: View {
|
|||||||
.background(AppColors.backgroundLight)
|
.background(AppColors.backgroundLight)
|
||||||
.appHiddenNavigationBar()
|
.appHiddenNavigationBar()
|
||||||
.navigationBarBackButtonHidden(true)
|
.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 {
|
.onAppear {
|
||||||
hydrateFromAppState()
|
hydrateFromAppState()
|
||||||
}
|
}
|
||||||
@@ -135,6 +148,54 @@ struct UserProfileView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: AppLayout.radiusLG, style: .continuous))
|
.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 {
|
private var saveButton: some View {
|
||||||
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
|
Button(isSaving ? "Salvando..." : "Salvar Alterações") {
|
||||||
Task { await saveProfile() }
|
Task { await saveProfile() }
|
||||||
@@ -180,6 +241,97 @@ struct UserProfileView: View {
|
|||||||
phone = formatPhoneForDisplay(appState.profile.phone)
|
phone = formatPhoneForDisplay(appState.profile.phone)
|
||||||
profilePicture = appState.profile.profilePicture
|
profilePicture = appState.profile.profilePicture
|
||||||
cpf = formatCPF(appState.profile.cpf.filter(\.isNumber))
|
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 {
|
private func formatCPF(_ digits: String) -> String {
|
||||||
|
|||||||
Reference in New Issue
Block a user