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
@@ -35,7 +35,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Write App Store Connect API key
|
- name: Write App Store Connect API key
|
||||||
run: |
|
run: |
|
||||||
cat > Darwin/fastlane/apikey.json <<EOF
|
cat > fastlane/apikey.json <<EOF
|
||||||
{
|
{
|
||||||
"key_id": "${{ secrets.ASC_KEY_ID }}",
|
"key_id": "${{ secrets.ASC_KEY_ID }}",
|
||||||
"issuer_id": "${{ secrets.ASC_ISSUER_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 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 default-keychain -d user -s ~/Library/Keychains/ci-signing.keychain-db
|
||||||
security find-identity -v -p codesigning
|
security find-identity -v -p codesigning
|
||||||
cd Darwin
|
|
||||||
fastlane beta
|
fastlane beta
|
||||||
|
|
||||||
- name: Clean up API key
|
- name: Clean up API key
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f Darwin/fastlane/apikey.json
|
run: rm -f fastlane/apikey.json
|
||||||
|
|
||||||
sleep-macos-vm:
|
sleep-macos-vm:
|
||||||
needs: build-and-upload
|
needs: build-and-upload
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Write App Store Connect API key
|
- name: Write App Store Connect API key
|
||||||
run: |
|
run: |
|
||||||
cat > Darwin/fastlane/apikey.json <<EOF
|
cat > fastlane/apikey.json <<EOF
|
||||||
{
|
{
|
||||||
"key_id": "${{ secrets.ASC_KEY_ID }}",
|
"key_id": "${{ secrets.ASC_KEY_ID }}",
|
||||||
"issuer_id": "${{ secrets.ASC_ISSUER_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
|
security dump-keychain "$HOME/Library/Keychains/ci-signing.keychain-db" | grep -c "class.*0x80001000" || true
|
||||||
echo "--- find-identity ---"
|
echo "--- find-identity ---"
|
||||||
security find-identity -v -p codesigning
|
security find-identity -v -p codesigning
|
||||||
cd Darwin
|
|
||||||
fastlane release
|
fastlane release
|
||||||
|
|
||||||
- name: Clean up API key
|
- name: Clean up API key
|
||||||
if: always()
|
if: always()
|
||||||
run: rm -f Darwin/fastlane/apikey.json
|
run: rm -f fastlane/apikey.json
|
||||||
|
|
||||||
sleep-macos-vm:
|
sleep-macos-vm:
|
||||||
needs: build-archive-deliver
|
needs: build-archive-deliver
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ jobs:
|
|||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: |
|
run: |
|
||||||
xcodebuild test \
|
xcodebuild test \
|
||||||
-project Darwin/PediFoods.xcodeproj \
|
-project PediFoods.xcodeproj \
|
||||||
-scheme "PediFoods App" \
|
-scheme "PediFoods" \
|
||||||
-destination "platform=iOS Simulator,name=iPhone 16,OS=latest"
|
-destination "platform=iOS Simulator,name=iPhone 16,OS=latest"
|
||||||
|
|
||||||
sleep-macos-vm:
|
sleep-macos-vm:
|
||||||
|
|||||||
8
.gitignore
vendored
@@ -7,14 +7,6 @@
|
|||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# gradle properties
|
|
||||||
local.properties
|
|
||||||
.gradle/
|
|
||||||
.android/
|
|
||||||
.kotlin/
|
|
||||||
Android/app/keystore.jks
|
|
||||||
Android/app/keystore.properties
|
|
||||||
|
|
||||||
xcodebuild*.log
|
xcodebuild*.log
|
||||||
|
|
||||||
default.profraw
|
default.profraw
|
||||||
|
|||||||
@@ -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 =
|
|
||||||
@@ -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 */;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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
@@ -0,0 +1,86 @@
|
|||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
31
NotificationServiceExtension/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NotificationService</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.usernotifications.service</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
60
NotificationServiceExtension/NotificationService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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"]),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
1296
PediFoods.xcodeproj/project.pbxproj
Normal file
@@ -2,6 +2,6 @@
|
|||||||
<Workspace
|
<Workspace
|
||||||
version = "1.0">
|
version = "1.0">
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Darwin/PediFoods.xcodeproj">
|
location = "self:">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
143
PediFoods.xcodeproj/xcshareddata/xcschemes/PediFoods.xcscheme
Normal 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>
|
||||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 835 B After Width: | Height: | Size: 835 B |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 667 KiB After Width: | Height: | Size: 667 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 406 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 924 KiB After Width: | Height: | Size: 924 KiB |
|
Before Width: | Height: | Size: 951 KiB After Width: | Height: | Size: 951 KiB |
|
Before Width: | Height: | Size: 940 KiB After Width: | Height: | Size: 940 KiB |
|
Before Width: | Height: | Size: 926 KiB After Width: | Height: | Size: 926 KiB |
|
Before Width: | Height: | Size: 799 KiB After Width: | Height: | Size: 799 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -1,10 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#elseif canImport(AppKit)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum ImageFitMode {
|
enum ImageFitMode {
|
||||||
/// Scale to cover the whole box, cropping whichever axis overflows.
|
/// Scale to cover the whole box, cropping whichever axis overflows.
|
||||||
@@ -22,9 +18,7 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
|||||||
let fitMode: ImageFitMode
|
let fitMode: ImageFitMode
|
||||||
let placeholder: Placeholder
|
let placeholder: Placeholder
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
@StateObject var loader = CachedRemoteImageLoader()
|
@StateObject var loader = CachedRemoteImageLoader()
|
||||||
#endif
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
imageURL: String?,
|
imageURL: String?,
|
||||||
@@ -41,52 +35,23 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
Group {
|
Group {
|
||||||
#if canImport(UIKit)
|
|
||||||
if let uiImage = loader.uiImage {
|
if let uiImage = loader.uiImage {
|
||||||
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
|
rendered(image: Image(uiImage: uiImage), pixelSize: uiImage.size, in: geometry.size)
|
||||||
} else {
|
} else {
|
||||||
placeholder
|
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)
|
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
.clipped()
|
.clipped()
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
loader.load(imageURL: imageURL, ttl: ttl)
|
loader.load(imageURL: imageURL, ttl: ttl)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
.onChange(of: imageURL) { _, newValue in
|
.onChange(of: imageURL) { _, newValue in
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
loader.load(imageURL: newValue, ttl: ttl)
|
loader.load(imageURL: newValue, ttl: ttl)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View {
|
private func rendered(image: Image, pixelSize: CGSize, in containerSize: CGSize) -> some View {
|
||||||
switch fitMode {
|
switch fitMode {
|
||||||
@@ -103,17 +68,11 @@ struct CachedRemoteImage<Placeholder: View>: View {
|
|||||||
.frame(width: containerSize.width, height: containerSize.height)
|
.frame(width: containerSize.width, height: containerSize.height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CachedRemoteImageLoader: ObservableObject {
|
final class CachedRemoteImageLoader: ObservableObject {
|
||||||
#if canImport(UIKit)
|
|
||||||
@Published var uiImage: UIImage?
|
@Published var uiImage: UIImage?
|
||||||
#elseif canImport(AppKit)
|
|
||||||
@Published var nsImage: NSImage?
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private var currentKey: String?
|
private var currentKey: String?
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
@@ -130,50 +89,30 @@ final class CachedRemoteImageLoader: ObservableObject {
|
|||||||
|
|
||||||
task?.cancel()
|
task?.cancel()
|
||||||
|
|
||||||
#if canImport(UIKit)
|
|
||||||
uiImage = nil
|
uiImage = nil
|
||||||
#elseif canImport(AppKit)
|
|
||||||
nsImage = nil
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard let normalized, normalized.isEmpty == false else { return }
|
guard let normalized, normalized.isEmpty == false else { return }
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
let dataCacheKey = Self.dataURLCacheKey(normalized)
|
let dataCacheKey = Self.dataURLCacheKey(normalized)
|
||||||
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
|
if let cachedDataImage: PlatformImage = AppContentCache.shared.value(for: dataCacheKey, as: PlatformImage.self) {
|
||||||
#if canImport(UIKit)
|
|
||||||
uiImage = cachedDataImage
|
uiImage = cachedDataImage
|
||||||
#elseif canImport(AppKit)
|
|
||||||
nsImage = cachedDataImage
|
|
||||||
#endif
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let image = Self.imageFromDataURL(normalized) {
|
if let image = Self.imageFromDataURL(normalized) {
|
||||||
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
|
AppContentCache.shared.set(image, for: dataCacheKey, ttl: ttl)
|
||||||
#if canImport(UIKit)
|
|
||||||
uiImage = image
|
uiImage = image
|
||||||
#elseif canImport(AppKit)
|
|
||||||
nsImage = image
|
|
||||||
#endif
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
guard let url = URL(string: normalized) else { return }
|
guard let url = URL(string: normalized) else { return }
|
||||||
|
|
||||||
task = Task { [weak self] in
|
task = Task { [weak self] in
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
|
let image = await AppImageCache.shared.image(for: url, ttl: ttl)
|
||||||
guard Task.isCancelled == false else { return }
|
guard Task.isCancelled == false else { return }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
#if canImport(UIKit)
|
|
||||||
self?.uiImage = image
|
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)"
|
return "data-image:\(source.count):\(head):\(tail)"
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
|
private static func imageFromDataURL(_ source: String) -> PlatformImage? {
|
||||||
let lower = source.lowercased()
|
let lower = source.lowercased()
|
||||||
guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil }
|
guard lower.hasPrefix("data:image"), let commaIndex = source.firstIndex(of: ",") else { return nil }
|
||||||
@@ -203,14 +141,6 @@ final class CachedRemoteImageLoader: ObservableObject {
|
|||||||
.replacingOccurrences(of: " ", with: "")
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
|
||||||
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
|
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else { return nil }
|
||||||
#if canImport(UIKit)
|
|
||||||
return UIImage(data: data)
|
return UIImage(data: data)
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return NSImage(data: data)
|
|
||||||
#else
|
|
||||||
return nil
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum DeepLinkRouteEffect: Equatable {
|
||||||
|
case navigateToOrder(OrderRouteContext, tab: MainTab)
|
||||||
|
case none
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State var root: RootFlow = .main
|
@State var root: RootFlow = .main
|
||||||
@State var selectedTab: MainTab = .home
|
@State var selectedTab: MainTab = .home
|
||||||
@@ -15,6 +20,7 @@ struct ContentView: View {
|
|||||||
@State private var sessionExpiredObserver: NSObjectProtocol?
|
@State private var sessionExpiredObserver: NSObjectProtocol?
|
||||||
@State var cartResetObserver: Any?
|
@State var cartResetObserver: Any?
|
||||||
@State var appResumeObserver: Any?
|
@State var appResumeObserver: Any?
|
||||||
|
@State var pushDeepLinkObserver: Any?
|
||||||
@StateObject var snackbarCenter = SnackbarCenter.shared
|
@StateObject var snackbarCenter = SnackbarCenter.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -34,7 +40,7 @@ struct ContentView: View {
|
|||||||
if isBootstrappingSession {
|
if isBootstrappingSession {
|
||||||
sessionBootstrapLoadingView
|
sessionBootstrapLoadingView
|
||||||
} else {
|
} 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)
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $appState.activeModal) { modal in
|
.sheet(item: $appState.activeModal, onDismiss: { appState.activeModal = nil }) { modal in
|
||||||
switch modal {
|
switch modal {
|
||||||
case .addressPicker:
|
case .addressPicker:
|
||||||
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
|
AddressPickerModalView(appState: $appState, selectedTab: $selectedTab)
|
||||||
@@ -90,11 +96,13 @@ struct ContentView: View {
|
|||||||
attachCartResetObserverIfNeeded()
|
attachCartResetObserverIfNeeded()
|
||||||
attachAppResumeObserverIfNeeded()
|
attachAppResumeObserverIfNeeded()
|
||||||
attachSessionExpiredObserverIfNeeded()
|
attachSessionExpiredObserverIfNeeded()
|
||||||
|
attachPushDeepLinkObserverIfNeeded()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
detachCartResetObserver()
|
detachCartResetObserver()
|
||||||
detachAppResumeObserver()
|
detachAppResumeObserver()
|
||||||
detachSessionExpiredObserver()
|
detachSessionExpiredObserver()
|
||||||
|
detachPushDeepLinkObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +249,69 @@ struct ContentView: View {
|
|||||||
self.appResumeObserver = nil
|
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
|
@MainActor
|
||||||
private func hydrateAppState(with customer: CustomerProfile) {
|
private func hydrateAppState(with customer: CustomerProfile) {
|
||||||
appState.profile.id = customer.id
|
appState.profile.id = customer.id
|
||||||
@@ -336,6 +407,16 @@ struct ContentView: View {
|
|||||||
isBootstrappingSession = false
|
isBootstrappingSession = false
|
||||||
appState = AppState()
|
appState = AppState()
|
||||||
selectedTab = .home
|
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
|
shouldPrepareAuthEntryAnimation = true
|
||||||
root = .auth
|
root = .auth
|
||||||
authEntryAnimationToken += 1
|
authEntryAnimationToken += 1
|
||||||
49
PediFoods/Coordinators/DeepLinkDestination.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
#elseif canImport(AppKit)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Colors
|
// MARK: - Colors
|
||||||
|
|
||||||
@@ -66,13 +62,7 @@ struct AppTypography {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static var isBrandFontAvailable: Bool {
|
private static var isBrandFontAvailable: Bool {
|
||||||
#if canImport(UIKit)
|
UIFont(name: fontFamily, size: 16) != nil
|
||||||
return UIFont(name: fontFamily, size: 16) != nil
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return NSFont(name: fontFamily, size: 16) != nil
|
|
||||||
#else
|
|
||||||
return false
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
63
PediFoods/PediFoodsApp.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,6 @@
|
|||||||
"strings" : {
|
"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.",
|
"comment" : "A text element displaying a negative monetary value, indicating a discount.",
|
||||||
@@ -89,6 +85,9 @@
|
|||||||
"10km" : {
|
"10km" : {
|
||||||
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
|
"comment" : "A label displayed next to the far end of the distance slider in the filters modal.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Abrir Ajustes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Acompanhamento em tempo real" : {
|
"Acompanhamento em tempo real" : {
|
||||||
|
|
||||||
@@ -148,6 +147,9 @@
|
|||||||
"Adicione produtos para continuar." : {
|
"Adicione produtos para continuar." : {
|
||||||
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
|
"comment" : "A message displayed when the cart is empty, encouraging the user to add products.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Agora não" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"AGUARDANDO PAGAMENTO" : {
|
"AGUARDANDO PAGAMENTO" : {
|
||||||
"comment" : "A status text indicating that the payment is pending.",
|
"comment" : "A status text indicating that the payment is pending.",
|
||||||
@@ -212,6 +214,15 @@
|
|||||||
"Até %lldkm" : {
|
"Até %lldkm" : {
|
||||||
"comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
|
"comment" : "A label displaying the maximum distance filter value in kilometers. The argument is the current maximum distance filter value in kilometers.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ativar" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Ative as notificações" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Ative as notificações para acompanhar em tempo real as atualizações do seu pedido." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Atualizando status do pedido..." : {
|
"Atualizando status do pedido..." : {
|
||||||
|
|
||||||
@@ -296,10 +307,6 @@
|
|||||||
"comment" : "A loading message displayed while bootstrapping the user's session.",
|
"comment" : "A loading message displayed while bootstrapping the user's session.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Carrinho" : {
|
|
||||||
"comment" : "A label for the cart section of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Cartão de Crédito" : {
|
"Cartão de Crédito" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -438,9 +445,6 @@
|
|||||||
"Do que você gostou?" : {
|
"Do que você gostou?" : {
|
||||||
"comment" : "A prompt asking users to mention what they liked about a review.",
|
"comment" : "A prompt asking users to mention what they liked about a review.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Editar endereço" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Em uma escala de 0 a 10, qual é a chance de você indicar o Pedi Foods para um amigo?" : {
|
"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.",
|
"comment" : "A description under the NPS scale question in the order review view.",
|
||||||
@@ -551,38 +555,9 @@
|
|||||||
"comment" : "The title of the checkout screen.",
|
"comment" : "The title of the checkout screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Home" : {
|
||||||
"comment" : "Tab bar item title for the Home tab",
|
"comment" : "Tab bar item title for the Home tab",
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"es" : {
|
"es" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -700,13 +675,6 @@
|
|||||||
},
|
},
|
||||||
"Meus Cartões" : {
|
"Meus Cartões" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Meus Endereços" : {
|
|
||||||
"comment" : "A label displayed at the top of the view.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Meus Pedidos" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Minhas Avaliações" : {
|
"Minhas Avaliações" : {
|
||||||
"comment" : "A button that links to their reviews.",
|
"comment" : "A button that links to their reviews.",
|
||||||
@@ -809,16 +777,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Notificações desativadas" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Novo cartão" : {
|
"Novo cartão" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"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?" : {
|
"Novo por aqui?" : {
|
||||||
"comment" : "A text that appears at the bottom of the screen, inviting users to create an account.",
|
"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" : {
|
"Pague mais rápido nas próximas compras" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Para receber notificações sobre seus pedidos, ative a permissão nos Ajustes do iPhone." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Pedido #%@" : {
|
"Pedido #%@" : {
|
||||||
|
|
||||||
@@ -886,19 +856,11 @@
|
|||||||
},
|
},
|
||||||
"Pedido %@" : {
|
"Pedido %@" : {
|
||||||
|
|
||||||
},
|
|
||||||
"PediFoods" : {
|
|
||||||
"comment" : "The name of the app.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Pedir Novamente" : {
|
"Pedir Novamente" : {
|
||||||
"comment" : "A button that allows a user to order the same items again.",
|
"comment" : "A button that allows a user to order the same items again.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Perfil" : {
|
|
||||||
"comment" : "A label for the \"Profile\" tab in the main tab view.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Pizza de varios sabores" : {
|
"Pizza de varios sabores" : {
|
||||||
"comment" : "A title displayed above the main content of the view.",
|
"comment" : "A title displayed above the main content of the view.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -916,36 +878,6 @@
|
|||||||
"comment" : "The title of the privacy policy section.",
|
"comment" : "The title of the privacy policy section.",
|
||||||
"isCommentAutoGenerated" : true
|
"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?" : {
|
"Precisa de ajuda com esse pedido?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -964,10 +896,6 @@
|
|||||||
"comment" : "A label indicating that a particular address is the user's primary address.",
|
"comment" : "A label indicating that a particular address is the user's primary address.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Privacidade" : {
|
|
||||||
"comment" : "The title of the privacy policy screen.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Progresso do Pedido" : {
|
"Progresso do Pedido" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -975,24 +903,12 @@
|
|||||||
"comment" : "The text for a button that allows a user to re-evaluate an order.",
|
"comment" : "The text for a button that allows a user to re-evaluate an order.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Remover" : {
|
||||||
"comment" : "A button that deletes the user's profile picture.",
|
"comment" : "A button that deletes the user's profile picture.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Remover loja dos favoritos" : {
|
"Remover loja dos favoritos" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Resposta da loja" : {
|
|
||||||
"comment" : "A heading for the reply from the store in an order review.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"RESTAURANT" : {
|
"RESTAURANT" : {
|
||||||
|
|
||||||
@@ -1123,9 +1039,6 @@
|
|||||||
"Seu endereço não é atendido por esta loja.\nDeseja trocar mesmo assim?" : {
|
"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",
|
"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
|
"isCommentAutoGenerated" : true
|
||||||
},
|
|
||||||
"Seu pedido está em andamento" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Sim" : {
|
"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.",
|
"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.",
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(FoundationNetworking)
|
#if canImport(LCEssentials)
|
||||||
import FoundationNetworking
|
|
||||||
#endif
|
|
||||||
#if canImport(LCEssentials) && os(iOS)
|
|
||||||
import LCEssentials
|
import LCEssentials
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -45,7 +42,7 @@ enum NetworkError: Error, LocalizedError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ApiErrorDescriptor {
|
struct ApiErrorDescriptor {
|
||||||
let code: String?
|
let code: String?
|
||||||
let message: String?
|
let message: String?
|
||||||
}
|
}
|
||||||
@@ -102,7 +99,7 @@ final class ApiClient: @unchecked Sendable {
|
|||||||
// resolving — no response, no error, nothing — the UI must still
|
// resolving — no response, no error, nothing — the UI must still
|
||||||
// get an answer so it can stop showing "nothing happened."
|
// get an answer so it can stop showing "nothing happened."
|
||||||
try await withTimeout(seconds: 20) { [self] in
|
try await withTimeout(seconds: 20) { [self] in
|
||||||
#if canImport(LCEssentials) && os(iOS)
|
#if canImport(LCEssentials)
|
||||||
try await sendWithLCEssentials(request)
|
try await sendWithLCEssentials(request)
|
||||||
#else
|
#else
|
||||||
try await sendWithURLSession(request)
|
try await sendWithURLSession(request)
|
||||||
@@ -128,8 +125,8 @@ final class ApiClient: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ApiClient {
|
extension ApiClient {
|
||||||
#if canImport(LCEssentials) && os(iOS)
|
#if canImport(LCEssentials)
|
||||||
func sendWithLCEssentials<T: Decodable>(_ request: ApiRequest) async throws -> T {
|
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 urlString = try buildURL(path: request.path, query: request.queryItems, baseURL: request.baseURLOverride ?? ApiConfig.baseURL).absoluteString
|
||||||
let method = request.method
|
let method = request.method
|
||||||
@@ -402,7 +399,7 @@ private extension ApiClient {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
#if canImport(LCEssentials) && os(iOS)
|
#if canImport(LCEssentials)
|
||||||
func serverMessage(from error: NSError) -> String? {
|
func serverMessage(from error: NSError) -> String? {
|
||||||
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
|
for key in [NSLocalizedFailureReasonErrorKey, "body", "responseBody", "data", "message"] {
|
||||||
if let value = error.userInfo[key] as? String,
|
if let value = error.userInfo[key] as? String,
|
||||||
@@ -38,6 +38,53 @@ struct CustomerIdentityUpdatePayload: Encodable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `POST /api/customer/:id` — partial update, sibling of `CustomerProfileUpdatePayload`.
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §2b.
|
||||||
|
struct CustomerNotificationsUpdatePayload: Encodable {
|
||||||
|
let notificationsEnabled: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/customer/:id` — biometric-login preference. Persistence only for
|
||||||
|
/// now; the actual Face ID/Touch ID unlock flow is a separate, later plan.
|
||||||
|
struct CustomerFaceIdUpdatePayload: Encodable {
|
||||||
|
let faceIdEnabled: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /api/customer/:id/push-token` — see docs/api/push-notifications-integration-guide.md §2.
|
||||||
|
struct CustomerPushTokenPayload: Encodable {
|
||||||
|
let pushToken: String
|
||||||
|
let deviceId: String
|
||||||
|
let deviceOS: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PUT /api/customer/:id/attributes` — wholesale replace, not a merge.
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §2a.
|
||||||
|
struct CustomerAttributesUpdatePayload: Encodable {
|
||||||
|
let appVersion: String?
|
||||||
|
let attributes: [String: String]?
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(appVersion, forKey: .appVersion)
|
||||||
|
try container.encodeIfPresent(attributes, forKey: .attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case appVersion
|
||||||
|
case attributes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/customer/:id/push-campaigns/opened` — see
|
||||||
|
/// docs/api/push-notifications-integration-guide.md §6a.
|
||||||
|
struct PushCampaignOpenedPayload: Encodable {
|
||||||
|
let campaignId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PushCampaignOpenedResult: Decodable {
|
||||||
|
let recorded: Bool
|
||||||
|
}
|
||||||
|
|
||||||
struct CustomerAddressPayload: Encodable {
|
struct CustomerAddressPayload: Encodable {
|
||||||
let label: String?
|
let label: String?
|
||||||
let address: String?
|
let address: String?
|
||||||
@@ -30,6 +30,8 @@ struct CustomerProfile: Decodable {
|
|||||||
let profilePicture: String?
|
let profilePicture: String?
|
||||||
let favorites: [String]?
|
let favorites: [String]?
|
||||||
let addressBook: [CustomerAddress]?
|
let addressBook: [CustomerAddress]?
|
||||||
|
let notificationsEnabled: Bool?
|
||||||
|
let faceIdEnabled: Bool?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
@@ -39,6 +41,8 @@ struct CustomerProfile: Decodable {
|
|||||||
case profilePicture
|
case profilePicture
|
||||||
case favorites
|
case favorites
|
||||||
case addressBook = "address_book"
|
case addressBook = "address_book"
|
||||||
|
case notificationsEnabled
|
||||||
|
case faceIdEnabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +88,17 @@ final class ApiService {
|
|||||||
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
AppContentCache.shared.invalidate(prefix: favoritesCachePrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every successful profile mutation must leave `profile()`'s cache
|
||||||
|
/// holding the server's authoritative post-mutation state — never
|
||||||
|
/// patched locally from a write-response of possibly different shape,
|
||||||
|
/// and never left merely invalidated for some future caller to lazily
|
||||||
|
/// refetch (which may never happen, leaving stale data visible
|
||||||
|
/// indefinitely within the TTL). Always does a real GET.
|
||||||
|
@discardableResult
|
||||||
|
private func refreshProfileCache() async -> ApiEnvelope<CustomerProfile>? {
|
||||||
|
try? await profile(forceRefresh: true)
|
||||||
|
}
|
||||||
|
|
||||||
private func scopedCacheSuffix() -> String {
|
private func scopedCacheSuffix() -> String {
|
||||||
let jwt = tokenStore.jwt ?? "anonymous"
|
let jwt = tokenStore.jwt ?? "anonymous"
|
||||||
if jwt.count <= 16 { return jwt }
|
if jwt.count <= 16 { return jwt }
|
||||||
@@ -184,10 +195,90 @@ final class ApiService {
|
|||||||
let body = try JSONEncoder().encode(payload)
|
let body = try JSONEncoder().encode(payload)
|
||||||
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||||
let envelope: ProfilePatchEnvelope = try await send(req)
|
let envelope: ProfilePatchEnvelope = try await send(req)
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
if envelope.error == false {
|
||||||
|
await refreshProfileCache()
|
||||||
|
}
|
||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §2b — only reachable
|
||||||
|
/// via `POST /api/customer/:id` today, not `PATCH /profile`.
|
||||||
|
func updateNotificationsEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = CustomerNotificationsUpdatePayload(notificationsEnabled: enabled)
|
||||||
|
let body = try JSONEncoder().encode(payload)
|
||||||
|
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||||
|
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
await refreshProfileCache()
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists the biometric-login preference only — no LocalAuthentication
|
||||||
|
/// wiring yet, that's a separate later plan.
|
||||||
|
func updateFaceIdEnabled(_ enabled: Bool) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = CustomerFaceIdUpdatePayload(faceIdEnabled: enabled)
|
||||||
|
let body = try JSONEncoder().encode(payload)
|
||||||
|
let req = ApiRequest(path: "/api/customer/\(customer.id)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||||
|
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||||
|
if envelope.error == false {
|
||||||
|
await refreshProfileCache()
|
||||||
|
}
|
||||||
|
return envelope
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §2.
|
||||||
|
func registerPushToken(_ token: String, deviceId: String, deviceOS: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||||
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = CustomerPushTokenPayload(pushToken: token, deviceId: deviceId, deviceOS: deviceOS)
|
||||||
|
let body = try JSONEncoder().encode(payload)
|
||||||
|
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-token", method: "PUT", module: .customer, requiresAuth: true, body: body)
|
||||||
|
return try await sendEnvelope(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §2a. Wholesale
|
||||||
|
/// replace, not a merge — callers must pass every `attributes` key they
|
||||||
|
/// still want kept, not just the changed ones.
|
||||||
|
func updateCustomerAttributes(appVersion: String?, attributes: [String: String]?) async throws -> ApiEnvelope<EmptyResult> {
|
||||||
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = CustomerAttributesUpdatePayload(appVersion: appVersion, attributes: attributes)
|
||||||
|
let body = try JSONEncoder().encode(payload)
|
||||||
|
let req = ApiRequest(path: "/api/customer/\(customer.id)/attributes", method: "PUT", module: .customer, requiresAuth: true, body: body)
|
||||||
|
return try await sendEnvelope(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §6a. Fire on tap
|
||||||
|
/// only, for `type: "campaign"` pushes — idempotent server-side.
|
||||||
|
func reportPushCampaignOpened(campaignId: String) async throws -> ApiEnvelope<PushCampaignOpenedResult> {
|
||||||
|
let currentProfile = try await profile(forceRefresh: true)
|
||||||
|
guard currentProfile.error == false, let customer = currentProfile.result else {
|
||||||
|
throw NetworkError.invalidResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = PushCampaignOpenedPayload(campaignId: campaignId)
|
||||||
|
let body = try JSONEncoder().encode(payload)
|
||||||
|
let req = ApiRequest(path: "/api/customer/\(customer.id)/push-campaigns/opened", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||||
|
return try await sendEnvelope(req)
|
||||||
|
}
|
||||||
|
|
||||||
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
func addCustomerAddress(_ address: CustomerAddress) async throws -> ApiEnvelope<CustomerProfile> {
|
||||||
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
return try await saveCustomerAddress(address, replacingAddressId: nil)
|
||||||
}
|
}
|
||||||
@@ -234,7 +325,9 @@ final class ApiService {
|
|||||||
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
func setDefaultAddress(addressId: String) async throws -> ApiEnvelope<EmptyResult> {
|
||||||
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
let req = ApiRequest(path: "/api/customer/addresses/\(addressId)/default", method: "PATCH", module: .customer, requiresAuth: true)
|
||||||
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
let envelope: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
if envelope.error == false {
|
||||||
|
await refreshProfileCache()
|
||||||
|
}
|
||||||
return envelope
|
return envelope
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,9 +352,8 @@ final class ApiService {
|
|||||||
let body = try JSONEncoder().encode(payload)
|
let body = try JSONEncoder().encode(payload)
|
||||||
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/\(customerId)", method: "POST", module: .customer, requiresAuth: true, body: body)
|
||||||
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
let envelope: ApiEnvelope<CustomerProfile> = try await sendEnvelope(req)
|
||||||
let scopedProfileKey = "\(profileCachePrefix)\(scopedCacheSuffix())"
|
if envelope.error == false {
|
||||||
if envelope.error == false, envelope.result != nil {
|
await refreshProfileCache()
|
||||||
AppContentCache.shared.set(envelope, for: scopedProfileKey, ttl: AppCacheTTL.twoHours)
|
|
||||||
} else {
|
} else {
|
||||||
invalidateFavoritesCache()
|
invalidateFavoritesCache()
|
||||||
}
|
}
|
||||||
@@ -488,7 +580,7 @@ final class ApiService {
|
|||||||
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
let req = ApiRequest(path: "/api/customer/profile", method: "PATCH", module: .customer, requiresAuth: true, body: body)
|
||||||
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
let result: ApiEnvelope<EmptyResult> = try await sendEnvelope(req)
|
||||||
if result.error == false {
|
if result.error == false {
|
||||||
AppContentCache.shared.invalidate(prefix: profileCachePrefix)
|
await refreshProfileCache()
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(UIKit)
|
|
||||||
import UIKit
|
import UIKit
|
||||||
typealias PlatformImage = UIImage
|
typealias PlatformImage = UIImage
|
||||||
#elseif canImport(AppKit)
|
|
||||||
import AppKit
|
|
||||||
typealias PlatformImage = NSImage
|
|
||||||
#endif
|
|
||||||
|
|
||||||
enum AppCacheTTL {
|
enum AppCacheTTL {
|
||||||
static let twoHours: TimeInterval = 2 * 60 * 60
|
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 {
|
final class AppImageCache: @unchecked Sendable {
|
||||||
static let shared = AppImageCache()
|
static let shared = AppImageCache()
|
||||||
|
|
||||||
@@ -132,23 +126,6 @@ final class AppImageCache: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func platformImage(from data: Data) -> PlatformImage? {
|
private func platformImage(from data: Data) -> PlatformImage? {
|
||||||
#if canImport(UIKit)
|
UIImage(data: data)
|
||||||
return UIImage(data: data)
|
|
||||||
#elseif canImport(AppKit)
|
|
||||||
return NSImage(data: data)
|
|
||||||
#else
|
|
||||||
return nil
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
#if !(canImport(UIKit) || canImport(AppKit))
|
|
||||||
final class AppImageCache: @unchecked Sendable {
|
|
||||||
static let shared = AppImageCache()
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func invalidateAll() {}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(FoundationNetworking)
|
|
||||||
import FoundationNetworking
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct FeatureControlRawFlag: Codable, Equatable {
|
struct FeatureControlRawFlag: Codable, Equatable {
|
||||||
let enabled: Bool
|
let enabled: Bool
|
||||||
@@ -271,13 +268,11 @@ final class FeatureControlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func appVersion() -> String {
|
private func appVersion() -> String {
|
||||||
#if canImport(UIKit) || canImport(AppKit)
|
|
||||||
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
|
let version = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let version, version.isEmpty == false {
|
if let version, version.isEmpty == false {
|
||||||
return version
|
return version
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
return "0.0.0"
|
return "0.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,25 @@ import Foundation
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import CoreLocation
|
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
|
#endif
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -23,17 +42,22 @@ final class LocationService: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private let manager = CLLocationManager()
|
private let manager: LocationManaging
|
||||||
private var completion: ((LocationResult) -> Void)?
|
private var completion: ((LocationResult) -> Void)?
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
init(manager: LocationManaging = CLLocationManager()) {
|
||||||
|
self.manager = manager
|
||||||
|
super.init()
|
||||||
|
manager.locationManagingDelegate = self
|
||||||
|
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
}
|
||||||
|
#else
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
#if os(iOS)
|
|
||||||
manager.delegate = self
|
|
||||||
manager.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
|
func requestLocation(_ completion: @escaping (LocationResult) -> Void) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
#if canImport(FoundationNetworking)
|
|
||||||
import FoundationNetworking
|
|
||||||
#endif
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OrderRealtimeTracker {
|
final class OrderRealtimeTracker {
|
||||||
220
PediFoods/Services/PushNotificationCoordinator.swift
Normal 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
|
||||||
@@ -80,6 +80,12 @@ private struct PersistedOrderReviewsState: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SessionStateStore {
|
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 legacyAddressKey = "session.address.state.v1"
|
||||||
private static let addressKeyPrefix = "session.address.state.v2."
|
private static let addressKeyPrefix = "session.address.state.v2."
|
||||||
private static let activeUserKey = "session.active.user.v1"
|
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 pendingCartOrderKeyPrefix = "session.cart.pending-order.v1."
|
||||||
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
private static let orderReviewsKeyPrefix = "session.orders.reviews.v1."
|
||||||
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
|
private static let orderReviewDraftKeyPrefix = "session.orders.review-draft.v1."
|
||||||
|
private static let pushOptInPromptKey = "session.push.opt-in.last-prompted.v1"
|
||||||
|
private static let pushOptInCooldown: TimeInterval = 60 * 60 * 24
|
||||||
|
|
||||||
|
/// See docs/api/push-notifications-integration-guide.md §2b — avoid
|
||||||
|
/// re-prompting the order-tracking fallback alert on every screen visit.
|
||||||
|
static func shouldPromptPushOptIn() -> Bool {
|
||||||
|
guard let last = 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? {
|
static func makeUserKey(profileId: String?, email: String?) -> String? {
|
||||||
let id = (profileId ?? "")
|
let id = (profileId ?? "")
|
||||||
@@ -110,14 +129,14 @@ enum SessionStateStore {
|
|||||||
static func setActiveUserKey(_ userKey: String?) {
|
static func setActiveUserKey(_ userKey: String?) {
|
||||||
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (userKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
UserDefaults.standard.removeObject(forKey: activeUserKey)
|
defaults.removeObject(forKey: activeUserKey)
|
||||||
} else {
|
} else {
|
||||||
UserDefaults.standard.set(trimmed, forKey: activeUserKey)
|
defaults.set(trimmed, forKey: activeUserKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadActiveUserKey() -> String? {
|
static func loadActiveUserKey() -> String? {
|
||||||
let value = UserDefaults.standard.string(forKey: activeUserKey)?
|
let value = defaults.string(forKey: activeUserKey)?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let value, value.isEmpty == false {
|
if let value, value.isEmpty == false {
|
||||||
return value
|
return value
|
||||||
@@ -133,7 +152,6 @@ enum SessionStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadAddress() -> AddressState? {
|
static func loadAddress() -> AddressState? {
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
let activeKey = loadActiveUserKey()
|
let activeKey = loadActiveUserKey()
|
||||||
let scopedKey = addressStorageKey(for: activeKey)
|
let scopedKey = addressStorageKey(for: activeKey)
|
||||||
|
|
||||||
@@ -201,17 +219,16 @@ enum SessionStateStore {
|
|||||||
longitude: state.longitude
|
longitude: state.longitude
|
||||||
)
|
)
|
||||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
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() {
|
static func clearAddress() {
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
defaults.removeObject(forKey: addressStorageKey(for: nil))
|
defaults.removeObject(forKey: addressStorageKey(for: nil))
|
||||||
defaults.removeObject(forKey: legacyAddressKey)
|
defaults.removeObject(forKey: legacyAddressKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func clearActiveUser() {
|
static func clearActiveUser() {
|
||||||
UserDefaults.standard.removeObject(forKey: activeUserKey)
|
defaults.removeObject(forKey: activeUserKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func cartStorageKey(for userKey: String?) -> String {
|
private static func cartStorageKey(for userKey: String?) -> String {
|
||||||
@@ -222,7 +239,6 @@ enum SessionStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadCart() -> CartState? {
|
static func loadCart() -> CartState? {
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
let key = cartStorageKey(for: nil)
|
let key = cartStorageKey(for: nil)
|
||||||
|
|
||||||
if let data = defaults.data(forKey: key),
|
if let data = defaults.data(forKey: key),
|
||||||
@@ -284,11 +300,11 @@ enum SessionStateStore {
|
|||||||
total: state.total
|
total: state.total
|
||||||
)
|
)
|
||||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
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() {
|
static func clearCart() {
|
||||||
UserDefaults.standard.removeObject(forKey: cartStorageKey(for: nil))
|
defaults.removeObject(forKey: cartStorageKey(for: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func trackedOrdersStorageKey(for userKey: String?) -> String {
|
private static func trackedOrdersStorageKey(for userKey: String?) -> String {
|
||||||
@@ -323,7 +339,6 @@ enum SessionStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadTrackedOrders() -> [PublicOrderResult] {
|
static func loadTrackedOrders() -> [PublicOrderResult] {
|
||||||
let defaults = UserDefaults.standard
|
|
||||||
let key = trackedOrdersStorageKey(for: nil)
|
let key = trackedOrdersStorageKey(for: nil)
|
||||||
guard let data = defaults.data(forKey: key),
|
guard let data = defaults.data(forKey: key),
|
||||||
let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else {
|
let decoded = try? JSONDecoder().decode(PersistedTrackedOrdersState.self, from: data) else {
|
||||||
@@ -348,11 +363,11 @@ enum SessionStateStore {
|
|||||||
}
|
}
|
||||||
let payload = PersistedTrackedOrdersState(orders: orders)
|
let payload = PersistedTrackedOrdersState(orders: orders)
|
||||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
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() {
|
static func clearTrackedOrders() {
|
||||||
UserDefaults.standard.removeObject(forKey: trackedOrdersStorageKey(for: nil))
|
defaults.removeObject(forKey: trackedOrdersStorageKey(for: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func savePendingCartOrderId(_ orderId: String) {
|
static func savePendingCartOrderId(_ orderId: String) {
|
||||||
@@ -361,11 +376,11 @@ enum SessionStateStore {
|
|||||||
clearPendingCartOrder()
|
clearPendingCartOrder()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
UserDefaults.standard.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
|
defaults.set(clean, forKey: pendingCartOrderStorageKey(for: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadPendingCartOrderId() -> String? {
|
static func loadPendingCartOrderId() -> String? {
|
||||||
let value = UserDefaults.standard.string(forKey: pendingCartOrderStorageKey(for: nil))?
|
let value = defaults.string(forKey: pendingCartOrderStorageKey(for: nil))?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let value, value.isEmpty == false {
|
if let value, value.isEmpty == false {
|
||||||
return value
|
return value
|
||||||
@@ -374,12 +389,12 @@ enum SessionStateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func clearPendingCartOrder() {
|
static func clearPendingCartOrder() {
|
||||||
UserDefaults.standard.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
|
defaults.removeObject(forKey: pendingCartOrderStorageKey(for: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadOrderReviews() -> [OrderReviewRecord] {
|
static func loadOrderReviews() -> [OrderReviewRecord] {
|
||||||
let key = orderReviewsStorageKey(for: nil)
|
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 {
|
let decoded = try? JSONDecoder().decode(PersistedOrderReviewsState.self, from: data) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -416,12 +431,12 @@ enum SessionStateStore {
|
|||||||
|
|
||||||
let payload = PersistedOrderReviewsState(reviews: reviews)
|
let payload = PersistedOrderReviewsState(reviews: reviews)
|
||||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
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? {
|
static func loadOrderReviewDraft(orderId: String) -> OrderReviewDraftState? {
|
||||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
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 {
|
let draft = try? JSONDecoder().decode(OrderReviewDraftState.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -431,11 +446,11 @@ enum SessionStateStore {
|
|||||||
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
|
static func saveOrderReviewDraft(_ draft: OrderReviewDraftState) {
|
||||||
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
|
let key = orderReviewDraftStorageKey(for: draft.orderId, userKey: nil)
|
||||||
guard let data = try? JSONEncoder().encode(draft) else { return }
|
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) {
|
static func clearOrderReviewDraft(orderId: String) {
|
||||||
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
let key = orderReviewDraftStorageKey(for: orderId, userKey: nil)
|
||||||
UserDefaults.standard.removeObject(forKey: key)
|
defaults.removeObject(forKey: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||