Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fb9a810262
commit
658423ee16
@ -1,9 +1,12 @@
|
||||
Use /ios-18-role
|
||||
read the PRD.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 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.
|
||||
|
||||
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 */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SelfieCam.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA836ACC2F0ACE8B00077F87 /* SelfieCam.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCam.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA836AD62F0ACE8B00077F87 /* SelfieCam.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCam.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Selfie Cam.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Selfie Cam.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; };
|
||||
EACONFIG003 /* SelfieCam/Configuration/Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SelfieCam/Configuration/Release.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
/* End PBXFileReference section */
|
||||
@ -99,9 +99,9 @@
|
||||
EA836AC02F0ACE8A00077F87 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */,
|
||||
EA836ACC2F0ACE8B00077F87 /* SelfieCam.xctest */,
|
||||
EA836AD62F0ACE8B00077F87 /* SelfieCam.xctest */,
|
||||
EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */,
|
||||
EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */,
|
||||
EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -141,7 +141,7 @@
|
||||
EA836AF52F0AD00000077F87 /* MijickCamera */,
|
||||
);
|
||||
productName = SelfieCam;
|
||||
productReference = EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */;
|
||||
productReference = EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */ = {
|
||||
@ -164,7 +164,7 @@
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = SelfieCamTests;
|
||||
productReference = EA836ACC2F0ACE8B00077F87 /* SelfieCam.xctest */;
|
||||
productReference = EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */ = {
|
||||
@ -187,7 +187,7 @@
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = SelfieCamUITests;
|
||||
productReference = EA836AD62F0ACE8B00077F87 /* SelfieCam.xctest */;
|
||||
productReference = EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@ -436,14 +436,14 @@
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_PublicAppName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
|
||||
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
|
||||
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_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_PublicAppName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
@ -478,14 +478,14 @@
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_PublicAppName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
|
||||
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
|
||||
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_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_PublicAppName = "$(PRODUCT_NAME)";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
|
||||
Binary file not shown.
@ -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()
|
||||
}
|
||||
@ -30,8 +30,7 @@ struct SelfieCamApp: App {
|
||||
.ignoresSafeArea()
|
||||
|
||||
AppLaunchView(config: .selfieCam) {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
RootView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -555,6 +555,9 @@ struct SettingsView: View {
|
||||
accentColor: AppAccent.primary
|
||||
)
|
||||
|
||||
// Reset Onboarding Button
|
||||
ResetOnboardingButton()
|
||||
|
||||
// Icon Generator
|
||||
SettingsNavigationRow(
|
||||
title: "Icon Generator",
|
||||
@ -581,6 +584,46 @@ struct SettingsView: View {
|
||||
#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%" : {
|
||||
"comment" : "A label displayed alongside the left edge of the opacity slider.",
|
||||
"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" : {
|
||||
"comment" : "A description of the ring light brightness slider.",
|
||||
"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%" : {
|
||||
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
||||
"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" : {
|
||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||
"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" : {
|
||||
"extractionState" : "stale",
|
||||
"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" : {
|
||||
"comment" : "A button label that closes the view.",
|
||||
"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" : {
|
||||
"comment" : "A description below the flash mode picker, explaining its purpose.",
|
||||
"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!" : {
|
||||
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
||||
"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" : {
|
||||
"comment" : "A description of the purpose of the \"Self-Timer\" setting in the settings screen.",
|
||||
"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" : {
|
||||
"comment" : "An accessibility label for the toggle that enables the \"Center Stage\" feature.",
|
||||
"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" : {
|
||||
"comment" : "Title of a toggle in the Settings view that allows the user to enable or disable the ring light overlay.",
|
||||
"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" : {
|
||||
"comment" : "A description of the photo quality setting.",
|
||||
"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" : {
|
||||
"comment" : "An accessibility hint for the \"True Mirror\" setting.",
|
||||
"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" : {
|
||||
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
|
||||
"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" : {
|
||||
"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",
|
||||
@ -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" : {
|
||||
"comment" : "Description of a benefit that is included with the Premium membership.",
|
||||
"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" : {
|
||||
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -1664,6 +1756,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"OK" : {
|
||||
"comment" : "Label for an OK button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"On" : {
|
||||
"comment" : "A value that describes a control item as \"On\".",
|
||||
"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" : {
|
||||
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
||||
"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" : {
|
||||
"extractionState" : "stale",
|
||||
"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" : {
|
||||
"comment" : "Title of a segmented picker that allows the user to select the photo quality.",
|
||||
"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." : {
|
||||
"comment" : "Announcement read out to the user when a premium purchase is successful.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -1952,6 +2083,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Reset Onboarding" : {
|
||||
"comment" : "A button label that resets the onboarding state.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore Purchases" : {
|
||||
"comment" : "A button that restores purchases.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@ -2026,7 +2161,6 @@
|
||||
}
|
||||
},
|
||||
"Ring Light" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"es-MX" : {
|
||||
"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" : {
|
||||
"comment" : "The title of the slider that allows the user to select the size of their ring light.",
|
||||
"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" : {
|
||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||
"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" : {
|
||||
"comment" : "The title of the settings screen.",
|
||||
"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" : {
|
||||
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
||||
"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" : {
|
||||
"comment" : "Name of a ring light color preset.",
|
||||
"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 %@" : {
|
||||
"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,
|
||||
@ -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" : {
|
||||
"comment" : "A description of the third-party libraries used in this app.",
|
||||
"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" : {
|
||||
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
||||
"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" : {
|
||||
"comment" : "Name of a ring light color preset.",
|
||||
"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" : {
|
||||
"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,
|
||||
@ -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" : {
|
||||
"comment" : "A label describing the zoom level of the camera view. The argument is the string \"%.1f\".",
|
||||
"isCommentAutoGenerated" : true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user