Merge pull request 'feature/push/opt-in-prompts' (#43) from feature/push/opt-in-prompts into main

Reviewed-on: Loverde-Company-LTDA/Pedi-Foods-Skip#43
This commit is contained in:
2026-08-24 11:57:48 -03:00
232 changed files with 5356 additions and 1786 deletions

View File

@@ -35,7 +35,7 @@ jobs:
- name: Write App Store Connect API key
run: |
cat > Darwin/fastlane/apikey.json <<EOF
cat > fastlane/apikey.json <<EOF
{
"key_id": "${{ secrets.ASC_KEY_ID }}",
"issuer_id": "${{ secrets.ASC_ISSUER_ID }}",
@@ -52,12 +52,11 @@ jobs:
security list-keychains -d user -s ~/Library/Keychains/ci-signing.keychain-db ~/Library/Keychains/login.keychain-db
security default-keychain -d user -s ~/Library/Keychains/ci-signing.keychain-db
security find-identity -v -p codesigning
cd Darwin
fastlane beta
- name: Clean up API key
if: always()
run: rm -f Darwin/fastlane/apikey.json
run: rm -f fastlane/apikey.json
sleep-macos-vm:
needs: build-and-upload

View File

@@ -42,7 +42,7 @@ jobs:
- name: Write App Store Connect API key
run: |
cat > Darwin/fastlane/apikey.json <<EOF
cat > fastlane/apikey.json <<EOF
{
"key_id": "${{ secrets.ASC_KEY_ID }}",
"issuer_id": "${{ secrets.ASC_ISSUER_ID }}",
@@ -70,12 +70,11 @@ jobs:
security dump-keychain "$HOME/Library/Keychains/ci-signing.keychain-db" | grep -c "class.*0x80001000" || true
echo "--- find-identity ---"
security find-identity -v -p codesigning
cd Darwin
fastlane release
- name: Clean up API key
if: always()
run: rm -f Darwin/fastlane/apikey.json
run: rm -f fastlane/apikey.json
sleep-macos-vm:
needs: build-archive-deliver

View File

@@ -30,8 +30,8 @@ jobs:
- name: Run unit tests
run: |
xcodebuild test \
-project Darwin/PediFoods.xcodeproj \
-scheme "PediFoods App" \
-project PediFoods.xcodeproj \
-scheme "PediFoods" \
-destination "platform=iOS Simulator,name=iPhone 16,OS=latest"
sleep-macos-vm:

8
.gitignore vendored
View File

@@ -7,14 +7,6 @@
# macOS
.DS_Store
# gradle properties
local.properties
.gradle/
.android/
.kotlin/
Android/app/keystore.jks
Android/app/keystore.properties
xcodebuild*.log
default.profraw

View File

@@ -1,59 +0,0 @@
#include "../Skip.env"
// Set the action that will be executed as part of the Xcode Run Script phase
// Setting to "launch" will build and run the app in the first open Android emulator or device
// Setting to "build" will just run gradle build, but will not launch the app
// Setting to "none" will completely disable the build and launch of the Android app
SKIP_ACTION = launch
//SKIP_ACTION = build
//SKIP_ACTION = none
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor
INFOPLIST_FILE = Info.plist
GENERATE_INFOPLIST_FILE = YES
// The user-visible name of the app (localizable)
//INFOPLIST_KEY_CFBundleDisplayName = App Name
//INFOPLIST_KEY_LSApplicationCategoryType = public.app-category.utilities
//INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "This app uses your location to …"
// iOS-specific Info.plist property keys
INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphone*] = YES
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphone*] = YES
INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphone*] = NO
INFOPLIST_KEY_UILaunchStoryboardName[sdk=iphone*] = LaunchScreen
INFOPLIST_KEY_UIStatusBarStyle[sdk=iphone*] = UIStatusBarStyleDefault
INFOPLIST_KEY_UISupportedInterfaceOrientations[sdk=iphone*] = UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown
IPHONEOS_DEPLOYMENT_TARGET = 17.0
MACOSX_DEPLOYMENT_TARGET = 14.0
SUPPORTS_MACCATALYST = NO
// iPhone + iPad
TARGETED_DEVICE_FAMILY = 1,2
// iPhone only
// TARGETED_DEVICE_FAMILY = 1
SWIFT_EMIT_LOC_STRINGS = YES
// the name of the product module; this can be anything, but cannot conflict with any Swift module names
PRODUCT_MODULE_NAME = $(PRODUCT_NAME:c99extidentifier)App
// On-device testing may need to override the bundle ID
// PRODUCT_BUNDLE_IDENTIFIER[config=Debug][sdk=iphoneos*] = cool.beans.BundleIdentifer
SDKROOT = auto
SUPPORTED_PLATFORMS = iphoneos iphonesimulator macosx
SWIFT_EMIT_LOC_STRINGS = YES
SWIFT_VERSION = 6
// Development team ID for on-device testing
CODE_SIGNING_REQUIRED = NO
CODE_SIGN_STYLE = Automatic
CODE_SIGN_ENTITLEMENTS = Entitlements.plist
//CODE_SIGNING_IDENTITY = -
//DEVELOPMENT_TEAM =

View File

