Compare commits
3 Commits
052e4715dc
...
658423ee16
| Author | SHA1 | Date | |
|---|---|---|---|
| 658423ee16 | |||
| fb9a810262 | |||
| ab8ef49ddb |
@ -1,9 +1,12 @@
|
|||||||
Use /ios-18-role
|
Use /ios-18-role
|
||||||
read the PRD.md
|
read the PRD.md
|
||||||
read the README.md
|
read the README.md
|
||||||
|
read Bedrock/README.md
|
||||||
|
|
||||||
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
Always update the PRD.md and README.md when there are code changes that might cause these files to require those changes documented.
|
||||||
|
|
||||||
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
|
Always try to build after coding to ensure no build errors exist and use the iPhone 17 Pro Max using 26.2 simulator.
|
||||||
|
|
||||||
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
Try and use xcode build mcp if it is working and test using screenshots when asked.
|
||||||
|
|
||||||
|
Make sure for UI you are using the Bedrock framework reusable components and Typography where it is needed. Read the REA
|
||||||
@ -31,9 +31,9 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfieCam.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Selfie Cam.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCamTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Selfie Cam.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCamUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Selfie Cam.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EACONFIG002 /* SelfieCam/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieCam/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
EACONFIG002 /* SelfieCam/Configuration/Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieCam/Configuration/Debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
EACONFIG003 /* SelfieCam/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieCam/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
EACONFIG003 /* SelfieCam/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieCam/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -99,9 +99,9 @@
|
|||||||
EA836AC02F0ACE8A00077F87 /* Products */ = {
|
EA836AC02F0ACE8A00077F87 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */,
|
EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */,
|
||||||
EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */,
|
EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */,
|
||||||
EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */,
|
EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -141,7 +141,7 @@
|
|||||||
EA836AF52F0AD00000077F87 /* MijickCamera */,
|
EA836AF52F0AD00000077F87 /* MijickCamera */,
|
||||||
);
|
);
|
||||||
productName = SelfieCam;
|
productName = SelfieCam;
|
||||||
productReference = EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */;
|
productReference = EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */ = {
|
EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */ = {
|
||||||
@ -164,7 +164,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = SelfieCamTests;
|
productName = SelfieCamTests;
|
||||||
productReference = EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */;
|
productReference = EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
};
|
};
|
||||||
EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */ = {
|
EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */ = {
|
||||||
@ -187,7 +187,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
);
|
);
|
||||||
productName = SelfieCamUITests;
|
productName = SelfieCamUITests;
|
||||||
productReference = EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */;
|
productReference = EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
};
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
@ -436,9 +436,14 @@
|
|||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)";
|
||||||
|
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
|
||||||
|
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
|
||||||
|
INFOPLIST_KEY_PublicAppName = "$(PRODUCT_NAME)";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
@ -451,7 +456,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
@ -473,9 +478,14 @@
|
|||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)";
|
||||||
|
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
|
||||||
|
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
|
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
|
||||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
|
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
|
||||||
|
INFOPLIST_KEY_PublicAppName = "$(PRODUCT_NAME)";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||||
@ -488,7 +498,7 @@
|
|||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
@ -510,7 +520,7 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(TESTS_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(TESTS_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@ -532,7 +542,7 @@
|
|||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(TESTS_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(TESTS_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@ -552,7 +562,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(UITESTS_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(UITESTS_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@ -572,7 +582,7 @@
|
|||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(UITESTS_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(UITESTS_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
|||||||
Binary file not shown.
@ -16,7 +16,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "EA836ABE2F0ACE8A00077F87"
|
BlueprintIdentifier = "EA836ABE2F0ACE8A00077F87"
|
||||||
BuildableName = "SelfieCam.app"
|
BuildableName = "Selfie Cam.app"
|
||||||
BlueprintName = "SelfieCam"
|
BlueprintName = "SelfieCam"
|
||||||
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@ -36,7 +36,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "EA836ACB2F0ACE8B00077F87"
|
BlueprintIdentifier = "EA836ACB2F0ACE8B00077F87"
|
||||||
BuildableName = "SelfieCamTests.xctest"
|
BuildableName = "Selfie Cam.xctest"
|
||||||
BlueprintName = "SelfieCamTests"
|
BlueprintName = "SelfieCamTests"
|
||||||
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "EA836AD52F0ACE8B00077F87"
|
BlueprintIdentifier = "EA836AD52F0ACE8B00077F87"
|
||||||
BuildableName = "SelfieCamUITests.xctest"
|
BuildableName = "Selfie Cam.xctest"
|
||||||
BlueprintName = "SelfieCamUITests"
|
BlueprintName = "SelfieCamUITests"
|
||||||
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@ -69,7 +69,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "EA836ABE2F0ACE8A00077F87"
|
BlueprintIdentifier = "EA836ABE2F0ACE8A00077F87"
|
||||||
BuildableName = "SelfieCam.app"
|
BuildableName = "Selfie Cam.app"
|
||||||
BlueprintName = "SelfieCam"
|
BlueprintName = "SelfieCam"
|
||||||
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@ -93,7 +93,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "EA836ABE2F0ACE8A00077F87"
|
BlueprintIdentifier = "EA836ABE2F0ACE8A00077F87"
|
||||||
BuildableName = "SelfieCam.app"
|
BuildableName = "Selfie Cam.app"
|
||||||
BlueprintName = "SelfieCam"
|
BlueprintName = "SelfieCam"
|
||||||
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
ReferencedContainer = "container:SelfieCam.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Bucket
|
||||||
|
uuid = "255E4A64-2C12-4DF6-8E9E-7F7055D5E53E"
|
||||||
|
type = "1"
|
||||||
|
version = "2.0">
|
||||||
|
<Breakpoints>
|
||||||
|
<BreakpointProxy
|
||||||
|
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
|
||||||
|
<BreakpointContent
|
||||||
|
uuid = "EF22C3D9-6D4C-437B-95AC-E5F014FCAF06"
|
||||||
|
shouldBeEnabled = "No"
|
||||||
|
ignoreCount = "0"
|
||||||
|
continueAfterRunningActions = "No"
|
||||||
|
filePath = "../../../../../Library/Developer/Xcode/DerivedData/SelfieCam-emlnelscqiocmsdsgdvcsudobyxe/SourcePackages/checkouts/MijickCamera/Sources/Internal/UI/Camera View/CameraView+Metal.swift"
|
||||||
|
startingColumnNumber = "9223372036854775807"
|
||||||
|
endingColumnNumber = "9223372036854775807"
|
||||||
|
startingLineNumber = "147"
|
||||||
|
endingLineNumber = "147"
|
||||||
|
landmarkName = "beginCameraFlipAnimation()"
|
||||||
|
landmarkType = "7">
|
||||||
|
</BreakpointContent>
|
||||||
|
</BreakpointProxy>
|
||||||
|
</Breakpoints>
|
||||||
|
</Bucket>
|
||||||
56
SelfieCam/App/RootView.swift
Normal file
56
SelfieCam/App/RootView.swift
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// RootView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Root view that manages the app's main navigation flow.
|
||||||
|
// Shows onboarding on first launch, then the main camera view.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
/// Persistent flag for onboarding completion
|
||||||
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||||
|
|
||||||
|
/// Settings view model shared between onboarding and main app
|
||||||
|
@State private var settingsViewModel = SettingsViewModel()
|
||||||
|
|
||||||
|
/// Onboarding view model
|
||||||
|
@State private var onboardingViewModel = OnboardingViewModel()
|
||||||
|
|
||||||
|
/// Whether to show the paywall (shared between views)
|
||||||
|
@State private var showPaywall = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if hasCompletedOnboarding {
|
||||||
|
// Main app content
|
||||||
|
ContentView()
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
} else {
|
||||||
|
// Onboarding flow
|
||||||
|
OnboardingContainerView(
|
||||||
|
viewModel: onboardingViewModel,
|
||||||
|
settingsViewModel: settingsViewModel,
|
||||||
|
showPaywall: $showPaywall,
|
||||||
|
onComplete: {
|
||||||
|
withAnimation(.easeInOut(duration: Design.Animation.standard)) {
|
||||||
|
hasCompletedOnboarding = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showPaywall) {
|
||||||
|
ProPaywallView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Onboarding") {
|
||||||
|
RootView()
|
||||||
|
}
|
||||||
@ -12,6 +12,15 @@ import Bedrock
|
|||||||
struct SelfieCamApp: App {
|
struct SelfieCamApp: App {
|
||||||
init() {
|
init() {
|
||||||
Design.showDebugLogs = true
|
Design.showDebugLogs = true
|
||||||
|
|
||||||
|
// Register SelfieCam theme with Bedrock
|
||||||
|
Theme.register(
|
||||||
|
text: AppTextColors.self,
|
||||||
|
surface: AppSurface.self,
|
||||||
|
accent: AppAccent.self,
|
||||||
|
status: AppStatus.self
|
||||||
|
)
|
||||||
|
Theme.register(border: AppBorder.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
@ -21,8 +30,7 @@ struct SelfieCamApp: App {
|
|||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
AppLaunchView(config: .selfieCam) {
|
AppLaunchView(config: .selfieCam) {
|
||||||
ContentView()
|
RootView()
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
45
SelfieCam/Configuration/AppIdentifiers.swift
Normal file
45
SelfieCam/Configuration/AppIdentifiers.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppIdentifiers {
|
||||||
|
// Read from Info.plist (values come from xcconfig)
|
||||||
|
static let publicAppName: String = {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "PublicAppName") as? String
|
||||||
|
?? "SelfieCam"
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let appGroupIdentifier: String = {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
|
||||||
|
?? "group.com.mbrucedogs.SelfieCam"
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let cloudKitContainerIdentifier: String = {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
|
||||||
|
?? "iCloud.com.mbrucedogs.SelfieCam"
|
||||||
|
}()
|
||||||
|
|
||||||
|
static let appClipDomain: String = {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String
|
||||||
|
?? "yourapp.example.com"
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Derived from bundle identifier
|
||||||
|
static var bundleIdentifier: String {
|
||||||
|
Bundle.main.bundleIdentifier ?? "com.mbrucedogs.SelfieCam"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var watchBundleIdentifier: String {
|
||||||
|
"\(bundleIdentifier).watchkitapp"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var appClipBundleIdentifier: String {
|
||||||
|
"\(bundleIdentifier).Clip"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var widgetBundleIdentifier: String {
|
||||||
|
"\(bundleIdentifier).Widget"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func appClipURL(recordName: String) -> URL? {
|
||||||
|
URL(string: "https://\(appClipDomain)/appclip?id=\(recordName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,13 +6,23 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
COMPANY_IDENTIFIER = com.mbrucedogs
|
COMPANY_IDENTIFIER = com.mbrucedogs
|
||||||
APP_NAME = SelfieCam
|
BUNDLE_ID_NAME = SelfieCam
|
||||||
|
PRODUCT_NAME = Selfie Cam
|
||||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ
|
DEVELOPMENT_TEAM = 6R7KLBPBLZ
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// DERIVED IDENTIFIERS - DO NOT EDIT
|
// DERIVED IDENTIFIERS - DO NOT EDIT
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)
|
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
|
||||||
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests
|
WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp
|
||||||
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
|
APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip
|
||||||
|
WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Widget
|
||||||
|
INTENT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Intent
|
||||||
|
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)Tests
|
||||||
|
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)UITests
|
||||||
|
|
||||||
|
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
|
||||||
|
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
|
||||||
|
|
||||||
|
APPCLIP_DOMAIN = yourapp.example.com
|
||||||
|
|||||||
@ -188,16 +188,16 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Camera Container View
|
// MARK: - Camera Container View
|
||||||
/// Wrapper view for MCamera - only recreates when sessionKey changes
|
/// Wrapper view for MCamera - only recreates when sessionKey changes
|
||||||
struct CameraContainerView: View, Equatable {
|
struct CameraContainerView: View, Equatable {
|
||||||
let settings: SettingsViewModel
|
let settings: SettingsViewModel
|
||||||
let sessionKey: UUID
|
let sessionKey: UUID
|
||||||
let cameraPosition: CameraPosition
|
let cameraPosition: CameraPosition
|
||||||
let onImageCaptured: (UIImage) -> Void
|
let onImageCaptured: (UIImage) -> Void
|
||||||
|
|
||||||
// Only compare sessionKey and cameraPosition for equality
|
// Only compare sessionKey for equality - we want to handle position changes via runtime setCameraPosition
|
||||||
static func == (lhs: CameraContainerView, rhs: CameraContainerView) -> Bool {
|
static func == (lhs: CameraContainerView, rhs: CameraContainerView) -> Bool {
|
||||||
lhs.sessionKey == rhs.sessionKey && lhs.cameraPosition == rhs.cameraPosition
|
lhs.sessionKey == rhs.sessionKey
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@ -320,17 +320,17 @@ struct CustomCameraScreen: MCameraScreen {
|
|||||||
// Initial haptic for countdown start
|
// Initial haptic for countdown start
|
||||||
triggerHaptic(.light)
|
triggerHaptic(.light)
|
||||||
|
|
||||||
Task { @MainActor in
|
// Use a Timer to ensure we update on the main thread every second
|
||||||
while countdownSeconds > 0 {
|
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
|
||||||
try? await Task.sleep(for: .seconds(1))
|
if self.countdownSeconds > 1 {
|
||||||
countdownSeconds -= 1
|
self.countdownSeconds -= 1
|
||||||
// Haptic tick for each countdown second
|
self.triggerHaptic(.light)
|
||||||
triggerHaptic(.light)
|
} else {
|
||||||
|
timer.invalidate()
|
||||||
|
self.countdownSeconds = 0
|
||||||
|
self.isCountdownActive = false
|
||||||
|
self.performActualCapture()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Countdown finished, perform capture
|
|
||||||
isCountdownActive = false
|
|
||||||
performActualCapture()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -542,16 +542,8 @@ struct CustomCameraScreen: MCameraScreen {
|
|||||||
let newPosition: CameraPosition = cameraPosition == .front ? .back : .front
|
let newPosition: CameraPosition = cameraPosition == .front ? .back : .front
|
||||||
Design.debugLog("Double-tap: flipping camera to \(newPosition)")
|
Design.debugLog("Double-tap: flipping camera to \(newPosition)")
|
||||||
|
|
||||||
Task {
|
// Update settings to persist the change - this will trigger the onChange in CustomCameraScreen
|
||||||
do {
|
cameraSettings.cameraPosition = newPosition
|
||||||
try await setCameraPosition(newPosition)
|
|
||||||
// Update settings to persist the change
|
|
||||||
cameraSettings.cameraPosition = newPosition
|
|
||||||
currentCameraPosition = newPosition
|
|
||||||
} catch {
|
|
||||||
Design.debugLog("Failed to flip camera: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Haptic Feedback
|
// MARK: - Haptic Feedback
|
||||||
|
|||||||
@ -0,0 +1,301 @@
|
|||||||
|
//
|
||||||
|
// OnboardingComponents.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Shared UI components for the onboarding flow.
|
||||||
|
// Uses Bedrock design system for consistency.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Layout Constants
|
||||||
|
|
||||||
|
/// Layout constants for onboarding screens
|
||||||
|
enum OnboardingLayout {
|
||||||
|
/// Maximum content width for iPad/landscape (prevents content from stretching too wide)
|
||||||
|
static let maxContentWidth: CGFloat = 500
|
||||||
|
|
||||||
|
/// Hero icon sizes
|
||||||
|
static let heroIconSize: CGFloat = 80
|
||||||
|
static let heroIconCircleSize: CGFloat = 120
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content Container
|
||||||
|
|
||||||
|
/// Container that limits content width for iPad and landscape orientations
|
||||||
|
struct OnboardingContentContainer<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let isWide = geometry.size.width > OnboardingLayout.maxContentWidth + Design.Spacing.xxLarge * 2
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
content
|
||||||
|
.frame(maxWidth: isWide ? OnboardingLayout.maxContentWidth : .infinity)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(minHeight: geometry.size.height)
|
||||||
|
}
|
||||||
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Primary Button
|
||||||
|
|
||||||
|
/// Primary action button used throughout onboarding
|
||||||
|
struct OnboardingPrimaryButton: View {
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
var icon: String? = nil
|
||||||
|
var style: OnboardingButtonStyle = .accent
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
if let icon {
|
||||||
|
SymbolIcon(icon, size: .row, color: style.foregroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.styled(.headingEmphasis, emphasis: style.textEmphasis)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.background(style.background)
|
||||||
|
.clipShape(.capsule)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Button style variants for onboarding
|
||||||
|
enum OnboardingButtonStyle {
|
||||||
|
case accent
|
||||||
|
case premium
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var background: some View {
|
||||||
|
switch self {
|
||||||
|
case .accent:
|
||||||
|
AppAccent.primary
|
||||||
|
case .premium:
|
||||||
|
LinearGradient(
|
||||||
|
colors: [AppStatus.warning, AppStatus.warning.opacity(Design.Opacity.strong)],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var foregroundColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .accent, .premium:
|
||||||
|
return .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var textEmphasis: TextEmphasis {
|
||||||
|
.custom(.black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Secondary Button
|
||||||
|
|
||||||
|
/// Secondary/text button for optional actions
|
||||||
|
struct OnboardingSecondaryButton: View {
|
||||||
|
let title: String
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.styled(.body, emphasis: .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero Icon
|
||||||
|
|
||||||
|
/// Large icon display with optional glow effect
|
||||||
|
struct OnboardingHeroIcon: View {
|
||||||
|
let systemName: String
|
||||||
|
var color: Color = AppAccent.primary
|
||||||
|
var size: CGFloat = OnboardingLayout.heroIconSize
|
||||||
|
var showGlow: Bool = true
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
if showGlow {
|
||||||
|
// Glow background
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
color.opacity(Design.Opacity.medium),
|
||||||
|
color.opacity(Design.Opacity.subtle),
|
||||||
|
.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: Design.Spacing.large,
|
||||||
|
endRadius: size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: size * 2, height: size * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
SymbolIcon(systemName, size: .hero, color: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hero Icon Circle
|
||||||
|
|
||||||
|
/// Large circular icon container with gradient background
|
||||||
|
struct OnboardingHeroIconCircle: View {
|
||||||
|
let systemName: String
|
||||||
|
var size: CGFloat = OnboardingLayout.heroIconCircleSize
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Glow background
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Color.BrandColors.primary.opacity(Design.Opacity.medium),
|
||||||
|
Color.BrandColors.primary.opacity(Design.Opacity.subtle),
|
||||||
|
.clear
|
||||||
|
],
|
||||||
|
center: .center,
|
||||||
|
startRadius: Design.Spacing.xLarge,
|
||||||
|
endRadius: size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: size * 2, height: size * 2)
|
||||||
|
|
||||||
|
// Icon circle
|
||||||
|
Circle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color.BrandColors.primary, Color.BrandColors.secondary],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.overlay {
|
||||||
|
SymbolIcon(systemName, size: .hero, color: .white)
|
||||||
|
}
|
||||||
|
.shadow(
|
||||||
|
color: Color.BrandColors.primary.opacity(Design.Opacity.medium),
|
||||||
|
radius: Design.Shadow.radiusLarge,
|
||||||
|
y: Design.Shadow.offsetMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section Title
|
||||||
|
|
||||||
|
/// Title and subtitle for onboarding sections
|
||||||
|
struct OnboardingSectionTitle: View {
|
||||||
|
let title: String
|
||||||
|
var subtitle: String? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
Text(title)
|
||||||
|
.styled(.titleBold)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.styled(.body, emphasis: .secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feature Row
|
||||||
|
|
||||||
|
/// A single feature highlight row
|
||||||
|
struct OnboardingFeatureRow: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
SymbolIcon(icon, size: .rowContainer, color: AppAccent.primary)
|
||||||
|
.frame(width: Design.IconSize.xLarge)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(title)
|
||||||
|
.styled(.headingEmphasis)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.styled(.caption, emphasis: .tertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Benefit Row
|
||||||
|
|
||||||
|
/// A benefit row for paywall/premium sections
|
||||||
|
struct OnboardingBenefitRow: View {
|
||||||
|
let image: String
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
SymbolIcon(image, size: .rowContainer, color: AppAccent.primary)
|
||||||
|
.frame(width: Design.IconSize.xLarge)
|
||||||
|
|
||||||
|
Text(text)
|
||||||
|
.styled(.body)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feature Card
|
||||||
|
|
||||||
|
/// A compact card showing a single feature
|
||||||
|
struct OnboardingFeatureCard: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Design.Spacing.small) {
|
||||||
|
SymbolIcon(icon, size: .card, color: AppAccent.primary)
|
||||||
|
|
||||||
|
VStack(spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text(title)
|
||||||
|
.styled(.captionEmphasis)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.styled(.caption2, emphasis: .tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(Design.Spacing.medium)
|
||||||
|
.background(AppSurface.card)
|
||||||
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
//
|
||||||
|
// OnboardingViewModel.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Manages onboarding flow state including step navigation, permission tracking,
|
||||||
|
// and premium status detection for dynamic content.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import AVFoundation
|
||||||
|
import Photos
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
|
// MARK: - Onboarding Step
|
||||||
|
|
||||||
|
/// Represents each step in the onboarding flow
|
||||||
|
enum OnboardingStep: Int, CaseIterable, Comparable {
|
||||||
|
case welcome
|
||||||
|
case cameraPermission
|
||||||
|
case microphonePermission
|
||||||
|
case photoPermission
|
||||||
|
case settings
|
||||||
|
case premiumOrPaywall
|
||||||
|
|
||||||
|
static func < (lhs: OnboardingStep, rhs: OnboardingStep) -> Bool {
|
||||||
|
lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total number of steps (excluding dynamic premium/paywall which shows as one step)
|
||||||
|
static var totalSteps: Int { 6 }
|
||||||
|
|
||||||
|
/// Human-readable step number for progress indicator (1-indexed)
|
||||||
|
var stepNumber: Int { rawValue + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Status
|
||||||
|
|
||||||
|
/// Tracks the authorization status for required permissions
|
||||||
|
enum PermissionStatus: Sendable {
|
||||||
|
case notDetermined
|
||||||
|
case authorized
|
||||||
|
case denied
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Onboarding ViewModel
|
||||||
|
|
||||||
|
/// Observable view model for managing onboarding state and flow
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class OnboardingViewModel {
|
||||||
|
|
||||||
|
// MARK: - Step Navigation
|
||||||
|
|
||||||
|
/// Current step in the onboarding flow
|
||||||
|
var currentStep: OnboardingStep = .welcome
|
||||||
|
|
||||||
|
/// Whether the user can proceed to the next step
|
||||||
|
var canProceed: Bool {
|
||||||
|
switch currentStep {
|
||||||
|
case .welcome:
|
||||||
|
return true
|
||||||
|
case .cameraPermission:
|
||||||
|
// Must have camera permission to proceed
|
||||||
|
return cameraPermissionStatus == .authorized
|
||||||
|
case .microphonePermission:
|
||||||
|
// Must have microphone permission (required by camera framework)
|
||||||
|
return microphonePermissionStatus == .authorized
|
||||||
|
case .photoPermission:
|
||||||
|
// Can proceed even if denied (share is an alternative)
|
||||||
|
return true
|
||||||
|
case .settings:
|
||||||
|
return true
|
||||||
|
case .premiumOrPaywall:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission States
|
||||||
|
|
||||||
|
/// Current camera permission status
|
||||||
|
var cameraPermissionStatus: PermissionStatus = .notDetermined
|
||||||
|
|
||||||
|
/// Current microphone permission status
|
||||||
|
var microphonePermissionStatus: PermissionStatus = .notDetermined
|
||||||
|
|
||||||
|
/// Current photo library permission status
|
||||||
|
var photoPermissionStatus: PermissionStatus = .notDetermined
|
||||||
|
|
||||||
|
// MARK: - Premium Status
|
||||||
|
|
||||||
|
/// Premium manager instance for checking subscription status
|
||||||
|
private let premiumManager = PremiumManager()
|
||||||
|
|
||||||
|
/// Whether the user has premium access (dynamically checked)
|
||||||
|
var isPremiumUser: Bool {
|
||||||
|
premiumManager.isPremiumUnlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings During Onboarding
|
||||||
|
|
||||||
|
/// Ring light enabled preference (applies to SettingsViewModel on completion)
|
||||||
|
var isRingLightEnabled: Bool = true
|
||||||
|
|
||||||
|
/// Camera position preference (applies to SettingsViewModel on completion)
|
||||||
|
var prefersFrontCamera: Bool = true
|
||||||
|
|
||||||
|
// MARK: - Completion
|
||||||
|
|
||||||
|
/// Callback triggered when onboarding is completed
|
||||||
|
var onComplete: (() -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Check initial permission states
|
||||||
|
updatePermissionStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission Handling
|
||||||
|
|
||||||
|
/// Updates cached permission states from system
|
||||||
|
func updatePermissionStates() {
|
||||||
|
// Camera permission
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||||
|
case .notDetermined:
|
||||||
|
cameraPermissionStatus = .notDetermined
|
||||||
|
case .authorized:
|
||||||
|
cameraPermissionStatus = .authorized
|
||||||
|
case .denied, .restricted:
|
||||||
|
cameraPermissionStatus = .denied
|
||||||
|
@unknown default:
|
||||||
|
cameraPermissionStatus = .notDetermined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microphone permission
|
||||||
|
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||||
|
case .notDetermined:
|
||||||
|
microphonePermissionStatus = .notDetermined
|
||||||
|
case .authorized:
|
||||||
|
microphonePermissionStatus = .authorized
|
||||||
|
case .denied, .restricted:
|
||||||
|
microphonePermissionStatus = .denied
|
||||||
|
@unknown default:
|
||||||
|
microphonePermissionStatus = .notDetermined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo library permission (add-only)
|
||||||
|
switch PHPhotoLibrary.authorizationStatus(for: .addOnly) {
|
||||||
|
case .notDetermined:
|
||||||
|
photoPermissionStatus = .notDetermined
|
||||||
|
case .authorized, .limited:
|
||||||
|
photoPermissionStatus = .authorized
|
||||||
|
case .denied, .restricted:
|
||||||
|
photoPermissionStatus = .denied
|
||||||
|
@unknown default:
|
||||||
|
photoPermissionStatus = .notDetermined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests camera permission and updates state
|
||||||
|
func requestCameraPermission() async {
|
||||||
|
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||||
|
cameraPermissionStatus = granted ? .authorized : .denied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests microphone permission and updates state
|
||||||
|
func requestMicrophonePermission() async {
|
||||||
|
let granted = await AVCaptureDevice.requestAccess(for: .audio)
|
||||||
|
microphonePermissionStatus = granted ? .authorized : .denied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests photo library permission and updates state
|
||||||
|
func requestPhotoPermission() async {
|
||||||
|
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||||
|
switch status {
|
||||||
|
case .authorized, .limited:
|
||||||
|
photoPermissionStatus = .authorized
|
||||||
|
case .denied, .restricted:
|
||||||
|
photoPermissionStatus = .denied
|
||||||
|
case .notDetermined:
|
||||||
|
photoPermissionStatus = .notDetermined
|
||||||
|
@unknown default:
|
||||||
|
photoPermissionStatus = .notDetermined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
/// Advances to the next step if possible
|
||||||
|
func advanceToNextStep() {
|
||||||
|
guard canProceed else { return }
|
||||||
|
|
||||||
|
if let nextStep = OnboardingStep(rawValue: currentStep.rawValue + 1) {
|
||||||
|
currentStep = nextStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Goes back to the previous step
|
||||||
|
func goToPreviousStep() {
|
||||||
|
if let previousStep = OnboardingStep(rawValue: currentStep.rawValue - 1) {
|
||||||
|
currentStep = previousStep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes onboarding and applies settings
|
||||||
|
func completeOnboarding(settingsViewModel: SettingsViewModel) {
|
||||||
|
// Apply user preferences from onboarding
|
||||||
|
settingsViewModel.isRingLightEnabled = isRingLightEnabled
|
||||||
|
|
||||||
|
if prefersFrontCamera {
|
||||||
|
settingsViewModel.cameraPosition = .front
|
||||||
|
} else {
|
||||||
|
settingsViewModel.cameraPosition = .back
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger completion callback
|
||||||
|
onComplete?()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Settings URL
|
||||||
|
|
||||||
|
/// Opens system Settings app for this app's permissions
|
||||||
|
func openSettings() {
|
||||||
|
guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||||
|
UIApplication.shared.open(settingsURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
//
|
||||||
|
// OnboardingContainerView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Main container for the onboarding flow with step-based navigation
|
||||||
|
// and progress indication. Supports landscape and iPad layouts.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct OnboardingContainerView: View {
|
||||||
|
@Bindable var viewModel: OnboardingViewModel
|
||||||
|
@Bindable var settingsViewModel: SettingsViewModel
|
||||||
|
@Binding var showPaywall: Bool
|
||||||
|
|
||||||
|
/// Callback when onboarding is completed
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Progress indicator at top
|
||||||
|
OnboardingProgressView(
|
||||||
|
currentStep: viewModel.currentStep.stepNumber,
|
||||||
|
totalSteps: OnboardingStep.totalSteps
|
||||||
|
)
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
|
||||||
|
// Main content area
|
||||||
|
TabView(selection: Binding(
|
||||||
|
get: { viewModel.currentStep },
|
||||||
|
set: { viewModel.currentStep = $0 }
|
||||||
|
)) {
|
||||||
|
// Step 1: Welcome
|
||||||
|
OnboardingWelcomeView(viewModel: viewModel)
|
||||||
|
.tag(OnboardingStep.welcome)
|
||||||
|
|
||||||
|
// Step 2: Camera Permission
|
||||||
|
OnboardingPermissionView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
permissionType: .camera
|
||||||
|
)
|
||||||
|
.tag(OnboardingStep.cameraPermission)
|
||||||
|
|
||||||
|
// Step 3: Microphone Permission (required by camera framework)
|
||||||
|
OnboardingPermissionView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
permissionType: .microphone
|
||||||
|
)
|
||||||
|
.tag(OnboardingStep.microphonePermission)
|
||||||
|
|
||||||
|
// Step 4: Photo Library Permission
|
||||||
|
OnboardingPermissionView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
permissionType: .photoLibrary
|
||||||
|
)
|
||||||
|
.tag(OnboardingStep.photoPermission)
|
||||||
|
|
||||||
|
// Step 5: Settings
|
||||||
|
OnboardingSettingsView(viewModel: viewModel)
|
||||||
|
.tag(OnboardingStep.settings)
|
||||||
|
|
||||||
|
// Step 6: Premium tour or Soft Paywall (dynamic based on premium status)
|
||||||
|
Group {
|
||||||
|
if viewModel.isPremiumUser {
|
||||||
|
OnboardingPremiumView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
settingsViewModel: settingsViewModel,
|
||||||
|
onComplete: onComplete
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
OnboardingSoftPaywallView(
|
||||||
|
viewModel: viewModel,
|
||||||
|
settingsViewModel: settingsViewModel,
|
||||||
|
showPaywall: $showPaywall,
|
||||||
|
onComplete: onComplete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tag(OnboardingStep.premiumOrPaywall)
|
||||||
|
}
|
||||||
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.standard), value: viewModel.currentStep)
|
||||||
|
}
|
||||||
|
.background(AppSurface.primary.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Progress View
|
||||||
|
|
||||||
|
/// Displays horizontal progress dots for onboarding steps
|
||||||
|
struct OnboardingProgressView: View {
|
||||||
|
let currentStep: Int
|
||||||
|
let totalSteps: Int
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ForEach(1...totalSteps, id: \.self) { step in
|
||||||
|
Circle()
|
||||||
|
.fill(step <= currentStep ? AppAccent.primary : AppSurface.card)
|
||||||
|
.frame(width: Design.Spacing.small, height: Design.Spacing.small)
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: currentStep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingContainerView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel(),
|
||||||
|
showPaywall: .constant(false),
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -0,0 +1,234 @@
|
|||||||
|
//
|
||||||
|
// OnboardingPermissionView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Reusable permission request screen for camera and photo library access.
|
||||||
|
// Handles permission states and provides appropriate UI for each state.
|
||||||
|
// Supports landscape and iPad layouts with max width constraints.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Permission Type
|
||||||
|
|
||||||
|
/// Types of permissions the onboarding can request
|
||||||
|
enum OnboardingPermissionType {
|
||||||
|
case camera
|
||||||
|
case microphone
|
||||||
|
case photoLibrary
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .camera: return "camera.fill"
|
||||||
|
case .microphone: return "mic.fill"
|
||||||
|
case .photoLibrary: return "photo.on.rectangle.angled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .camera: return String(localized: "Camera Access")
|
||||||
|
case .microphone: return String(localized: "Microphone Access")
|
||||||
|
case .photoLibrary: return String(localized: "Photo Library")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .camera:
|
||||||
|
return String(localized: "SelfieCam needs camera access to show your live preview, apply real-time filters, and capture high-quality photos.")
|
||||||
|
case .microphone:
|
||||||
|
return String(localized: "The camera requires microphone access to initialize properly. Audio is not recorded or stored.")
|
||||||
|
case .photoLibrary:
|
||||||
|
return String(localized: "Save your photos directly to your library for easy access and sharing.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buttonTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .camera: return String(localized: "Enable Camera")
|
||||||
|
case .microphone: return String(localized: "Enable Microphone")
|
||||||
|
case .photoLibrary: return String(localized: "Enable Photos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deniedTitle: String {
|
||||||
|
switch self {
|
||||||
|
case .camera: return String(localized: "Camera Access Required")
|
||||||
|
case .microphone: return String(localized: "Microphone Access Required")
|
||||||
|
case .photoLibrary: return String(localized: "Photo Access Denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var deniedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .camera:
|
||||||
|
return String(localized: "SelfieCam requires camera access to function. Please enable it in Settings to continue.")
|
||||||
|
case .microphone:
|
||||||
|
return String(localized: "The camera framework requires microphone access to work. Please enable it in Settings to continue.")
|
||||||
|
case .photoLibrary:
|
||||||
|
return String(localized: "Without photo library access, you can still share photos directly but won't be able to save them automatically.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this permission is required to proceed
|
||||||
|
var isRequired: Bool {
|
||||||
|
switch self {
|
||||||
|
case .camera: return true
|
||||||
|
case .microphone: return true
|
||||||
|
case .photoLibrary: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission View
|
||||||
|
|
||||||
|
struct OnboardingPermissionView: View {
|
||||||
|
@Bindable var viewModel: OnboardingViewModel
|
||||||
|
let permissionType: OnboardingPermissionType
|
||||||
|
|
||||||
|
/// Current permission status for this permission type
|
||||||
|
private var permissionStatus: PermissionStatus {
|
||||||
|
switch permissionType {
|
||||||
|
case .camera: return viewModel.cameraPermissionStatus
|
||||||
|
case .microphone: return viewModel.microphonePermissionStatus
|
||||||
|
case .photoLibrary: return viewModel.photoPermissionStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this permission has been granted
|
||||||
|
private var isGranted: Bool {
|
||||||
|
permissionStatus == .authorized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this permission was explicitly denied
|
||||||
|
private var isDenied: Bool {
|
||||||
|
permissionStatus == .denied
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Icon color based on permission state
|
||||||
|
private var iconColor: Color {
|
||||||
|
isGranted ? AppStatus.success : AppAccent.primary
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
OnboardingContentContainer {
|
||||||
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Permission icon with status indicator
|
||||||
|
ZStack {
|
||||||
|
OnboardingHeroIcon(
|
||||||
|
systemName: permissionType.icon,
|
||||||
|
color: iconColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
// Checkmark overlay when granted
|
||||||
|
if isGranted {
|
||||||
|
Circle()
|
||||||
|
.fill(AppStatus.success)
|
||||||
|
.frame(width: Design.Spacing.xLarge, height: Design.Spacing.xLarge)
|
||||||
|
.overlay {
|
||||||
|
SymbolIcon("checkmark", size: .inline, color: .white, weight: .bold)
|
||||||
|
}
|
||||||
|
.offset(x: Design.Spacing.medium, y: Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title and description
|
||||||
|
OnboardingSectionTitle(
|
||||||
|
title: isDenied ? permissionType.deniedTitle : permissionType.title,
|
||||||
|
subtitle: isDenied ? permissionType.deniedDescription : permissionType.description
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
if isGranted {
|
||||||
|
// Already granted - show continue button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: String(localized: "Continue"),
|
||||||
|
action: { viewModel.advanceToNextStep() }
|
||||||
|
)
|
||||||
|
} else if isDenied {
|
||||||
|
// Denied - show open settings button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: String(localized: "Open Settings"),
|
||||||
|
action: { viewModel.openSettings() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// For non-required permissions, allow skipping
|
||||||
|
if !permissionType.isRequired {
|
||||||
|
OnboardingSecondaryButton(
|
||||||
|
title: String(localized: "Skip for Now"),
|
||||||
|
action: { viewModel.advanceToNextStep() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not determined - show request button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: permissionType.buttonTitle,
|
||||||
|
action: {
|
||||||
|
Task {
|
||||||
|
switch permissionType {
|
||||||
|
case .camera:
|
||||||
|
await viewModel.requestCameraPermission()
|
||||||
|
case .microphone:
|
||||||
|
await viewModel.requestMicrophonePermission()
|
||||||
|
case .photoLibrary:
|
||||||
|
await viewModel.requestPhotoPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-advance if granted (or skip for optional permissions)
|
||||||
|
if viewModel.canProceed {
|
||||||
|
viewModel.advanceToNextStep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Refresh permission status when view appears (in case user changed in Settings)
|
||||||
|
viewModel.updatePermissionStates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Camera Permission") {
|
||||||
|
OnboardingPermissionView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
permissionType: .camera
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Photo Library Permission") {
|
||||||
|
OnboardingPermissionView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
permissionType: .photoLibrary
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Landscape", traits: .landscapeLeft) {
|
||||||
|
OnboardingPermissionView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
permissionType: .camera
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
122
SelfieCam/Features/Onboarding/Views/OnboardingPremiumView.swift
Normal file
122
SelfieCam/Features/Onboarding/Views/OnboardingPremiumView.swift
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
//
|
||||||
|
// OnboardingPremiumView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Premium features tour shown to users who already have Pro access.
|
||||||
|
// Highlights the premium features they can use.
|
||||||
|
// Supports landscape and iPad layouts with max width constraints.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct OnboardingPremiumView: View {
|
||||||
|
@Bindable var viewModel: OnboardingViewModel
|
||||||
|
@Bindable var settingsViewModel: SettingsViewModel
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
OnboardingContentContainer {
|
||||||
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Header with crown
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
OnboardingHeroIcon(
|
||||||
|
systemName: "crown.fill",
|
||||||
|
color: AppStatus.warning
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingSectionTitle(
|
||||||
|
title: String(localized: "Welcome to Pro!"),
|
||||||
|
subtitle: String(localized: "You have access to all premium features")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Premium features grid
|
||||||
|
LazyVGrid(
|
||||||
|
columns: [
|
||||||
|
GridItem(.flexible(), spacing: Design.Spacing.medium),
|
||||||
|
GridItem(.flexible(), spacing: Design.Spacing.medium)
|
||||||
|
],
|
||||||
|
spacing: Design.Spacing.medium
|
||||||
|
) {
|
||||||
|
OnboardingFeatureCard(
|
||||||
|
icon: "paintpalette.fill",
|
||||||
|
title: String(localized: "Custom Colors"),
|
||||||
|
subtitle: String(localized: "Ring light colors")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureCard(
|
||||||
|
icon: "sparkles",
|
||||||
|
title: String(localized: "Skin Smoothing"),
|
||||||
|
subtitle: String(localized: "Beauty filter")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureCard(
|
||||||
|
icon: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill",
|
||||||
|
title: String(localized: "True Mirror"),
|
||||||
|
subtitle: String(localized: "Flipped preview")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureCard(
|
||||||
|
icon: "camera.filters",
|
||||||
|
title: String(localized: "HDR Mode"),
|
||||||
|
subtitle: String(localized: "Better lighting")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureCard(
|
||||||
|
icon: "timer",
|
||||||
|
title: String(localized: "Extended Timers"),
|
||||||
|
subtitle: String(localized: "5s and 10s")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureCard(
|
||||||
|
icon: "star.fill",
|
||||||
|
title: String(localized: "High Quality"),
|
||||||
|
subtitle: String(localized: "Photo export")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Start button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: String(localized: "Start Taking Photos"),
|
||||||
|
action: {
|
||||||
|
viewModel.completeOnboarding(settingsViewModel: settingsViewModel)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingPremiumView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel(),
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Landscape", traits: .landscapeLeft) {
|
||||||
|
OnboardingPremiumView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel(),
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// OnboardingSettingsView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Basic settings personalization during onboarding.
|
||||||
|
// Allows users to configure ring light and camera position preferences.
|
||||||
|
// Supports landscape and iPad layouts with max width constraints.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct OnboardingSettingsView: View {
|
||||||
|
@Bindable var viewModel: OnboardingViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
OnboardingContentContainer {
|
||||||
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
SymbolIcon("slider.horizontal.3", size: .hero, color: AppAccent.primary)
|
||||||
|
|
||||||
|
OnboardingSectionTitle(
|
||||||
|
title: String(localized: "Personalize Your Setup"),
|
||||||
|
subtitle: String(localized: "Set your preferences to get started quickly")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Settings cards using Bedrock components
|
||||||
|
SettingsCard(backgroundColor: AppSurface.card, borderColor: AppBorder.subtle) {
|
||||||
|
// Ring Light Toggle
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Ring Light"),
|
||||||
|
subtitle: String(localized: "Adds flattering lighting around the camera preview"),
|
||||||
|
isOn: $viewModel.isRingLightEnabled,
|
||||||
|
accentColor: AppAccent.primary
|
||||||
|
)
|
||||||
|
|
||||||
|
// Camera Position using Bedrock SettingsSegmentedPicker
|
||||||
|
SettingsSegmentedPicker(
|
||||||
|
title: String(localized: "Default Camera"),
|
||||||
|
subtitle: String(localized: "Choose which camera opens by default"),
|
||||||
|
options: [
|
||||||
|
(String(localized: "Front"), true),
|
||||||
|
(String(localized: "Back"), false)
|
||||||
|
],
|
||||||
|
selection: $viewModel.prefersFrontCamera,
|
||||||
|
accentColor: AppAccent.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Continue button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: String(localized: "Continue"),
|
||||||
|
action: { viewModel.advanceToNextStep() }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingSettingsView(viewModel: OnboardingViewModel())
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Landscape", traits: .landscapeLeft) {
|
||||||
|
OnboardingSettingsView(viewModel: OnboardingViewModel())
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
//
|
||||||
|
// OnboardingSoftPaywallView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Soft paywall shown to free users during onboarding.
|
||||||
|
// Presents premium benefits with options to upgrade or continue with free version.
|
||||||
|
// Supports landscape and iPad layouts with max width constraints.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct OnboardingSoftPaywallView: View {
|
||||||
|
@Bindable var viewModel: OnboardingViewModel
|
||||||
|
@Bindable var settingsViewModel: SettingsViewModel
|
||||||
|
@Binding var showPaywall: Bool
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
OnboardingContentContainer {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
SymbolIcon("crown.fill", size: .hero, color: AppStatus.warning)
|
||||||
|
|
||||||
|
OnboardingSectionTitle(
|
||||||
|
title: String(localized: "Unlock Pro Features"),
|
||||||
|
subtitle: String(localized: "Get the most out of SelfieCam")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Benefits list
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
|
||||||
|
OnboardingBenefitRow(
|
||||||
|
image: "paintpalette.fill",
|
||||||
|
text: String(localized: "Premium Colors + Custom Color Picker")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingBenefitRow(
|
||||||
|
image: "sparkles",
|
||||||
|
text: String(localized: "Skin Smoothing Beauty Filter")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingBenefitRow(
|
||||||
|
image: "arrow.left.and.right.righttriangle.left.righttriangle.right.fill",
|
||||||
|
text: String(localized: "True Mirror Mode")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingBenefitRow(
|
||||||
|
image: "camera.filters",
|
||||||
|
text: String(localized: "HDR Mode for Better Photos")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingBenefitRow(
|
||||||
|
image: "timer",
|
||||||
|
text: String(localized: "Extended Self-Timers (5s, 10s)")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingBenefitRow(
|
||||||
|
image: "star.fill",
|
||||||
|
text: String(localized: "High Quality Photo Export")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Upgrade button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: String(localized: "Upgrade to Pro"),
|
||||||
|
action: { showPaywall = true },
|
||||||
|
icon: "crown.fill",
|
||||||
|
style: .premium
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maybe Later button
|
||||||
|
OnboardingSecondaryButton(
|
||||||
|
title: String(localized: "Maybe Later"),
|
||||||
|
action: {
|
||||||
|
viewModel.completeOnboarding(settingsViewModel: settingsViewModel)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
.onChange(of: viewModel.isPremiumUser) { _, isPremium in
|
||||||
|
// If user subscribed during paywall, complete onboarding
|
||||||
|
if isPremium {
|
||||||
|
viewModel.completeOnboarding(settingsViewModel: settingsViewModel)
|
||||||
|
onComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingSoftPaywallView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel(),
|
||||||
|
showPaywall: .constant(false),
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Landscape", traits: .landscapeLeft) {
|
||||||
|
OnboardingSoftPaywallView(
|
||||||
|
viewModel: OnboardingViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel(),
|
||||||
|
showPaywall: .constant(false),
|
||||||
|
onComplete: {}
|
||||||
|
)
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// OnboardingWelcomeView.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Welcome screen introducing the app with branding and a call to action.
|
||||||
|
// Supports landscape and iPad layouts with max width constraints.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
struct OnboardingWelcomeView: View {
|
||||||
|
@Bindable var viewModel: OnboardingViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
OnboardingContentContainer {
|
||||||
|
VStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// App icon/branding
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Camera icon with glow effect
|
||||||
|
OnboardingHeroIconCircle(systemName: "camera.fill")
|
||||||
|
|
||||||
|
// App name
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Text("SelfieCam")
|
||||||
|
.styled(.titleBold)
|
||||||
|
|
||||||
|
Text(String(localized: "The perfect selfie, every time"))
|
||||||
|
.styled(.body, emphasis: .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Feature highlights (centered within container)
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
OnboardingFeatureRow(
|
||||||
|
icon: "light.max",
|
||||||
|
title: String(localized: "Ring Light"),
|
||||||
|
subtitle: String(localized: "Perfect lighting for every shot")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureRow(
|
||||||
|
icon: "camera.filters",
|
||||||
|
title: String(localized: "Pro Features"),
|
||||||
|
subtitle: String(localized: "HDR, skin smoothing, and more")
|
||||||
|
)
|
||||||
|
|
||||||
|
OnboardingFeatureRow(
|
||||||
|
icon: "hand.tap.fill",
|
||||||
|
title: String(localized: "Easy Capture"),
|
||||||
|
subtitle: String(localized: "Volume buttons, timers, gestures")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Get Started button
|
||||||
|
OnboardingPrimaryButton(
|
||||||
|
title: String(localized: "Get Started"),
|
||||||
|
action: { viewModel.advanceToNextStep() }
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.xLarge)
|
||||||
|
.padding(.bottom, Design.Spacing.xLarge)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
OnboardingWelcomeView(viewModel: OnboardingViewModel())
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("iPad Landscape", traits: .landscapeLeft) {
|
||||||
|
OnboardingWelcomeView(viewModel: OnboardingViewModel())
|
||||||
|
.background(AppSurface.primary)
|
||||||
|
.preferredColorScheme(.dark)
|
||||||
|
}
|
||||||
@ -22,14 +22,14 @@ extension SettingsViewModel {
|
|||||||
|
|
||||||
var cameraPosition: CameraPosition {
|
var cameraPosition: CameraPosition {
|
||||||
get {
|
get {
|
||||||
|
// Access the data through cloudSync to ensure observation is tracked
|
||||||
let raw = cloudSync.data.cameraPositionRaw
|
let raw = cloudSync.data.cameraPositionRaw
|
||||||
let position: CameraPosition = raw == "front" ? .front : .back
|
return raw == "back" ? .back : .front
|
||||||
Design.debugLog("cameraPosition getter: raw='\(raw)' -> \(position)")
|
|
||||||
return position
|
|
||||||
}
|
}
|
||||||
set {
|
set {
|
||||||
let rawValue = newValue == .front ? "front" : "back"
|
let rawValue = newValue == .back ? "back" : "front"
|
||||||
Design.debugLog("cameraPosition setter: \(newValue) -> raw='\(rawValue)'")
|
|
||||||
|
// Use updateSettings to ensure modificationCount is incremented and observers are notified
|
||||||
updateSettings { $0.cameraPositionRaw = rawValue }
|
updateSettings { $0.cameraPositionRaw = rawValue }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,6 +137,9 @@ final class SettingsViewModel: RingLightConfigurable {
|
|||||||
|
|
||||||
/// Updates settings and saves to cloud immediately
|
/// Updates settings and saves to cloud immediately
|
||||||
func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
|
func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
|
||||||
|
// Since we are using @Observable, we need to ensure the property access is tracked
|
||||||
|
// The cloudSync.update call modifies the data, but we need to trigger the observation
|
||||||
|
// We wrap the update in a way that SwiftUI's observation system can see the change
|
||||||
cloudSync.update { settings in
|
cloudSync.update { settings in
|
||||||
transform(&settings)
|
transform(&settings)
|
||||||
settings.modificationCount += 1
|
settings.modificationCount += 1
|
||||||
|
|||||||
@ -27,7 +27,7 @@ struct AppLicensesView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
LicensesView(
|
LicensesView(
|
||||||
licenses: Self.licenses,
|
licenses: Self.licenses,
|
||||||
backgroundColor: AppSurface.overlay,
|
backgroundColor: AppSurface.primary,
|
||||||
cardBackgroundColor: AppSurface.card,
|
cardBackgroundColor: AppSurface.card,
|
||||||
cardBorderColor: AppBorder.subtle,
|
cardBorderColor: AppBorder.subtle,
|
||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
|
|||||||
@ -555,6 +555,9 @@ struct SettingsView: View {
|
|||||||
accentColor: AppAccent.primary
|
accentColor: AppAccent.primary
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Reset Onboarding Button
|
||||||
|
ResetOnboardingButton()
|
||||||
|
|
||||||
// Icon Generator
|
// Icon Generator
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
title: "Icon Generator",
|
title: "Icon Generator",
|
||||||
@ -581,6 +584,46 @@ struct SettingsView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Reset Onboarding Button
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
/// Debug button to reset onboarding state and show it again on next launch
|
||||||
|
struct ResetOnboardingButton: View {
|
||||||
|
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = true
|
||||||
|
@State private var showConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
hasCompletedOnboarding = false
|
||||||
|
showConfirmation = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
|
||||||
|
Text("Reset Onboarding")
|
||||||
|
.font(.system(size: Design.FontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Show onboarding flow on next app launch")
|
||||||
|
.font(.system(size: Design.FontSize.caption))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
.foregroundStyle(AppAccent.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.alert("Onboarding Reset", isPresented: $showConfirmation) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("Onboarding will show again when you restart the app.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -196,6 +196,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"5s and 10s" : {
|
||||||
|
"comment" : "Subtitle for a premium feature card that offers extended timers for taking selfies.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"10%" : {
|
"10%" : {
|
||||||
"comment" : "A label displayed alongside the left edge of the opacity slider.",
|
"comment" : "A label displayed alongside the left edge of the opacity slider.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -270,6 +274,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Adds flattering lighting around the camera preview" : {
|
||||||
|
"comment" : "Subtitle for the \"Ring Light\" toggle in the onboarding settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Adjusts the brightness of the ring light" : {
|
"Adjusts the brightness of the ring light" : {
|
||||||
"comment" : "A description of the ring light brightness slider.",
|
"comment" : "A description of the ring light brightness slider.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -486,6 +494,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Beauty filter" : {
|
||||||
|
"comment" : "Subtitle of a premium feature card that offers skin smoothing.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Best Value • Save 33%" : {
|
"Best Value • Save 33%" : {
|
||||||
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -510,6 +522,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Better lighting" : {
|
||||||
|
"comment" : "Subtitle for a premium feature card that allows users to take photos in better lighting.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Boomerang" : {
|
"Boomerang" : {
|
||||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -559,6 +575,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Camera Access" : {
|
||||||
|
"comment" : "Title of a permission request for camera access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Camera Access Required" : {
|
||||||
|
"comment" : "Title displayed when the user denies camera access in the onboarding.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Camera controls" : {
|
"Camera controls" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -797,6 +821,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Choose which camera opens by default" : {
|
||||||
|
"comment" : "Title of a segmented picker in the onboarding settings view, describing the option to choose which camera is selected by default.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Close" : {
|
"Close" : {
|
||||||
"comment" : "A button label that closes the view.",
|
"comment" : "A button label that closes the view.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -846,6 +874,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Continue" : {
|
||||||
|
"comment" : "Text for a button that allows the user to continue to the next step in the onboarding process.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Controls automatic flash behavior for photos" : {
|
"Controls automatic flash behavior for photos" : {
|
||||||
"comment" : "A description below the flash mode picker, explaining its purpose.",
|
"comment" : "A description below the flash mode picker, explaining its purpose.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -942,6 +974,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Custom Colors" : {
|
||||||
|
"comment" : "Title of a premium feature card that allows users to change the color of the ring light.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Debug mode: Purchase simulated!" : {
|
"Debug mode: Purchase simulated!" : {
|
||||||
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -990,6 +1026,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Default Camera" : {
|
||||||
|
"comment" : "Title of the segmented picker that allows users to choose which camera opens by default.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Delay before photo capture for self-portraits" : {
|
"Delay before photo capture for self-portraits" : {
|
||||||
"comment" : "A description of the purpose of the \"Self-Timer\" setting in the settings screen.",
|
"comment" : "A description of the purpose of the \"Self-Timer\" setting in the settings screen.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1062,6 +1102,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Easy Capture" : {
|
||||||
|
"comment" : "Subtitle for a feature row in the \"Welcome\" view, describing the ease of capturing photos.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Enable Camera" : {
|
||||||
|
"comment" : "Text on the action button for enabling camera permission.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable Center Stage" : {
|
"Enable Center Stage" : {
|
||||||
"comment" : "An accessibility label for the toggle that enables the \"Center Stage\" feature.",
|
"comment" : "An accessibility label for the toggle that enables the \"Center Stage\" feature.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -1087,6 +1135,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Enable Microphone" : {
|
||||||
|
"comment" : "Title for the button that enables microphone access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Enable Photos" : {
|
||||||
|
"comment" : "Title for the button that enables photo library access in the onboarding.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Enable Ring Light" : {
|
"Enable Ring Light" : {
|
||||||
"comment" : "Title of a toggle in the Settings view that allows the user to enable or disable the ring light overlay.",
|
"comment" : "Title of a toggle in the Settings view that allows the user to enable or disable the ring light overlay.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1159,6 +1215,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Extended Timers" : {
|
||||||
|
"comment" : "Title of a premium feature that allows users to take photos for 5 seconds or 10 seconds.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"File size and image quality for saved photos" : {
|
"File size and image quality for saved photos" : {
|
||||||
"comment" : "A description of the photo quality setting.",
|
"comment" : "A description of the photo quality setting.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1255,6 +1315,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Flipped preview" : {
|
||||||
|
"comment" : "Text displayed in a feature card in the premium features tour, describing the \"True Mirror\" feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Flips the camera preview horizontally" : {
|
"Flips the camera preview horizontally" : {
|
||||||
"comment" : "An accessibility hint for the \"True Mirror\" setting.",
|
"comment" : "An accessibility hint for the \"True Mirror\" setting.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1352,6 +1416,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Get Started" : {
|
||||||
|
"comment" : "Title of the \"Get Started\" button in the onboarding welcome view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Get the most out of SelfieCam" : {
|
||||||
|
"comment" : "Subtitle for the \"Unlock Pro Features\" section in the soft paywall view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Go Pro" : {
|
"Go Pro" : {
|
||||||
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
|
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1448,6 +1520,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"HDR, skin smoothing, and more" : {
|
||||||
|
"comment" : "Subtitle for a feature row in the \"Pro Features\" section of the onboarding welcome view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Hide preview during capture for flash effect" : {
|
"Hide preview during capture for flash effect" : {
|
||||||
"comment" : "Text displayed in a toggle within the \"Camera Controls\" section, allowing the user to enable or disable the feature of hiding the camera preview during a photo capture to simulate a flash effect.",
|
"comment" : "Text displayed in a toggle within the \"Camera Controls\" section, allowing the user to enable or disable the feature of hiding the camera preview during a photo capture to simulate a flash effect.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -1497,6 +1573,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"High Quality" : {
|
||||||
|
"comment" : "Title of a premium feature that allows users to export high-quality photos.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"High Quality Photo Export" : {
|
"High Quality Photo Export" : {
|
||||||
"comment" : "Description of a benefit that is included with the Premium membership.",
|
"comment" : "Description of a benefit that is included with the Premium membership.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1640,6 +1720,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Maybe Later" : {
|
||||||
|
"comment" : "Text for a button that allows a user to dismiss a paywall without purchasing a premium subscription.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Microphone Access" : {
|
||||||
|
"comment" : "Title of a permission request for microphone access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Microphone Access Required" : {
|
||||||
|
"comment" : "Title for an alert when the user has denied microphone access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Off" : {
|
"Off" : {
|
||||||
"comment" : "The accessibility value for the grid toggle when it is off.",
|
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1664,6 +1756,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"OK" : {
|
||||||
|
"comment" : "Label for an OK button.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"On" : {
|
"On" : {
|
||||||
"comment" : "A value that describes a control item as \"On\".",
|
"comment" : "A value that describes a control item as \"On\".",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -1689,6 +1785,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Onboarding Reset" : {
|
||||||
|
"comment" : "The title of an alert that confirms the onboarding state has been reset.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Onboarding will show again when you restart the app." : {
|
||||||
|
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Open Settings" : {
|
||||||
|
"comment" : "Text for an action button that opens the user's device settings to grant a denied permission.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Open Source Licenses" : {
|
"Open Source Licenses" : {
|
||||||
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1737,6 +1845,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Perfect lighting for every shot" : {
|
||||||
|
"comment" : "Subtitle for a feature highlight that emphasizes the app's ability to provide perfect lighting for every selfie.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Personalize Your Setup" : {
|
||||||
|
"comment" : "A title displayed in the header of the view, encouraging users to personalize their selfie setup.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Photo" : {
|
"Photo" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@ -1760,6 +1876,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Photo Access Denied" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Photo export" : {
|
||||||
|
"comment" : "Description of a premium feature that allows them to export high-quality photos.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Photo Library" : {
|
||||||
|
"comment" : "Title of the photo library permission in the onboarding.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Photo Quality" : {
|
"Photo Quality" : {
|
||||||
"comment" : "Title of a segmented picker that allows the user to select the photo quality.",
|
"comment" : "Title of a segmented picker that allows the user to select the photo quality.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1880,6 +2007,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Pro Features" : {
|
||||||
|
"comment" : "Subtitle for a feature row in the \"Welcome\" view, highlighting the app's advanced features.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Purchase successful! Pro features unlocked." : {
|
"Purchase successful! Pro features unlocked." : {
|
||||||
"comment" : "Announcement read out to the user when a premium purchase is successful.",
|
"comment" : "Announcement read out to the user when a premium purchase is successful.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -1952,6 +2083,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Reset Onboarding" : {
|
||||||
|
"comment" : "A button label that resets the onboarding state.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Restore Purchases" : {
|
"Restore Purchases" : {
|
||||||
"comment" : "A button that restores purchases.",
|
"comment" : "A button that restores purchases.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -2026,7 +2161,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ring Light" : {
|
"Ring Light" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"es-MX" : {
|
"es-MX" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@ -2121,6 +2255,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Ring light colors" : {
|
||||||
|
"comment" : "Description of a premium feature: Custom colors for the ring light.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ring Light Size" : {
|
"Ring Light Size" : {
|
||||||
"comment" : "The title of the slider that allows the user to select the size of their ring light.",
|
"comment" : "The title of the slider that allows the user to select the size of their ring light.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -2243,6 +2381,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Save your photos directly to your library for easy access and sharing." : {
|
||||||
|
"comment" : "Description of a photo library permission, emphasizing the ability to save photos directly to the user's library.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Saved to Photos" : {
|
"Saved to Photos" : {
|
||||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@ -2460,6 +2602,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SelfieCam" : {
|
||||||
|
"comment" : "The name of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"SelfieCam needs camera access to show your live preview, apply real-time filters, and capture high-quality photos." : {
|
||||||
|
"comment" : "Description of the photo library permission, highlighting the ability to save photos to the library.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"SelfieCam requires camera access to function. Please enable it in Settings to continue." : {
|
||||||
|
"comment" : "Description of the photo library permission error when the user has denied access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Set your preferences to get started quickly" : {
|
||||||
|
"comment" : "A description of the benefits of customizing the onboarding settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Settings" : {
|
"Settings" : {
|
||||||
"comment" : "The title of the settings screen.",
|
"comment" : "The title of the settings screen.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -2557,6 +2715,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Show onboarding flow on next app launch" : {
|
||||||
|
"comment" : "A description of what the \"Reset Onboarding\" button does.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Shows a grid overlay to help compose your shot" : {
|
"Shows a grid overlay to help compose your shot" : {
|
||||||
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -2702,6 +2864,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Skip for Now" : {
|
||||||
|
"comment" : "Text for a secondary button that allows the user to skip a permission request.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Soft Pink" : {
|
"Soft Pink" : {
|
||||||
"comment" : "Name of a ring light color preset.",
|
"comment" : "Name of a ring light color preset.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -2726,6 +2892,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Start Taking Photos" : {
|
||||||
|
"comment" : "Text for a button that lets them start taking selfies.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Subscribe to %@ for %@" : {
|
"Subscribe to %@ for %@" : {
|
||||||
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
|
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -2998,6 +3168,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"The camera framework requires microphone access to work. Please enable it in Settings to continue." : {
|
||||||
|
"comment" : "Description of the microphone access denial state for the camera permission.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"The camera requires microphone access to initialize properly. Audio is not recorded or stored." : {
|
||||||
|
"comment" : "Description of microphone access for the camera permission.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"The perfect selfie, every time" : {
|
||||||
|
"comment" : "A subtitle describing the main feature of the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Third-party libraries used in this app" : {
|
"Third-party libraries used in this app" : {
|
||||||
"comment" : "A description of the third-party libraries used in this app.",
|
"comment" : "A description of the third-party libraries used in this app.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -3070,6 +3252,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Unlock Pro Features" : {
|
||||||
|
"comment" : "Title of a section in the onboarding soft paywall that describes the benefits of upgrading to a premium account.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Upgrade to Pro" : {
|
"Upgrade to Pro" : {
|
||||||
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -3266,6 +3452,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Volume buttons, timers, gestures" : {
|
||||||
|
"comment" : "Subtitle for a feature row in the \"Easy Capture\" section of the OnboardingWelcomeView.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Warm Amber" : {
|
"Warm Amber" : {
|
||||||
"comment" : "Name of a ring light color preset.",
|
"comment" : "Name of a ring light color preset.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -3314,6 +3504,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Welcome to Pro!" : {
|
||||||
|
"comment" : "Title of the premium features tour shown to users who already have Pro access.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"When enabled, photos and videos are saved immediately after capture" : {
|
"When enabled, photos and videos are saved immediately after capture" : {
|
||||||
"comment" : "A hint provided by the \"Auto-Save\" toggle in the Settings view, explaining that photos and videos are saved immediately after capture when enabled.",
|
"comment" : "A hint provided by the \"Auto-Save\" toggle in the Settings view, explaining that photos and videos are saved immediately after capture when enabled.",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -3338,6 +3532,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Without photo library access, you can still share photos directly but won't be able to save them automatically." : {
|
||||||
|
"comment" : "Description of the photo library permission, emphasizing that users can still share photos but not save them automatically.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"You have access to all premium features" : {
|
||||||
|
"comment" : "Subtitle for the \"Welcome to Pro!\" header in the Premium features tour.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Zoom %@ times" : {
|
"Zoom %@ times" : {
|
||||||
"comment" : "A label describing the zoom level of the camera view. The argument is the string \"%.1f\".",
|
"comment" : "A label describing the zoom level of the camera view. The argument is the string \"%.1f\".",
|
||||||
"isCommentAutoGenerated" : true,
|
"isCommentAutoGenerated" : true,
|
||||||
|
|||||||
@ -2,7 +2,15 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
<string>$(TeamIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user