Compare commits

...

3 Commits

25 changed files with 1764 additions and 58 deletions

View File

@ -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

View File

@ -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 /* SelfieCamTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCamTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SelfieCamUITests.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 */
@ -100,8 +100,8 @@
isa = PBXGroup;
children = (
EA836ABF2F0ACE8A00077F87 /* Selfie Cam.app */,
EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */,
EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */,
EA836ACC2F0ACE8B00077F87 /* Selfie Cam.xctest */,
EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -164,7 +164,7 @@
packageProductDependencies = (
);
productName = SelfieCamTests;
productReference = EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.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 /* SelfieCamUITests.xctest */;
productReference = EA836AD62F0ACE8B00077F87 /* Selfie Cam.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
@ -436,9 +436,14 @@
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)";
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
INFOPLIST_KEY_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;
@ -451,7 +456,7 @@
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "$(PRODUCT_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@ -473,9 +478,14 @@
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_AppClipDomain = "$(APPCLIP_DOMAIN)";
INFOPLIST_KEY_AppGroupIdentifier = "$(APP_GROUP_IDENTIFIER)";
INFOPLIST_KEY_CFBundleDisplayName = "$(PRODUCT_NAME)";
INFOPLIST_KEY_CloudKitContainerIdentifier = "$(CLOUDKIT_CONTAINER_IDENTIFIER)";
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
INFOPLIST_KEY_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;
@ -488,7 +498,7 @@
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "$(PRODUCT_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
@ -510,7 +520,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "$(PRODUCT_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
@ -532,7 +542,7 @@
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(TESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "$(PRODUCT_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
@ -552,7 +562,7 @@
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(UITESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "$(PRODUCT_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
@ -572,7 +582,7 @@
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(UITESTS_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
PRODUCT_NAME = "$(PRODUCT_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;

View File

@ -36,7 +36,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA836ACB2F0ACE8B00077F87"
BuildableName = "SelfieCamTests.xctest"
BuildableName = "Selfie Cam.xctest"
BlueprintName = "SelfieCamTests"
ReferencedContainer = "container:SelfieCam.xcodeproj">
</BuildableReference>
@ -47,7 +47,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA836AD52F0ACE8B00077F87"
BuildableName = "SelfieCamUITests.xctest"
BuildableName = "Selfie Cam.xctest"
BlueprintName = "SelfieCamUITests"
ReferencedContainer = "container:SelfieCam.xcodeproj">
</BuildableReference>

View File

@ -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>

View 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()
}

View File

@ -12,6 +12,15 @@ import Bedrock
struct SelfieCamApp: App {
init() {
Design.showDebugLogs = true
// Register SelfieCam theme with Bedrock
Theme.register(
text: AppTextColors.self,
surface: AppSurface.self,
accent: AppAccent.self,
status: AppStatus.self
)
Theme.register(border: AppBorder.self)
}
var body: some Scene {
@ -21,8 +30,7 @@ struct SelfieCamApp: App {
.ignoresSafeArea()
AppLaunchView(config: .selfieCam) {
ContentView()
.preferredColorScheme(.dark)
RootView()
}
}
.onAppear {

View File

@ -0,0 +1,45 @@
import Foundation
enum AppIdentifiers {
// Read from Info.plist (values come from xcconfig)
static let publicAppName: String = {
Bundle.main.object(forInfoDictionaryKey: "PublicAppName") as? String
?? "SelfieCam"
}()
static let appGroupIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
?? "group.com.mbrucedogs.SelfieCam"
}()
static let cloudKitContainerIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
?? "iCloud.com.mbrucedogs.SelfieCam"
}()
static let appClipDomain: String = {
Bundle.main.object(forInfoDictionaryKey: "AppClipDomain") as? String
?? "yourapp.example.com"
}()
// Derived from bundle identifier
static var bundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "com.mbrucedogs.SelfieCam"
}
static var watchBundleIdentifier: String {
"\(bundleIdentifier).watchkitapp"
}
static var appClipBundleIdentifier: String {
"\(bundleIdentifier).Clip"
}
static var widgetBundleIdentifier: String {
"\(bundleIdentifier).Widget"
}
static func appClipURL(recordName: String) -> URL? {
URL(string: "https://\(appClipDomain)/appclip?id=\(recordName)")
}
}

View File

@ -6,13 +6,23 @@
// =============================================================================
COMPANY_IDENTIFIER = com.mbrucedogs
APP_NAME = SelfieCam
BUNDLE_ID_NAME = SelfieCam
PRODUCT_NAME = Selfie Cam
DEVELOPMENT_TEAM = 6R7KLBPBLZ
// =============================================================================
// DERIVED IDENTIFIERS - DO NOT EDIT
// =============================================================================
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp
APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip
WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Widget
INTENT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Intent
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)Tests
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)UITests
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(BUNDLE_ID_NAME)
APPCLIP_DOMAIN = yourapp.example.com

View File

@ -195,9 +195,9 @@ struct CameraContainerView: View, Equatable {
let cameraPosition: CameraPosition
let onImageCaptured: (UIImage) -> Void
// Only compare sessionKey and cameraPosition for equality
// Only compare sessionKey for equality - we want to handle position changes via runtime setCameraPosition
static func == (lhs: CameraContainerView, rhs: CameraContainerView) -> Bool {
lhs.sessionKey == rhs.sessionKey && lhs.cameraPosition == rhs.cameraPosition
lhs.sessionKey == rhs.sessionKey
}
var body: some View {

View File

@ -320,17 +320,17 @@ struct CustomCameraScreen: MCameraScreen {
// Initial haptic for countdown start
triggerHaptic(.light)
Task { @MainActor in
while countdownSeconds > 0 {
try? await Task.sleep(for: .seconds(1))
countdownSeconds -= 1
// Haptic tick for each countdown second
triggerHaptic(.light)
// Use a Timer to ensure we update on the main thread every second
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { timer in
if self.countdownSeconds > 1 {
self.countdownSeconds -= 1
self.triggerHaptic(.light)
} else {
timer.invalidate()
self.countdownSeconds = 0
self.isCountdownActive = false
self.performActualCapture()
}
// Countdown finished, perform capture
isCountdownActive = false
performActualCapture()
}
}
@ -542,16 +542,8 @@ struct CustomCameraScreen: MCameraScreen {
let newPosition: CameraPosition = cameraPosition == .front ? .back : .front
Design.debugLog("Double-tap: flipping camera to \(newPosition)")
Task {
do {
try await setCameraPosition(newPosition)
// Update settings to persist the change
// Update settings to persist the change - this will trigger the onChange in CustomCameraScreen
cameraSettings.cameraPosition = newPosition
currentCameraPosition = newPosition
} catch {
Design.debugLog("Failed to flip camera: \(error)")
}
}
}
// MARK: - Haptic Feedback

View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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)
}

View 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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -22,14 +22,14 @@ extension SettingsViewModel {
var cameraPosition: CameraPosition {
get {
// Access the data through cloudSync to ensure observation is tracked
let raw = cloudSync.data.cameraPositionRaw
let position: CameraPosition = raw == "front" ? .front : .back
Design.debugLog("cameraPosition getter: raw='\(raw)' -> \(position)")
return position
return raw == "back" ? .back : .front
}
set {
let rawValue = newValue == .front ? "front" : "back"
Design.debugLog("cameraPosition setter: \(newValue) -> raw='\(rawValue)'")
let rawValue = newValue == .back ? "back" : "front"
// Use updateSettings to ensure modificationCount is incremented and observers are notified
updateSettings { $0.cameraPositionRaw = rawValue }
}
}

View File

@ -137,6 +137,9 @@ final class SettingsViewModel: RingLightConfigurable {
/// Updates settings and saves to cloud immediately
func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
// Since we are using @Observable, we need to ensure the property access is tracked
// The cloudSync.update call modifies the data, but we need to trigger the observation
// We wrap the update in a way that SwiftUI's observation system can see the change
cloudSync.update { settings in
transform(&settings)
settings.modificationCount += 1

View File

@ -27,7 +27,7 @@ struct AppLicensesView: View {
var body: some View {
LicensesView(
licenses: Self.licenses,
backgroundColor: AppSurface.overlay,
backgroundColor: AppSurface.primary,
cardBackgroundColor: AppSurface.card,
cardBorderColor: AppBorder.subtle,
accentColor: AppAccent.primary

View File

@ -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

View File

@ -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,

View File

@ -2,7 +2,15 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<string>$(TeamIdentifierPrefix)$(APP_BUNDLE_IDENTIFIER)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>