@@ -1,301 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
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 */; };
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */ = {isa = PBXBuildFile; productRef = B13CABF12F36437300469FD6 /* LCEssentials */; };
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
491F27832DA55B72004926EE /* PediFoods in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
4900101C2BACEA710000DE33 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
493609562A6B7EAE00C401E2 /* PediFoods */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = PediFoods; path = ..; sourceTree = "<group>"; };
496BDBEB2B89A47800C09264 /* PediFoods.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PediFoods.app; sourceTree = BUILT_PRODUCTS_DIR; };
496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = Localizable.xcstrings; path = ../Sources/PediFoods/Resources/Localizable.xcstrings; sourceTree = "<group>"; };
496EB72F2A6AE4DE00C1253A /* Skip.env */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Skip.env; path = ../Skip.env; sourceTree = "<group>"; };
496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = PediFoods.xcconfig; sourceTree = "<group>"; };
496EB72F2A6AE4DE00C1253C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
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; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
499CD43C2AC5B799001AE8D8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B13CABF22F36437300469FD6 /* LCEssentials in Frameworks */,
491F27822DA55B72004926EE /* PediFoods in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
496BDBEC2B89A47800C09264 /* Products */ = {
isa = PBXGroup;
children = (
496BDBEB2B89A47800C09264 /* PediFoods.app */,
);
name = Products;
sourceTree = "<group>";
};
49F90C1F2A52156200F06D93 = {
isa = PBXGroup;
children = (
496EB72F2A6AE4DE00C1253C /* README.md */,
496EB72F2A6AE4DE00C1253A /* Skip.env */,
496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */,
496BDBED2B8A7E9C00C09264 /* Localizable.xcstrings */,
493609562A6B7EAE00C401E2 /* PediFoods */,
49F90C2A2A52156200F06D93 /* App */,
496BDBEC2B89A47800C09264 /* Products */,
);
sourceTree = "<group>";
};
49F90C2A2A52156200F06D93 /* App */ = {
isa = PBXGroup;
children = (
49F90C2B2A52156200F06D93 /* Main.swift */,
B185A1FC2FB67A12001C7771 /* Sources/LaunchScreen.storyboard */,
49F90C2F2A52156300F06D93 /* Assets.xcassets */,
49F90C312A52156300F06D93 /* Entitlements.plist */,
4900101C2BACEA710000DE33 /* Info.plist */,
);
name = App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
499CD4382AC5B799001AE8D8 /* PediFoods App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "PediFoods App" */;
buildPhases = (
499CD43A2AC5B799001AE8D8 /* Sources */,
499CD43C2AC5B799001AE8D8 /* Frameworks */,
499CD43E2AC5B799001AE8D8 /* Resources */,
499CD44A2AC5B9C6001AE8D8 /* Embed Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = "PediFoods App";
packageProductDependencies = (
491F27812DA55B72004926EE /* PediFoods */,
B13CABF12F36437300469FD6 /* LCEssentials */,
);
productName = App;
productReference = 496BDBEB2B89A47800C09264 /* PediFoods.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
49F90C202A52156200F06D93 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1430;
LastUpgradeCheck = 1630;
};
buildConfigurationList = 49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
es,
ja,
"zh-Hans",
);
mainGroup = 49F90C1F2A52156200F06D93;
packageReferences = (
B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */,
);
productRefGroup = 496BDBEC2B89A47800C09264 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
499CD4382AC5B799001AE8D8 /* PediFoods App */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
499CD43E2AC5B799001AE8D8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B185A1FD2FB67A12001C7771 /* Sources/LaunchScreen.storyboard in Resources */,
499CD4402AC5B799001AE8D8 /* Assets.xcassets in Resources */,
496BDBEE2B8A7E9C00C09264 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
499CD43A2AC5B799001AE8D8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
499CD43B2AC5B799001AE8D8 /* Main.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
499CD4422AC5B799001AE8D8 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
buildSettings = {
APP_ATTEST_ENVIRONMENT = development;
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
499CD4432AC5B799001AE8D8 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 496EB72F2A6AE4DE00C1253B /* PediFoods.xcconfig */;
buildSettings = {
APP_ATTEST_ENVIRONMENT = production;
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = K4E5BZMM4V;
ENABLE_PREVIEWS = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pedi Foods";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "PediFoods usa sua localização para mostrar lojas perto de você";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
PROVISIONING_PROFILE_SPECIFIER = "com.br.pedifoods.app AppStore";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
49F90C4B2A52156300F06D93 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
49F90C4C2A52156300F06D93 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
499CD4412AC5B799001AE8D8 /* Build configuration list for PBXNativeTarget "PediFoods App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
499CD4422AC5B799001AE8D8 /* Debug */,
499CD4432AC5B799001AE8D8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
49F90C232A52156200F06D93 /* Build configuration list for PBXProject "PediFoods" */ = {
isa = XCConfigurationList;
buildConfigurations = (
49F90C4B2A52156300F06D93 /* Debug */,
49F90C4C2A52156300F06D93 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.11;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
491F27812DA55B72004926EE /* PediFoods */ = {
isa = XCSwiftPackageProductDependency;
productName = PediFoods;
};
B13CABF12F36437300469FD6 /* LCEssentials */ = {
isa = XCSwiftPackageProductDependency;
package = B13CABF02F36437300469FD6 /* XCRemoteSwiftPackageReference "LCEssentials" */;
productName = LCEssentials;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 49F90C202A52156200F06D93 /* Project object */;
}

View File

@@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "499CD4382AC5B799001AE8D8"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods App"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "499CD4382AC5B799001AE8D8"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods App"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "499CD4382AC5B799001AE8D8"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods App"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -1,86 +0,0 @@
import SwiftUI
import PediFoods
private typealias AppRootView = PediFoodsRootView
private typealias AppDelegate = PediFoodsAppDelegate
/// The entry point to the app simply loads the App implementation from SPM module.
@main struct AppMain: App {
@AppDelegateAdaptor(AppMainDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
AppRootView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
AppDelegate.shared.onResume()
case .inactive:
AppDelegate.shared.onPause()
case .background:
AppDelegate.shared.onStop()
@unknown default:
print("unknown app phase: \(newPhase)")
}
}
}
}
#if canImport(UIKit)
typealias AppDelegateAdaptor = UIApplicationDelegateAdaptor
typealias AppMainDelegateBase = UIApplicationDelegate
typealias AppType = UIApplication
#elseif canImport(AppKit)
typealias AppDelegateAdaptor = NSApplicationDelegateAdaptor
typealias AppMainDelegateBase = NSApplicationDelegate
typealias AppType = NSApplication
#endif
@MainActor final class AppMainDelegate: NSObject, AppMainDelegateBase {
let application = AppType.shared
#if canImport(UIKit)
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
AppDelegate.shared.onInit()
return true
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
AppDelegate.shared.onLaunch()
return true
}
func applicationWillTerminate(_ application: UIApplication) {
AppDelegate.shared.onDestroy()
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
AppDelegate.shared.onLowMemory()
}
// support for SkipNotify.fetchNotificationToken()
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(name: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: application, userInfo: ["deviceToken": deviceToken])
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
NotificationCenter.default.post(name: NSNotification.Name("didFailToRegisterForRemoteNotificationsWithError"), object: application, userInfo: ["error": error])
}
#elseif canImport(AppKit)
func applicationWillFinishLaunching(_ notification: Notification) {
AppDelegate.shared.onInit()
}
func applicationDidFinishLaunching(_ notification: Notification) {
AppDelegate.shared.onLaunch()
}
func applicationWillTerminate(_ application: Notification) {
AppDelegate.shared.onDestroy()
}
#endif
}

