[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:
Daniel Arantes Loverde
2026-08-04 11:33:56 -03:00
parent 32f12d6c0e
commit 7dd4cda1a4
20 changed files with 943 additions and 12 deletions

View File

@@ -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>

View 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>

View File

@@ -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
}
}
}

View File

@@ -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 */

View File

@@ -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

View File

@@ -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)

View File

@@ -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 #%@" : {

View File

@@ -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?

View File

@@ -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
}
}

View File

@@ -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
}

View 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

View File

@@ -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 ?? "")

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)
)

View File

@@ -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)
)

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 {