diff --git a/AGENTS.md b/AGENTS.md index 4fad121..ba65d94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. \ No newline at end of file +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 \ No newline at end of file diff --git a/SelfieCam.xcodeproj/project.pbxproj b/SelfieCam.xcodeproj/project.pbxproj index 58cb46b..698ad18 100644 --- a/SelfieCam.xcodeproj/project.pbxproj +++ b/SelfieCam.xcodeproj/project.pbxproj @@ -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 = ""; @@ -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; diff --git a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate index 031df7a..7939266 100644 Binary files a/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate and b/SelfieCam.xcodeproj/project.xcworkspace/xcuserdata/mattbruce.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/SelfieCam.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/SelfieCam.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..707f600 --- /dev/null +++ b/SelfieCam.xcodeproj/xcuserdata/mattbruce.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/SelfieCam/App/RootView.swift b/SelfieCam/App/RootView.swift new file mode 100644 index 0000000..23e05a8 --- /dev/null +++ b/SelfieCam/App/RootView.swift @@ -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() +} diff --git a/SelfieCam/App/SelfieCamApp.swift b/SelfieCam/App/SelfieCamApp.swift index 1dfbc83..ecfbc20 100644 --- a/SelfieCam/App/SelfieCamApp.swift +++ b/SelfieCam/App/SelfieCamApp.swift @@ -30,8 +30,7 @@ struct SelfieCamApp: App { .ignoresSafeArea() AppLaunchView(config: .selfieCam) { - ContentView() - .preferredColorScheme(.dark) + RootView() } } .onAppear { diff --git a/SelfieCam/Features/Onboarding/Components/OnboardingComponents.swift b/SelfieCam/Features/Onboarding/Components/OnboardingComponents.swift new file mode 100644 index 0000000..3f55e39 --- /dev/null +++ b/SelfieCam/Features/Onboarding/Components/OnboardingComponents.swift @@ -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: 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)) + } +} diff --git a/SelfieCam/Features/Onboarding/ViewModels/OnboardingViewModel.swift b/SelfieCam/Features/Onboarding/ViewModels/OnboardingViewModel.swift new file mode 100644 index 0000000..05da55a --- /dev/null +++ b/SelfieCam/Features/Onboarding/ViewModels/OnboardingViewModel.swift @@ -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) + } +} diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingContainerView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingContainerView.swift new file mode 100644 index 0000000..5827a74 --- /dev/null +++ b/SelfieCam/Features/Onboarding/Views/OnboardingContainerView.swift @@ -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) +} diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingPermissionView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingPermissionView.swift new file mode 100644 index 0000000..58a56ed --- /dev/null +++ b/SelfieCam/Features/Onboarding/Views/OnboardingPermissionView.swift @@ -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) +} diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingPremiumView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingPremiumView.swift new file mode 100644 index 0000000..9e53ba0 --- /dev/null +++ b/SelfieCam/Features/Onboarding/Views/OnboardingPremiumView.swift @@ -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) +} diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingSettingsView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingSettingsView.swift new file mode 100644 index 0000000..5f0db25 --- /dev/null +++ b/SelfieCam/Features/Onboarding/Views/OnboardingSettingsView.swift @@ -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) +} diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift new file mode 100644 index 0000000..dca3bcc --- /dev/null +++ b/SelfieCam/Features/Onboarding/Views/OnboardingSoftPaywallView.swift @@ -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) +} diff --git a/SelfieCam/Features/Onboarding/Views/OnboardingWelcomeView.swift b/SelfieCam/Features/Onboarding/Views/OnboardingWelcomeView.swift new file mode 100644 index 0000000..e04108a --- /dev/null +++ b/SelfieCam/Features/Onboarding/Views/OnboardingWelcomeView.swift @@ -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) +} diff --git a/SelfieCam/Features/Settings/Views/SettingsView.swift b/SelfieCam/Features/Settings/Views/SettingsView.swift index dcb5ce5..9152552 100644 --- a/SelfieCam/Features/Settings/Views/SettingsView.swift +++ b/SelfieCam/Features/Settings/Views/SettingsView.swift @@ -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 + diff --git a/SelfieCam/Resources/Localizable.xcstrings b/SelfieCam/Resources/Localizable.xcstrings index f0eed5c..6067634 100644 --- a/SelfieCam/Resources/Localizable.xcstrings +++ b/SelfieCam/Resources/Localizable.xcstrings @@ -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,