86
LICENSE.md Normal file
View File

@@ -0,0 +1,86 @@
![](loverde_company_logo_full.png)
Copyright (C) Loverde Company - All Rights Reserved
----------
# ⚠️ INFORMAÇÕES CONFIDENCIAIS E PROPRIEDADE INTELECTUAL ⚠️
## 🚨 AVISO LEGAL IMPORTANTE
**ATENÇÃO: Este repositório contém informações CONFIDENCIAIS e PROPRIETÁRIAS.**
### ⛔ PROIBIÇÕES ABSOLUTAS
- **NÃO** compartilhe este código com terceiros
- **NÃO** faça fork, clone ou cópia sem autorização expressa
- **NÃO** utilize qualquer parte deste código em outros projetos
- **NÃO** distribua, venda ou comercialize este software
- **NÃO** reverse engineer ou descompile o código
- **NÃO** acesse sem permissão explícita do proprietário
### 🔒 DIREITOS DE PROPRIEDADE INTELECTUAL
Este software é propriedade exclusiva de **Daniel Arantes Loverde** e está protegido por:
- **Lei de Propriedade Intelectual (Lei 9.610/98)**
- **Lei de Software (Lei 9.609/98)**
- **Lei Geral de Proteção de Dados (LGPD - Lei 13.709/18)**
- **Direitos Autorais e Patentes**
### ⚖️ CONSEQUÊNCIAS LEGAIS
O acesso não autorizado, uso indevido, cópia ou distribuição deste código constitui:
- **CRIME** de violação de propriedade intelectual
- **CRIME** de roubo de propriedade intelectual
- **CRIME** de violação de direitos autorais
- **CRIME** de acesso não autorizado a sistema informatizado
**Penas previstas: 2 a 5 anos de reclusão + multa**
### 🛡️ MEDIDAS DE SEGURANÇA
- **Monitoramento**: Todo acesso é registrado e monitorado
- **Rastreamento**: IPs e atividades são rastreados
- **Logs**: Sistema completo de auditoria
- **Criptografia**: Dados protegidos com AES-256
- **Autenticação**: Acesso restrito com JWT + OTP
### 📧 CONTATO AUTORIZADO
**ÚNICO CONTATO VÁLIDO:**
- **Email**: daniel@loverde.com.br
- **Autorização**: Apenas via email oficial
### 🔐 CLASSIFICAÇÃO
**NÍVEL DE CONFIDENCIALIDADE: MÁXIMO**
- **Restrito**: Apenas para o proprietário
- **Não Divulgar**: Informações proprietárias
- **Não Compartilhar**: Código fonte protegido
---
## ⚠️ DISCLAIMER FINAL
**Ao acessar este repositório, você concorda que:**
1. **Reconhece** que este é um software proprietário
2. **Compromete-se** a não violar os direitos de propriedade intelectual
3. **Aceita** as consequências legais por qualquer violação
4. **Entende** que o acesso é monitorado e rastreado
5. **Concorda** em respeitar todas as leis aplicáveis
---
*Este documento é parte integrante do software e deve ser mantido confidencial.*
---
This source code is protected under international copyright law. All rights
reserved and protected by the copyright holders.
This file is confidential and only available to authorized individuals with the
permission of the copyright holders. If you encounter this file and do not have
permission, please contact the copyright holders and delete this file.
Autor: Daniel Arantes Loverde

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,60 @@
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 image (real wire key is
/// `"image"`, not the guide's `"imageUrl"` verified against
/// `PushNotificationService.dispatchApns`/`dispatchApnsMany` in Atomenta).
/// `@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["image"] 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

@@ -1,31 +0,0 @@
e# Plano - Tela de Perfil
Legenda:
- 🟢 Concluído
- 🟡 Em validação
- 🔴 Pendente
Última atualização: 2026-02-13
## Entregas
- 🟢 Redesign do conteúdo da tela de perfil no estilo do modelo (header + cards de menu)
- 🟢 Header ajustada para encostar no topo e nas laterais
- 🟢 Redução de escala dos itens da lista (ícones, fonte, altura e paddings)
- 🟢 Faixa de proteção no rodapé para não deixar conteúdo por trás da tabbar
- 🟢 Confirmação com alert padrão ao tocar em "Sair da Conta"
- 🟢 Fluxo de editar endereço reutilizando a tela de novo endereço
- 🟢 Swipe para excluir endereço com compatibilidade Android (gesto custom)
- 🟢 Polimento visual do swipe/delete (sem vazamento de fundo vermelho)
## Pendências
- 🟡 Validação visual final no device (iOS e Android) com screenshots finais
- 🔴 Definir destinos reais dos itens ainda placeholder:
- Meus Cartões
- Minhas Avaliações
- Cupons de Desconto
- Configurações/Ajuda (confirmar nomenclatura final)
- 🔴 Corrigir build global do projeto (dependência `LCEssentials` ausente em `StoreDetailView.swift`)
Autor: Daniel Arantes Loverde

View File

@@ -1,52 +0,0 @@
# Análise Técnica — Arquitetura Skip (PediFoods)
## Entendimento confirmado
O projeto usa **Skip com fonte principal em Swift/SwiftUI** e geração/transpilação para Android.
Diretriz operacional validada:
1. **iOS é a plataforma prioritária** de desenvolvimento e validação.
2. **Android entra depois**, com adaptações isoladas por compilação condicional.
3. Toda alteração deve preservar comportamento de iOS e evitar regressão cross-platform.
## Como o projeto está estruturado
1. Código compartilhado principal: `Sources/PediFoods/*` (Swift/SwiftUI).
2. Entry point iOS: `Darwin/Sources/Main.swift`.
3. Entry point Android: `Android/app/src/main/kotlin/Main.kt`.
4. Configuração Skip: `Sources/PediFoods/Skip/skip.yml` (`mode: native`).
5. Build/transpilação Android via plugin Skip (`skipstone`) no target Swift Package.
## Evidências no código (padrão já em uso)
1. Uso extensivo de condicionais:
- `#if os(iOS)`
- `#if os(Android)`
- `#if canImport(UIKit)`
2. Dependência iOS-only no `Package.swift`:
- `LCEssentials` com `condition: .when(platforms: [.iOS])`
3. Exemplo claro de adaptação segura:
- `ContentView.swift`: `SnackbarCenter` com implementação distinta para Android e iOS.
## Regra prática para próximas tarefas
1. Implementar primeiro para iOS.
2. Validar iOS (build/execução/comportamento).
3. Só depois adaptar Android, sempre em bloco condicional separado.
4. Nunca alterar caminho iOS por causa de ajuste Android.
5. Em mudanças de UI de inicialização (ex.: splash), tratar iOS e Android em fluxos independentes.
## Nota específica para Splash
Estado atual identificado:
1. iOS com Launch Screen gerado automaticamente (`INFOPLIST_KEY_UILaunchScreen_Generation = YES`).
2. Android sem implementação custom de splash dedicada no `res/values` + tema de launch.
Implicação:
1. Mudança de splash para iOS pode ser feita sem tocar Android.
2. Quando Android for tratado, deve entrar em camada própria (tema/recursos Android), sem impactar o fluxo iOS.
Autor: Daniel Arantes Loverde

View File

@@ -1,20 +0,0 @@
// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "pedi-foods",
defaultLocalization: "en",
platforms: [.iOS(.v17), .macOS(.v14)],
products: [
.library(name: "PediFoods", type: .dynamic, targets: ["PediFoods"]),
],
dependencies: [
.package(url: "https://git.loverde.com.br/Loverde-Company-LTDA/LCEssentials.git", from: "1.0.5")
],
targets: [
.target(name: "PediFoods", dependencies: [
.product(name: "LCEssentials", package: "lcessentials")
], resources: [.process("Resources")]),
.testTarget(name: "PediFoodsTests", dependencies: ["PediFoods"]),
]
)

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,6 @@
<Workspace
version = "1.0">
<FileRef
location = "group:Darwin/PediFoods.xcodeproj">
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3027BAE353B414EC70E2A2AC"
BuildableName = "PediFoodsTests.xctest"
BlueprintName = "PediFoodsTests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66486D25E3A8A75EA8FACE44"
BuildableName = "PediFoodsUITests.xctest"
BlueprintName = "PediFoodsUITests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3027BAE353B414EC70E2A2AC"
BuildableName = "PediFoodsTests.xctest"
BlueprintName = "PediFoodsTests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "66486D25E3A8A75EA8FACE44"
BuildableName = "PediFoodsUITests.xctest"
BlueprintName = "PediFoodsUITests"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B3B5CFF77A8ED019D1299ABB"
BuildableName = "PediFoods.app"
BlueprintName = "PediFoods"
ReferencedContainer = "container:PediFoods.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 835 B

After

Width:  |  Height:  |  Size: 835 B

View File

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 667 KiB

After

Width:  |  Height:  |  Size: 667 KiB

View File

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

View File

Before

Width:  |  Height:  |  Size: 924 KiB

After

Width:  |  Height:  |  Size: 924 KiB

View File

Before

Width:  |  Height:  |  Size: 951 KiB

After

Width:  |  Height:  |  Size: 951 KiB

View File

Before

Width:  |  Height:  |  Size: 940 KiB

After

Width:  |  Height:  |  Size: 940 KiB

View File

Before

Width:  |  Height:  |  Size: 926 KiB

After

Width:  |  Height:  |  Size: 926 KiB

View File

Before

Width:  |  Height:  |  Size: 799 KiB

After

Width:  |  Height:  |  Size: 799 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,10 +1,6 @@
import Foundation
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
enum ImageFitMode {
/// Scale to cover the whole box, cropping whichever axis overflows.
@@ -22,9 +18,7 @@ struct CachedRemoteImage<Placeholder: View>: View {
let fitMode: ImageFitMode
let placeholder: Placeholder
#if canImport(UIKit) || canImport(AppKit)
@StateObject var loader = CachedRemoteImageLoader()
#endif
init(
imageURL: String?,
@@ -41,52 +35,23 @@ struct CachedRemoteImage<Placeholder: View>: View {
var body: some View {
GeometryReader { geometry in
Group {
#if canImport(UIKit)
if let uiImage = loader.uiImage {
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
} else {
placeholder
}
#elseif canImport(AppKit)
if let nsImage = loader.nsImage {
rendered(image: Image(nsImage: nsImage), pixelSize: nsImage.size, in: geometry.size)
} else {
placeholder
}
#else
if let imageURL,
let url = URL(string: imageURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
default:
placeholder
}
}
} else {
placeholder
}
#endif
}
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
.onAppear {
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: imageURL, ttl: ttl)
#endif
}
.onChange(of: imageURL) { _, newValue in
#if canImport(UIKit) || canImport(AppKit)
loader.load(imageURL: newValue, ttl: ttl)
#endif
}
}
#if canImport(UIKit) || canImport(AppKit)
@ViewBuilder
private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View {
switch fitMode {
@@ -103,17 +68,11 @@ struct CachedRemoteImage<Placeholder: View>: View {
.frame(width: containerSize.width, height: containerSize.height)
}
}
#endif
}
#if canImport(UIKit) || canImport(AppKit)
@MainActor
final class CachedRemoteImageLoader: ObservableObject {
#if canImport(UIKit)
@Published var uiImage: UIImage?
#elseif canImport(AppKit)
@Published var nsImage: NSImage?
#endif
private var currentKey: String?
private var task: Task<Void, Never>?
@@ -130,50 +89,30 @@ final class CachedRemoteImageLoader: ObservableObject {
task?.cancel()
#if canImport(UIKit)
uiImage = nil
#elseif canImport(AppKit)
nsImage = nil
#endif
guard let normalized, normalized.isEmpty == false else { return }
#if canImport(UIKit) || canImport(AppKit)
let dataCacheKey = Self.dataURLCacheKey(normalized)
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
#if canImport(UIKit)
uiImage = cachedDataImage
#elseif canImport(AppKit)
nsImage = cachedDataImage
#endif
return
}
if let image = Self.imageFromDataURL(normalized) {
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
#if canImport(UIKit)
uiImage = image
#elseif canImport(AppKit)
nsImage = image
#endif
return
}
#endif
guard let url = URL(string: normalized) else { return }
task = Task { [weak self] in
#if canImport(UIKit) || canImport(AppKit)
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
guard Task.isCancelled == false else { return }
await MainActor.run {
#if canImport(UIKit)
self?.uiImage = image
#elseif canImport(AppKit)
self?.nsImage = image
#endif
}
#endif
}
}
@@ -187,7 +126,6 @@ final class CachedRemoteImageLoader: ObservableObject {
return "data-image:\(source.count):\(head):\(tail)"
}
#if canImport(UIKit) || canImport(AppKit)
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
let lower = source.lowercased()
guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil }
@@ -203,14 +141,6 @@ final class CachedRemoteImageLoader: ObservableObject {
.replacingOccurrences(of: " ", with: "")
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#else
return nil
#endif
}
#endif
}
#endif

View File

@@ -1,6 +1,11 @@
import Foundation
import SwiftUI
enum DeepLinkRouteEffect: Equatable {
case navigateToOrder(OrderRouteContext, tab: MainTab)
case none
}
struct ContentView: View {
@State var root: RootFlow = .main
@State var selectedTab: MainTab = .home
@@ -15,6 +20,7 @@ struct ContentView: View {
@State private var sessionExpiredObserver: NSObjectProtocol?
@State var cartResetObserver: Any?
@State var appResumeObserver: Any?
@State var pushDeepLinkObserver: Any?
@StateObject var snackbarCenter = SnackbarCenter.shared
var body: some View {
@@ -34,7 +40,7 @@ struct ContentView: View {
if isBootstrappingSession {
sessionBootstrapLoadingView
} else {
MainTabView(selectedTab: $selectedTab, root: $root, tokenStore: tokenStore, appState: $appState)
MainTabView(selectedTab: $selectedTab, tokenStore: tokenStore, appState: $appState, enterAuth: enterAuthFlow)
}
}
}
@@ -46,7 +52,7 @@ struct ContentView: View {
.transition(.opacity)
}
}
.sheet(item: $appState.activeModal) { modal in
.sheet(item: $appState.activeModal, onDismiss: { appState.activeModal = nil }) { modal in
switch modal {
case .addressPicker:
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
@@ -90,11 +96,13 @@ struct ContentView: View {
attachCartResetObserverIfNeeded()
attachAppResumeObserverIfNeeded()
attachSessionExpiredObserverIfNeeded()
attachPushDeepLinkObserverIfNeeded()
}
.onDisappear {
detachCartResetObserver()
detachAppResumeObserver()
detachSessionExpiredObserver()
detachPushDeepLinkObserver()
}
}
@@ -241,6 +249,69 @@ struct ContentView: View {
self.appResumeObserver = nil
}
/// §6 of the push notifications guide, generalized: any tapped push that
/// resolves to a `DeepLinkDestination` (reported by
/// `PushNotificationCoordinator`) lands here. Adding a future promo/coupon
/// screen means adding one case to `route(to:)` below this observer,
/// the notification name, and the AppState plumbing for it stay put.
private func attachPushDeepLinkObserverIfNeeded() {
guard pushDeepLinkObserver == nil else { return }
pushDeepLinkObserver = NotificationCenter.default.addObserver(
forName: .pushDeepLinkReceived,
object: nil,
queue: nil
) { notification in
guard let destination = notification.userInfo?["destination"] as? DeepLinkDestination else { return }
Task { @MainActor in
route(to: destination)
}
}
}
private func detachPushDeepLinkObserver() {
guard let pushDeepLinkObserver else { return }
NotificationCenter.default.removeObserver(pushDeepLinkObserver)
self.pushDeepLinkObserver = nil
}
/// Pure mapping from a deep-link destination to what `route(to:)` should
/// do - split out so it's testable without a live `ContentView`
/// instance (this reads no `@State`, `route(to:)` is what applies the
/// result to `appState`/`selectedTab`).
func routeEffect(for destination: DeepLinkDestination) -> DeepLinkRouteEffect {
switch destination {
case .orderTracking(let orderId, let shortId):
return .navigateToOrder(
OrderRouteContext(
orderId: orderId,
shortId: shortId,
paymentMethod: nil,
total: nil,
intent: .auto
),
tab: .profile
)
case .screen(let name, let params):
// Promo/coupon screens (participating stores/products, §6's
// `targetScreen` convention) land here once they exist see
// decisions/2026-08-04-push-deeplink-routing-contract.md.
logger.debug("Unhandled deep-link screen: \(name, privacy: .public) params=\(params, privacy: .public)")
return .none
}
}
@MainActor
private func route(to destination: DeepLinkDestination) {
guard root == .main else { return }
switch routeEffect(for: destination) {
case .navigateToOrder(let context, let tab):
appState.pendingOrderDeepLink = context
selectedTab = tab
case .none:
break
}
}
@MainActor
private func hydrateAppState(with customer: CustomerProfile) {
appState.profile.id = customer.id
@@ -336,6 +407,16 @@ struct ContentView: View {
isBootstrappingSession = false
appState = AppState()
selectedTab = .home
enterAuthFlow()
}
/// The only correct way to transition `root` to `.auth`: pairs the
/// switch with the token bump `LoginView` needs to run its entry
/// reveal animation. A bare `root = .auth` leaves the login screen's
/// hero/text/button stuck hidden - see
/// decisions/2026-08-06-login-entry-animation-bug.md.
@MainActor
private func enterAuthFlow() {
shouldPrepareAuthEntryAnimation = true
root = .auth
authEntryAnimationToken += 1

View File

@@ -0,0 +1,49 @@
import Foundation
/// Single decode target for anything that can deep-link into the app push
/// notification taps today (docs/api/push-notifications-integration-guide.md
/// §6), in-app promo/discount banner taps tomorrow (same shape: a string-keyed
/// dictionary naming a screen + its params). Adding a new destination means
/// adding one case here + one branch in `ContentView.route(to:)` never a new
/// `NotificationCenter` name, `AppState` field, or observer pair.
enum DeepLinkDestination: Equatable {
/// `type: "order_status"` the only push type Atomenta fixes today
/// (§6), so it gets its own case instead of routing through `targetScreen`.
case orderTracking(orderId: String, shortId: String?)
/// Everything else, keyed by `targetScreen` our own convention (§6
/// leaves this to us). Once a real screen exists for a given name (e.g.
/// future promo/coupon "list of participating stores/products"), give it
/// its own case; until then this carries the raw params so nothing is
/// silently dropped.
case screen(name: String, params: [String: String])
}
/// Resolves the *navigation* half of a tapped push's payload. Pure and
/// side-effect-free on purpose: easy to unit test, and reusable as-is for
/// in-app banner taps once those exist. Campaign-open reporting (§6a) is a
/// separate side effect, not a navigation target, and is not decided here
/// see `PushNotificationCoordinator.handleTap`.
struct PushDeepLinkParser {
static func parse(_ userInfo: [AnyHashable: Any]) -> DeepLinkDestination? {
if let type = userInfo["type"] as? String, type == "order_status" {
// Atomenta's real order-status push (PushNotificationService.
// notifyOrderStatusUpdate) sends only `shortId`, never `orderId`
// `GET /api/public/orders/:orderId` accepts either as the lookup
// key server-side, so `shortId` doubles as the id here too.
let shortId = userInfo["shortId"] as? String
guard let orderId = (userInfo["orderId"] as? String) ?? shortId else { return nil }
return .orderTracking(orderId: orderId, shortId: shortId)
}
guard let targetScreen = userInfo["targetScreen"] as? String else { return nil }
var params: [String: String] = [:]
for (key, value) in userInfo {
guard let key = key as? String, key != "type", key != "targetScreen" else { continue }
if let stringValue = value as? String {
params[key] = stringValue
}
}
return .screen(name: targetScreen, params: params)
}
}

View File

@@ -1,9 +1,5 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
// MARK: - Colors
@@ -66,13 +62,7 @@ struct AppTypography {
}
private static var isBrandFontAvailable: Bool {
#if canImport(UIKit)
return UIFont(name: fontFamily, size: 16) != nil
#elseif canImport(AppKit)
return NSFont(name: fontFamily, size: 16) != nil
#else
return false
#endif
UIFont(name: fontFamily, size: 16) != nil
}
}

View File

@@ -0,0 +1,63 @@
import OSLog
import SwiftUI
let logger: Logger = Logger(subsystem: "com.br.pedifoods.app", category: "PediFoods")
@main
struct PediFoodsApp: App {
@UIApplicationDelegateAdaptor(PediFoodsAppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
logger.debug("onResume")
NotificationCenter.default.post(name: .appDidResume, object: nil)
case .inactive:
logger.debug("onPause")
case .background:
logger.debug("onStop")
@unknown default:
logger.debug("unknown scene phase: \(String(describing: newPhase))")
}
}
}
}
final class PediFoodsAppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
logger.debug("onInit")
return true
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
logger.debug("onLaunch")
Task { @MainActor in
PushNotificationCoordinator.shared.startObservingDeviceToken()
PushNotificationCoordinator.shared.becomeNotificationCenterDelegate()
await PushNotificationCoordinator.shared.refreshRegistrationIfAuthorized()
await PushNotificationCoordinator.shared.syncCustomerAttributes()
}
return true
}
func applicationWillTerminate(_ application: UIApplication) {
logger.debug("onDestroy")
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
logger.debug("onLowMemory")
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(name: NSNotification.Name("didRegisterForRemoteNotificationsWithDeviceToken"), object: application, userInfo: ["deviceToken": deviceToken])
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
NotificationCenter.default.post(name: NSNotification.Name("didFailToRegisterForRemoteNotificationsWithError"), object: application, userInfo: ["error": error])
}
}

View File

@@ -3,10 +3,6 @@
"strings" : {
"" : {
},
" " : {
"comment" : "A placeholder text used to maintain vertical spacing in the UI.",
"isCommentAutoGenerated" : true
},
"- %@" : {
"comment" : "A text element displaying a negative monetary value, indicating a discount.",
@@ -89,6 +85,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 +147,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 +214,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..." : {
@@ -296,10 +307,6 @@
"comment" : "A loading message displayed while bootstrapping the user's session.",
"isCommentAutoGenerated" : true
},
"Carrinho" : {
"comment" : "A label for the cart section of the app.",
"isCommentAutoGenerated" : true
},
"Cartão de Crédito" : {
},
@@ -438,9 +445,6 @@
"Do que você gostou?" : {
"comment" : "A prompt asking users to mention what they liked about a review.",
"isCommentAutoGenerated" : true
},
"Editar endereço" : {
},
"Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : {
"comment" : "A description under the NPS scale question in the order review view.",
@@ -551,38 +555,9 @@
"comment" : "The title of the checkout screen.",
"isCommentAutoGenerated" : true
},
"Hello [%@](https://skip.tools)!" : {
"comment" : "Welcome tab contents",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "¡Hola [%@](https://skip.tools)!"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bonjour [%@](https://skip.tools)!"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "こんにちは [%@](https://skip.tools)"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "你好 [%@](https://skip.tools)"
}
}
}
},
"Home" : {
"comment" : "Tab bar item title for the Home tab",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
@@ -700,13 +675,6 @@
},
"Meus Cartões" : {
},
"Meus Endereços" : {
"comment" : "A label displayed at the top of the view.",
"isCommentAutoGenerated" : true
},
"Meus Pedidos" : {
},
"Minhas Avaliações" : {
"comment" : "A button that links to their reviews.",
@@ -809,16 +777,15 @@
}
}
}
},
"Notificações desativadas" : {
},
"Novo cartão" : {
},
"Novo Cartão" : {
},
"Novo endereço" : {
"comment" : "A label for a form to add a new address.",
"isCommentAutoGenerated" : true
},
"Novo por aqui?" : {
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
@@ -870,6 +837,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 #%@" : {
@@ -886,19 +856,11 @@
},
"Pedido %@" : {
},
"PediFoods" : {
"comment" : "The name of the app.",
"isCommentAutoGenerated" : true
},
"Pedir Novamente" : {
"comment" : "A button that allows a user to order the same items again.",
"isCommentAutoGenerated" : true
},
"Perfil" : {
"comment" : "A label for the \"Profile\" tab in the main tab view.",
"isCommentAutoGenerated" : true
},
"Pizza de varios sabores" : {
"comment" : "A title displayed above the main content of the view.",
"extractionState" : "stale",
@@ -916,36 +878,6 @@
"comment" : "The title of the privacy policy section.",
"isCommentAutoGenerated" : true
},
"Powered by [Skip](https://skip.tools)" : {
"comment" : "Link markdown text for the Powered by… label",
"extractionState" : "stale",
"localizations" : {
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Impulsado por [Skip](https://skip.tools)"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fonctionnalités offertes par [Skip](https://skip.tools)"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "[Skip](https://skip.tools) を使って動かしています"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "由[Skip](https://skip.tools)提供支持"
}
}
}
},
"Precisa de ajuda com esse pedido?" : {
},
@@ -964,10 +896,6 @@
"comment" : "A label indicating that a particular address is the user's primary address.",
"isCommentAutoGenerated" : true
},
"Privacidade" : {
"comment" : "The title of the privacy policy screen.",
"isCommentAutoGenerated" : true
},
"Progresso do Pedido" : {
},
@@ -975,24 +903,12 @@
"comment" : "The text for a button that allows a user to re-evaluate an order.",
"isCommentAutoGenerated" : true
},
"Receber Código" : {
"comment" : "A button label that says \"Receive Code\".",
"isCommentAutoGenerated" : true
},
"Reenviar código" : {
"comment" : "A button that allows a user to request a new OTP code.",
"isCommentAutoGenerated" : true
},
"Remover" : {
"comment" : "A button that deletes the user's profile picture.",
"isCommentAutoGenerated" : true
},
"Remover loja dos favoritos" : {
},
"Resposta da loja" : {
"comment" : "A heading for the reply from the store in an order review.",
"isCommentAutoGenerated" : true
},
"RESTAURANT" : {
@@ -1123,9 +1039,6 @@
"Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : {
"comment" : "An alert that appears when a user tries to place an order from an address that is not served by the store. The alert offers the user the option to either keep the current address or to switch to a different one",
"isCommentAutoGenerated" : true
},
"Seu pedido está em andamento" : {
},
"Sim" : {
"comment" : "The text for a button that confirms an action. In this case, it confirms the user's choice to continue with the order despite the address not being served by the store.",

View File

@@ -1,8 +1,5 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(LCEssentials) && os(iOS)
#if canImport(LCEssentials)
import LCEssentials
#endif
@@ -45,7 +42,7 @@ enum NetworkError: Error, LocalizedError {
}
}
private struct ApiErrorDescriptor {
struct ApiErrorDescriptor {
let code: String?
let message: String?
}
@@ -102,7 +99,7 @@ final class ApiClient: @unchecked Sendable {
// resolving no response, no error, nothing the UI must still
// get an answer so it can stop showing "nothing happened."
try await withTimeout(seconds: 20) { [self] in
#if canImport(LCEssentials) && os(iOS)
#if canImport(LCEssentials)
try await sendWithLCEssentials(request)
#else
try await sendWithURLSession(request)
@@ -128,8 +125,8 @@ final class ApiClient: @unchecked Sendable {
}
}
private extension ApiClient {
#if canImport(LCEssentials) && os(iOS)
extension ApiClient {
#if canImport(LCEssentials)
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
let urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString
let method = request.method
@@ -402,7 +399,7 @@ private extension ApiClient {
return false
}
#if canImport(LCEssentials) && os(iOS)
#if canImport(LCEssentials)
func serverMessage(from error: NSError) -> String? {
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
if let value = error.userInfo[key] as? String,

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

@@ -1,11 +1,6 @@
import Foundation
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endif
enum AppCacheTTL {
static let twoHours: TimeInterval = 2 * 60 * 60
@@ -61,7 +56,6 @@ final class AppContentCache: @unchecked Sendable {
}
}
#if canImport(UIKit) || canImport(AppKit)
final class AppImageCache: @unchecked Sendable {
static let shared = AppImageCache()
@@ -132,23 +126,6 @@ final class AppImageCache: @unchecked Sendable {
}
private func platformImage(from data: Data) -> PlatformImage? {
#if canImport(UIKit)
return UIImage(data: data)
#elseif canImport(AppKit)
return NSImage(data: data)
#else
return nil
#endif
UIImage(data: data)
}
}
#endif
#if !(canImport(UIKit) || canImport(AppKit))
final class AppImageCache: @unchecked Sendable {
static let shared = AppImageCache()
private init() {}
func invalidateAll() {}
}
#endif

View File

@@ -1,7 +1,4 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct FeatureControlRawFlag: Codable, Equatable {
let enabled: Bool
@@ -271,13 +268,11 @@ final class FeatureControlService {
}
private func appVersion() -> String {
#if canImport(UIKit) || canImport(AppKit)
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let version, version.isEmpty == false {
return version
}
#endif
return "0.0.0"
}
}

View File

@@ -2,6 +2,25 @@ import Foundation
#if os(iOS)
import CoreLocation
/// The subset of `CLLocationManager` `LocationService` needs lets tests
/// substitute a fake instead of touching real hardware/OS permission state.
@MainActor
protocol LocationManaging: AnyObject {
var locationManagingDelegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var authorizationStatus: CLAuthorizationStatus { get }
var location: CLLocation? { get }
func requestWhenInUseAuthorization()
func requestLocation()
}
extension CLLocationManager: LocationManaging {
var locationManagingDelegate: CLLocationManagerDelegate? {
get { delegate }
set { delegate = newValue }
}
}
#endif
@MainActor
@@ -23,17 +42,22 @@ final class LocationService: NSObject {
#endif
#if os(iOS)
private let manager = CLLocationManager()
private let manager: LocationManaging
private var completion: ((LocationResult) -> Void)?
#endif
#if os(iOS)
init(manager: LocationManaging = CLLocationManager()) {
self.manager = manager
super.init()
manager.locationManagingDelegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
}
#else
override init() {
super.init()
#if os(iOS)
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
#endif
}
#endif
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
#if os(iOS)

View File

@@ -1,7 +1,4 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
@MainActor
final class OrderRealtimeTracker {

View File

@@ -0,0 +1,220 @@
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 a tapped push resolves to a navigable `DeepLinkDestination`
/// (§6) so `ContentView` can route without this service depending on
/// `AppState`. One name for every destination, present and future see
/// `DeepLinkDestination`.
extension Notification.Name {
static let pushDeepLinkReceived = Notification.Name("pushDeepLinkReceived")
}
#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. Campaign-open
/// reporting (§6a, a side effect, not a navigation target) is decided
/// directly on `type` here; navigation is delegated to
/// `PushDeepLinkParser` and forwarded to `ContentView` via
/// `NotificationCenter` (this service has no `AppState` binding of its
/// own). A push can do both e.g. a future campaign that also sets
/// `targetScreen` reports its open *and* navigates.
fileprivate func handleTap(userInfo: [AnyHashable: Any]) {
if let type = userInfo["type"] as? String, type == "campaign",
let campaignId = userInfo["campaignId"] as? String {
Task { await reportCampaignOpened(campaignId: campaignId) }
}
guard let destination = PushDeepLinkParser.parse(userInfo) else { return }
NotificationCenter.default.post(name: .pushDeepLinkReceived, object: nil, userInfo: ["destination": destination])
}
}
/// `UNNotification.userInfo` is `[AnyHashable: Any]`, which the compiler
/// can't prove `Sendable` but it's an immutable payload handed to us
/// once by the OS, so crossing the actor boundary with it is safe in
/// practice.
private struct UncheckedSendableBox<Value>: @unchecked Sendable {
let value: Value
}
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 = UncheckedSendableBox(value: response.notification.request.content.userInfo)
Task { @MainActor in
PushNotificationCoordinator.shared.handleTap(userInfo: userInfo.value)
}
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

@@ -80,6 +80,12 @@ private struct PersistedOrderReviewsState: Codable {
}
enum SessionStateStore {
/// Swappable for tests (isolated `UserDefaults(suiteName:)`), defaults
/// to the real app defaults in production. `UserDefaults` is thread-safe
/// on its own; `nonisolated(unsafe)` only opts out of Swift 6's static-
/// mutable-state check for the var itself, tests set it once up front.
nonisolated(unsafe) static var defaults: UserDefaults = .standard
private static let legacyAddressKey = "session.address.state.v1"
private static let addressKeyPrefix = "session.address.state.v2."
private static let activeUserKey = "session.active.user.v1"
@@ -88,6 +94,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 = defaults.object(forKey: pushOptInPromptKey) as? Date else { return true }
return Date().timeIntervalSince(last) > pushOptInCooldown
}
static func recordPushOptInPrompted() {
defaults.set(Date(), forKey: pushOptInPromptKey)
}
static func makeUserKey(profileId: String?, email: String?) -> String? {
let id = (profileId ?? "")
@@ -110,14 +129,14 @@ enum SessionStateStore {
static func setActiveUserKey(_ userKey: String?) {
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
UserDefaults.standard.removeObject(forKey: activeUserKey)
defaults.removeObject(forKey: activeUserKey)
} else {
UserDefaults.standard.set(trimmed, forKey: activeUserKey)
defaults.set(trimmed, forKey: activeUserKey)
}
}
static func loadActiveUserKey() -> String? {
let value = UserDefaults.standard.string(forKey: activeUserKey)?
let value = defaults.string(forKey: activeUserKey)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
@@ -133,7 +152,6 @@ enum SessionStateStore {
}
static func loadAddress() -> AddressState? {
let defaults = UserDefaults.standard
let activeKey = loadActiveUserKey()
let scopedKey = addressStorageKey(for: activeKey)
@@ -201,17 +219,16 @@ enum SessionStateStore {
longitude: state.longitude
)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: addressStorageKey(for: nil))
defaults.set(data, forKey: addressStorageKey(for: nil))
}
static func clearAddress() {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: addressStorageKey(for: nil))
defaults.removeObject(forKey: legacyAddressKey)
}
static func clearActiveUser() {
UserDefaults.standard.removeObject(forKey: activeUserKey)
defaults.removeObject(forKey: activeUserKey)
}
private static func cartStorageKey(for userKey: String?) -> String {
@@ -222,7 +239,6 @@ enum SessionStateStore {
}
static func loadCart() -> CartState? {
let defaults = UserDefaults.standard
let key = cartStorageKey(for: nil)
if let data = defaults.data(forKey: key),
@@ -284,11 +300,11 @@ enum SessionStateStore {
total: state.total
)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: cartStorageKey(for: nil))
defaults.set(data, forKey: cartStorageKey(for: nil))
}
static func clearCart() {
UserDefaults.standard.removeObject(forKey: cartStorageKey(for: nil))
defaults.removeObject(forKey: cartStorageKey(for: nil))
}
private static func trackedOrdersStorageKey(for userKey: String?) -> String {
@@ -323,7 +339,6 @@ enum SessionStateStore {
}
static func loadTrackedOrders() -> [PublicOrderResult] {
let defaults = UserDefaults.standard
let key = trackedOrdersStorageKey(for: nil)
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else {
@@ -348,11 +363,11 @@ enum SessionStateStore {
}
let payload = PersistedTrackedOrdersState(orders: orders)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: trackedOrdersStorageKey(for: nil))
defaults.set(data, forKey: trackedOrdersStorageKey(for: nil))
}
static func clearTrackedOrders() {
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
defaults.removeObject(forKey: trackedOrdersStorageKey(for: nil))
}
static func savePendingCartOrderId(_ orderId: String) {
@@ -361,11 +376,11 @@ enum SessionStateStore {
clearPendingCartOrder()
return
}
UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
defaults.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadPendingCartOrderId() -> String? {
let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))?
let value = defaults.string(forKey: pendingCartOrderStorageKey(for: nil))?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let value, value.isEmpty == false {
return value
@@ -374,12 +389,12 @@ enum SessionStateStore {
}
static func clearPendingCartOrder() {
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
defaults.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
}
static func loadOrderReviews() -> [OrderReviewRecord] {
let key = orderReviewsStorageKey(for: nil)
guard let data = UserDefaults.standard.data(forKey: key),
guard let data = defaults.data(forKey: key),
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
return []
}
@@ -416,12 +431,12 @@ enum SessionStateStore {
let payload = PersistedOrderReviewsState(reviews: reviews)
guard let data = try? JSONEncoder().encode(payload) else { return }
UserDefaults.standard.set(data, forKey: orderReviewsStorageKey(for: nil))
defaults.set(data, forKey: orderReviewsStorageKey(for: nil))
}
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
guard let data = UserDefaults.standard.data(forKey: key),
guard let data = defaults.data(forKey: key),
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
return nil
}
@@ -431,11 +446,11 @@ enum SessionStateStore {
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
guard let data = try? JSONEncoder().encode(draft) else { return }
UserDefaults.standard.set(data, forKey: key)
defaults.set(data, forKey: key)
}
static func clearOrderReviewDraft(orderId: String) {
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
UserDefaults.standard.removeObject(forKey: key)
defaults.removeObject(forKey: key)
}
}

Some files were not shown because too many files have changed in this diff Show More