initial commit

Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
Matt Bruce 2026-01-04 10:57:30 -06:00
commit 1be8c073d3
31 changed files with 3697 additions and 0 deletions

523
AGENTS.md Normal file
View File

@ -0,0 +1,523 @@
# Agent guide for Swift and SwiftUI
This repository contains an Xcode project written with Swift and SwiftUI. Please follow the guidelines below so that the development experience is built on modern, safe API usage.
## Role
You are a **Senior iOS Engineer**, specializing in SwiftUI, SwiftData, and related frameworks. Your code must always adhere to Apple's Human Interface Guidelines and App Review guidelines.
## Core instructions
- Target iOS 18.0 or later.
- Swift 6 or later, using modern Swift concurrency.
- SwiftUI backed up by `@Observable` classes for shared data.
- **Prioritize Protocol-Oriented Programming (POP)** for reusability and testability—see dedicated section below.
- Avoid UIKit unless requested.
## Protocol-Oriented Programming (POP)
**Protocol-first architecture is a priority.** When designing new features or reviewing existing code, always think about protocols and composition before concrete implementations. This enables code reuse across modules, easier testing, and cleaner architecture.
### When architecting new code:
1. **Start with the protocol**: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol.
2. **Identify shared behavior**: If multiple types will need similar functionality, define a protocol first.
3. **Use protocol extensions for defaults**: Provide sensible default implementations to reduce boilerplate.
4. **Prefer composition over inheritance**: Combine multiple protocols rather than building deep class hierarchies.
### When reviewing existing code for reuse:
1. **Look for duplicated patterns**: If you see similar logic across modules, extract a protocol to a shared location.
2. **Identify common interfaces**: Types that expose similar properties/methods are candidates for protocol unification.
3. **Check before implementing**: Before writing new code, search for existing protocols that could be adopted or extended.
4. **Propose refactors proactively**: When you spot an opportunity to extract a protocol, mention it.
### Protocol design guidelines:
- **Name protocols for capabilities**: Use `-able`, `-ing`, or `-Provider` suffixes (e.g., `Searchable`, `DataLoading`, `ContentProvider`).
- **Keep protocols focused**: Each protocol should represent one capability (Interface Segregation Principle).
- **Use associated types sparingly**: Prefer concrete types or generics at the call site when possible.
- **Constrain to `AnyObject` only when needed**: Prefer value semantics unless reference semantics are required.
### Examples
**❌ BAD - Concrete implementations without protocols:**
```swift
// Features/Users/UserListViewModel.swift
@Observable @MainActor
class UserListViewModel {
var items: [User] = []
var isLoading: Bool = false
func load() async { ... }
func refresh() async { ... }
}
// Features/Products/ProductListViewModel.swift - duplicates the same pattern
@Observable @MainActor
class ProductListViewModel {
var items: [Product] = []
var isLoading: Bool = false
func load() async { ... }
func refresh() async { ... }
}
```
**✅ GOOD - Protocol in shared module, adopted by features:**
```swift
// Shared/Protocols/DataLoading.swift
protocol DataLoading: AnyObject {
associatedtype Item: Identifiable
var items: [Item] { get set }
var isLoading: Bool { get set }
func load() async
func refresh() async
}
extension DataLoading {
func refresh() async {
items = []
await load()
}
}
// Features/Users/UserListViewModel.swift - adopts protocol
@Observable @MainActor
class UserListViewModel: DataLoading {
var items: [User] = []
var isLoading: Bool = false
func load() async { ... }
// refresh() comes from protocol extension
}
```
**❌ BAD - View only works with one concrete type:**
```swift
struct ItemListView: View {
@Bindable var viewModel: UserListViewModel
// Tightly coupled to Users
}
```
**✅ GOOD - View works with any DataLoading type:**
```swift
struct ItemListView<ViewModel: DataLoading & Observable>: View {
@Bindable var viewModel: ViewModel
// Reusable across all features
}
```
### Common protocols to consider extracting:
| Capability | Protocol Name | Shared By |
|------------|---------------|-----------|
| Loading data | `DataLoading` | All list features |
| Search/filter | `Searchable` | Features with search |
| Settings/config | `Configurable` | Features with settings |
| Pagination | `Paginating` | Large data sets |
| Form validation | `Validatable` | Input forms |
| Persistence | `Persistable` | Cached data |
### Refactoring checklist:
When you encounter code that could benefit from POP:
- [ ] Is this logic duplicated across multiple features?
- [ ] Could this type conform to an existing protocol in the shared module?
- [ ] Would extracting a protocol make this code testable in isolation?
- [ ] Can views be made generic over a protocol instead of a concrete type?
- [ ] Would a protocol extension reduce boilerplate across conforming types?
### Benefits:
- **Reusability**: Shared protocols work across all features
- **Testability**: Mock types can conform to protocols for unit testing
- **Flexibility**: New features can adopt existing protocols immediately
- **Maintainability**: Fix a bug in a protocol extension, fix it everywhere
- **Discoverability**: Protocols document the expected interface clearly
## Swift instructions
- Always mark `@Observable` classes with `@MainActor`.
- Assume strict Swift concurrency rules are being applied.
- Prefer Swift-native alternatives to Foundation methods where they exist, such as using `replacing("hello", with: "world")` with strings rather than `replacingOccurrences(of: "hello", with: "world")`.
- Prefer modern Foundation API, for example `URL.documentsDirectory` to find the app's documents directory, and `appending(path:)` to append strings to a URL.
- Never use C-style number formatting such as `Text(String(format: "%.2f", abs(myNumber)))`; always use `Text(abs(change), format: .number.precision(.fractionLength(2)))` instead.
- Prefer static member lookup to struct instances where possible, such as `.circle` rather than `Circle()`, and `.borderedProminent` rather than `BorderedProminentButtonStyle()`.
- Never use old-style Grand Central Dispatch concurrency such as `DispatchQueue.main.async()`. If behavior like this is needed, always use modern Swift concurrency.
- Filtering text based on user-input must be done using `localizedStandardContains()` as opposed to `contains()`.
- Avoid force unwraps and force `try` unless it is unrecoverable.
## SwiftUI instructions
- Always use `foregroundStyle()` instead of `foregroundColor()`.
- Always use `clipShape(.rect(cornerRadius:))` instead of `cornerRadius()`.
- Always use the `Tab` API instead of `tabItem()`.
- Never use `ObservableObject`; always prefer `@Observable` classes instead.
- Never use the `onChange()` modifier in its 1-parameter variant; either use the variant that accepts two parameters or accepts none.
- Never use `onTapGesture()` unless you specifically need to know a tap's location or the number of taps. All other usages should use `Button`.
- Never use `Task.sleep(nanoseconds:)`; always use `Task.sleep(for:)` instead.
- Never use `UIScreen.main.bounds` to read the size of the available space.
- Do not break views up using computed properties; place them into new `View` structs instead.
- Do not force specific font sizes; prefer using Dynamic Type instead.
- Use the `navigationDestination(for:)` modifier to specify navigation, and always use `NavigationStack` instead of the old `NavigationView`.
- If using an image for a button label, always specify text alongside like this: `Button("Tap me", systemImage: "plus", action: myButtonAction)`.
- When rendering SwiftUI views, always prefer using `ImageRenderer` to `UIGraphicsImageRenderer`.
- Don't apply the `fontWeight()` modifier unless there is good reason. If you want to make some text bold, always use `bold()` instead of `fontWeight(.bold)`.
- Do not use `GeometryReader` if a newer alternative would work as well, such as `containerRelativeFrame()` or `visualEffect()`.
- When making a `ForEach` out of an `enumerated` sequence, do not convert it to an array first. So, prefer `ForEach(x.enumerated(), id: \.element.id)` instead of `ForEach(Array(x.enumerated()), id: \.element.id)`.
- When hiding scroll view indicators, use the `.scrollIndicators(.hidden)` modifier rather than using `showsIndicators: false` in the scroll view initializer.
- Avoid `AnyView` unless it is absolutely required.
- **Never use raw numeric literals** for padding, spacing, opacity, font sizes, dimensions, corner radii, shadows, or animation durations—always use Design constants (see "No magic numbers" section).
- **Never use inline `Color(red:green:blue:)` or hex colors**—define all colors in a `Color` extension with semantic names.
- Avoid using UIKit colors in SwiftUI code.
## View/State separation (MVVM-lite)
**Views should be "dumb" renderers.** All business logic belongs in dedicated view models or state objects.
### What belongs in the State/ViewModel:
- **Business logic**: Calculations, validations, business rules
- **Computed properties based on data**: recommendations, derived values
- **State checks**: `isLoading`, `canSubmit`, `isFormValid`, `hasUnsavedChanges`
- **Data transformations**: filtering, sorting, aggregations
### What is acceptable in Views:
- **Pure UI layout logic**: `isIPad`, `maxContentWidth` based on size class
- **Visual styling**: color selection based on state (`statusColor`, `errorColor`)
- **@ViewBuilder sub-views**: breaking up complex layouts
- **Accessibility labels**: combining data into accessible descriptions
### Examples
**❌ BAD - Business logic in view:**
```swift
struct MyView: View {
@Bindable var viewModel: FormViewModel
private var isFormValid: Bool {
!viewModel.email.isEmpty && viewModel.email.contains("@")
}
private var formattedPrice: String? {
guard let price = viewModel.price else { return nil }
return viewModel.formatter.string(from: price)
}
}
```
**✅ GOOD - Logic in ViewModel, view just reads:**
```swift
// In ViewModel:
var isFormValid: Bool {
!email.isEmpty && email.contains("@") && password.count >= 8
}
var formattedPrice: String? {
guard let price = price else { return nil }
return formatter.string(from: price)
}
// In View:
Button("Submit", action: submit)
.disabled(!viewModel.isFormValid)
if let price = viewModel.formattedPrice { Text(price) }
```
### Benefits:
- **Testable**: ViewModel logic can be unit tested without UI
- **Single source of truth**: No duplicated logic across views
- **Cleaner views**: Views focus purely on layout and presentation
- **Easier debugging**: Logic is centralized, not scattered
## SwiftData instructions
If SwiftData is configured to use CloudKit:
- Never use `@Attribute(.unique)`.
- Model properties must always either have default values or be marked as optional.
- All relationships must be marked optional.
## Localization instructions
- Use **String Catalogs** (`.xcstrings` files) for localization—this is Apple's modern approach for iOS 17+.
- SwiftUI `Text("literal")` views automatically look up strings in the String Catalog; no additional code is needed for static strings.
- For strings outside of `Text` views or with dynamic content, use `String(localized:)` or create a helper extension:
```swift
extension String {
static func localized(_ key: String) -> String {
String(localized: String.LocalizationValue(key))
}
static func localized(_ key: String, _ arguments: CVarArg...) -> String {
let format = String(localized: String.LocalizationValue(key))
return String(format: format, arguments: arguments)
}
}
```
- For format strings with interpolation (e.g., "Items: %@"), define a key in the String Catalog and use `String.localized("key", value)`.
- Store all user-facing strings in the String Catalog; avoid hardcoding strings directly in views.
- Never use `NSLocalizedString`; prefer the modern `String(localized:)` API.
## No magic numbers or hardcoded values
**Never use raw numeric literals or hardcoded colors directly in views.** All values must be extracted to named constants, enums, or variables. This applies to:
### Values that MUST be constants:
- **Spacing & Padding**: `.padding(Design.Spacing.medium)` not `.padding(12)`
- **Corner Radii**: `Design.CornerRadius.large` not `cornerRadius: 16`
- **Font Sizes**: `Design.FontSize.body` not `size: 14`
- **Opacity Values**: `Design.Opacity.strong` not `.opacity(0.7)`
- **Colors**: `Color.Primary.accent` not `Color(red: 0.8, green: 0.6, blue: 0.2)`
- **Line Widths**: `Design.LineWidth.medium` not `lineWidth: 2`
- **Shadow Values**: `Design.Shadow.radiusLarge` not `radius: 10`
- **Animation Durations**: `Design.Animation.quick` not `duration: 0.3`
- **Component Sizes**: `Design.Size.iconMedium` not `frame(width: 32)`
### What to do when you see a magic number:
1. Check if an appropriate constant already exists in your design constants file
2. If not, add a new constant with a semantic name
3. Use the constant in place of the raw value
4. If it's truly view-specific and used only once, extract to a `private let` at the top of the view struct
### Examples of violations:
```swift
// ❌ BAD - Magic numbers everywhere
.padding(16)
.opacity(0.6)
.frame(width: 80, height: 52)
.shadow(radius: 10, y: 5)
Color(red: 0.25, green: 0.3, blue: 0.45)
// ✅ GOOD - Named constants
.padding(Design.Spacing.large)
.opacity(Design.Opacity.accent)
.frame(width: Design.Size.cardWidth, height: Design.Size.cardHeight)
.shadow(radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge)
Color.Primary.background
```
## Design constants instructions
- Create a centralized design constants file (e.g., `DesignConstants.swift`) using enums for namespacing:
```swift
enum Design {
enum Spacing {
static let xxSmall: CGFloat = 2
static let xSmall: CGFloat = 4
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let xLarge: CGFloat = 20
}
enum CornerRadius {
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
}
enum FontSize {
static let small: CGFloat = 10
static let body: CGFloat = 14
static let large: CGFloat = 18
static let title: CGFloat = 24
}
enum Opacity {
static let subtle: Double = 0.1
static let hint: Double = 0.2
static let light: Double = 0.3
static let medium: Double = 0.5
static let accent: Double = 0.6
static let strong: Double = 0.7
static let heavy: Double = 0.8
static let almostFull: Double = 0.9
}
enum LineWidth {
static let thin: CGFloat = 1
static let medium: CGFloat = 2
static let thick: CGFloat = 3
}
enum Shadow {
static let radiusSmall: CGFloat = 2
static let radiusMedium: CGFloat = 6
static let radiusLarge: CGFloat = 10
static let offsetSmall: CGFloat = 1
static let offsetMedium: CGFloat = 3
}
enum Animation {
static let quick: Double = 0.3
static let springDuration: Double = 0.4
static let staggerDelay1: Double = 0.1
static let staggerDelay2: Double = 0.25
}
}
```
- For colors used across the app, extend `Color` with semantic color definitions:
```swift
extension Color {
enum Primary {
static let background = Color(red: 0.1, green: 0.2, blue: 0.3)
static let accent = Color(red: 0.8, green: 0.6, blue: 0.2)
}
enum Button {
static let primaryLight = Color(red: 1.0, green: 0.85, blue: 0.3)
static let primaryDark = Color(red: 0.9, green: 0.7, blue: 0.2)
}
}
```
- Within each view, extract view-specific magic numbers to private constants at the top of the struct with a comment explaining why they're local:
```swift
struct MyView: View {
// Layout: fixed dimensions for consistent appearance
private let thumbnailSize: CGFloat = 45
// Typography: constrained space requires fixed size
private let headerFontSize: CGFloat = 18
// ...
}
```
- Reference design constants in views: `Design.Spacing.medium`, `Design.CornerRadius.large`, `Color.Primary.accent`.
- Keep design constants organized by category: Spacing, CornerRadius, FontSize, IconSize, Size, Animation, Opacity, LineWidth, Shadow.
- When adding new features, check existing constants first before creating new ones.
- Name constants semantically (what they represent) not literally (their value): `accent` not `pointSix`, `large` not `sixteen`.
## Dynamic Type instructions
- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling.
- Use `@ScaledMetric` to scale custom font sizes and dimensions based on user accessibility settings:
```swift
struct MyView: View {
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = 14
@ScaledMetric(relativeTo: .title) private var titleFontSize: CGFloat = 24
@ScaledMetric(relativeTo: .caption) private var captionSize: CGFloat = 11
var body: some View {
Text("Hello")
.font(.system(size: bodyFontSize, weight: .medium))
}
}
```
- Choose the appropriate `relativeTo` text style based on the semantic purpose:
- `.largeTitle`, `.title`, `.title2`, `.title3` for headings
- `.headline`, `.subheadline` for emphasized content
- `.body` for main content
- `.callout`, `.footnote`, `.caption`, `.caption2` for smaller text
- For constrained UI elements (icons, badges, compact layouts) where overflow would break the design, you may use fixed sizes but document the reason:
```swift
// Fixed size: badge has strict space constraints
private let badgeFontSize: CGFloat = 11
```
- Prefer system text styles when possible: `.font(.body)`, `.font(.title)`, `.font(.caption)`.
- Test with accessibility settings: Settings > Accessibility > Display & Text Size > Larger Text.
## VoiceOver accessibility instructions
- All interactive elements (buttons, selectable items) must have meaningful `.accessibilityLabel()`.
- Use `.accessibilityValue()` to communicate dynamic state (e.g., current selection, count, progress).
- Use `.accessibilityHint()` to describe what will happen when interacting with an element:
```swift
Button("Submit", action: submit)
.accessibilityHint("Submits the form and creates your account")
```
- Use `.accessibilityAddTraits()` to communicate element type:
- `.isButton` for tappable elements that aren't SwiftUI Buttons
- `.isHeader` for section headers
- `.isModal` for modal overlays
- `.updatesFrequently` for live-updating content
- Hide purely decorative elements from VoiceOver:
```swift
DecorationView()
.accessibilityHidden(true) // Decorative element
```
- Group related elements to reduce VoiceOver navigation complexity:
```swift
VStack {
titleLabel
subtitleLabel
statusIndicator
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Item details")
.accessibilityValue("Title: \(title). Status: \(status)")
```
- For complex elements, use `.accessibilityElement(children: .contain)` to allow navigation to children while adding context.
- Post accessibility announcements for important events:
```swift
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
UIAccessibility.post(notification: .announcement, argument: "Upload complete!")
}
```
- Provide accessibility names for model types that appear in UI:
```swift
enum Status {
var accessibilityName: String {
switch self {
case .pending: return String(localized: "Pending")
case .complete: return String(localized: "Complete")
// ...
}
}
}
```
- Test with VoiceOver enabled: Settings > Accessibility > VoiceOver.
## Project structure
- Use a consistent project structure, with folder layout determined by app features.
- Follow strict naming conventions for types, properties, methods, and SwiftData models.
- Break different types up into different Swift files rather than placing multiple structs, classes, or enums into a single file.
- Write unit tests for core application logic.
- Only write UI tests if unit tests are not possible.
- Add code comments and documentation comments as needed.
- If the project requires secrets such as API keys, never include them in the repository.
## Documentation instructions
### Reference documentation before starting work
Before implementing features or making changes, **always read the following files** for context:
- **`README.md`**: User-facing documentation with features, usage instructions, and known limitations
- **`AI_IMPLEMENTATION.md`**: Technical implementation guide with architecture patterns, code examples, and troubleshooting
These files contain important context about existing patterns, workarounds, and design decisions that should inform your implementation.
### Keep documentation up to date
- **Always update `README.md`** when adding user-facing features, changing usage patterns, or discovering new limitations.
- **Always update `AI_IMPLEMENTATION.md`** when:
- Adding new architectural patterns or components
- Implementing workarounds for library limitations
- Adding new troubleshooting guidance
- Changing how features are implemented
- Document new features, settings, or behaviors in the appropriate documentation files.
- Update documentation when modifying existing behavior.
- Include any configuration options, keyboard shortcuts, or special interactions.
- Documentation updates should be part of the same commit as the feature/change they document.
### Documentation checklist
When completing a task, verify:
- [ ] `README.md` reflects any user-facing changes
- [ ] `AI_IMPLEMENTATION.md` reflects any implementation changes
- [ ] New workarounds or patterns are documented for future reference
- [ ] Known limitations are listed if discovered
## PR instructions
- If installed, make sure SwiftLint returns no warnings or errors before committing.
- Verify that documentation reflects any new functionality or behavioral changes.

View File

@ -0,0 +1,661 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
EA836AF02F0AD00000077F87 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AEF2F0AD00000077F87 /* RevenueCat */; };
EA836AF22F0AD00000077F87 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AF12F0AD00000077F87 /* RevenueCatUI */; };
EA836AF42F0AD00000077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AF32F0AD00000077F87 /* Bedrock */; };
EA836AF62F0AD00000077F87 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AF52F0AD00000077F87 /* MijickCamera */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
EA836ACD2F0ACE8B00077F87 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA836AB72F0ACE8A00077F87 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EA836ABE2F0ACE8A00077F87;
remoteInfo = SelfieCam;
};
EA836AD72F0ACE8B00077F87 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EA836AB72F0ACE8A00077F87 /* Project object */;
proxyType = 1;
remoteGlobalIDString = EA836ABE2F0ACE8A00077F87;
remoteInfo = SelfieCam;
};
/* 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; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EA836AC12F0ACE8A00077F87 /* SelfieCam */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfieCam;
sourceTree = "<group>";
};
EA836ACF2F0ACE8B00077F87 /* SelfieCamTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfieCamTests;
sourceTree = "<group>";
};
EA836AD92F0ACE8B00077F87 /* SelfieCamUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = SelfieCamUITests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
EA836ABC2F0ACE8A00077F87 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EA836AF62F0AD00000077F87 /* MijickCamera in Frameworks */,
EA836AF42F0AD00000077F87 /* Bedrock in Frameworks */,
EA836AF02F0AD00000077F87 /* RevenueCat in Frameworks */,
EA836AF22F0AD00000077F87 /* RevenueCatUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
EA836AC92F0ACE8B00077F87 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA836AD32F0ACE8B00077F87 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
EA836AB62F0ACE8A00077F87 = {
isa = PBXGroup;
children = (
EA836AC12F0ACE8A00077F87 /* SelfieCam */,
EA836ACF2F0ACE8B00077F87 /* SelfieCamTests */,
EA836AD92F0ACE8B00077F87 /* SelfieCamUITests */,
EA836AC02F0ACE8A00077F87 /* Products */,
);
sourceTree = "<group>";
};
EA836AC02F0ACE8A00077F87 /* Products */ = {
isa = PBXGroup;
children = (
EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */,
EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */,
EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
EA836ABE2F0ACE8A00077F87 /* SelfieCam */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA836AE02F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCam" */;
buildPhases = (
EA836ABB2F0ACE8A00077F87 /* Sources */,
EA836ABC2F0ACE8A00077F87 /* Frameworks */,
EA836ABD2F0ACE8A00077F87 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EA836AC12F0ACE8A00077F87 /* SelfieCam */,
);
name = SelfieCam;
packageProductDependencies = (
EA836AEF2F0AD00000077F87 /* RevenueCat */,
EA836AF12F0AD00000077F87 /* RevenueCatUI */,
EA836AF32F0AD00000077F87 /* Bedrock */,
EA836AF52F0AD00000077F87 /* MijickCamera */,
);
productName = SelfieCam;
productReference = EA836ABF2F0ACE8A00077F87 /* SelfieCam.app */;
productType = "com.apple.product-type.application";
};
EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA836AE32F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamTests" */;
buildPhases = (
EA836AC82F0ACE8B00077F87 /* Sources */,
EA836AC92F0ACE8B00077F87 /* Frameworks */,
EA836ACA2F0ACE8B00077F87 /* Resources */,
);
buildRules = (
);
dependencies = (
EA836ACE2F0ACE8B00077F87 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA836ACF2F0ACE8B00077F87 /* SelfieCamTests */,
);
name = SelfieCamTests;
packageProductDependencies = (
);
productName = SelfieCamTests;
productReference = EA836ACC2F0ACE8B00077F87 /* SelfieCamTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */ = {
isa = PBXNativeTarget;
buildConfigurationList = EA836AE62F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamUITests" */;
buildPhases = (
EA836AD22F0ACE8B00077F87 /* Sources */,
EA836AD32F0ACE8B00077F87 /* Frameworks */,
EA836AD42F0ACE8B00077F87 /* Resources */,
);
buildRules = (
);
dependencies = (
EA836AD82F0ACE8B00077F87 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EA836AD92F0ACE8B00077F87 /* SelfieCamUITests */,
);
name = SelfieCamUITests;
packageProductDependencies = (
);
productName = SelfieCamUITests;
productReference = EA836AD62F0ACE8B00077F87 /* SelfieCamUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
EA836AB72F0ACE8A00077F87 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
EA836ABE2F0ACE8A00077F87 = {
CreatedOnToolsVersion = 26.0;
};
EA836ACB2F0ACE8B00077F87 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = EA836ABE2F0ACE8A00077F87;
};
EA836AD52F0ACE8B00077F87 = {
CreatedOnToolsVersion = 26.0;
TestTargetID = EA836ABE2F0ACE8A00077F87;
};
};
};
buildConfigurationList = EA836ABA2F0ACE8A00077F87 /* Build configuration list for PBXProject "SelfieCam" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = EA836AB62F0ACE8A00077F87;
minimizedProjectReferenceProxies = 1;
packageReferences = (
EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */,
EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EA836AC02F0ACE8A00077F87 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
EA836ABE2F0ACE8A00077F87 /* SelfieCam */,
EA836ACB2F0ACE8B00077F87 /* SelfieCamTests */,
EA836AD52F0ACE8B00077F87 /* SelfieCamUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
EA836ABD2F0ACE8A00077F87 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA836ACA2F0ACE8B00077F87 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA836AD42F0ACE8B00077F87 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
EA836ABB2F0ACE8A00077F87 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA836AC82F0ACE8B00077F87 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
EA836AD22F0ACE8B00077F87 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
EA836ACE2F0ACE8B00077F87 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EA836ABE2F0ACE8A00077F87 /* SelfieCam */;
targetProxy = EA836ACD2F0ACE8B00077F87 /* PBXContainerItemProxy */;
};
EA836AD82F0ACE8B00077F87 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EA836ABE2F0ACE8A00077F87 /* SelfieCam */;
targetProxy = EA836AD72F0ACE8B00077F87 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
EA836ADE2F0ACE8B00077F87 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
EA836ADF2F0ACE8B00077F87 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
EA836AE12F0ACE8B00077F87 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SelfieCam/SelfieCam.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to show your selfie preview and capture photos and videos.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record audio with your videos.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to save your captured photos and videos.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieCam;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EA836AE22F0ACE8B00077F87 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = SelfieCam/SelfieCam.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to show your selfie preview and capture photos and videos.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record audio with your videos.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to save your captured photos and videos.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieCam;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
EA836AE42F0ACE8B00077F87 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieCamTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SelfieCam.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieCam";
};
name = Debug;
};
EA836AE52F0ACE8B00077F87 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieCamTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SelfieCam.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SelfieCam";
};
name = Release;
};
EA836AE72F0ACE8B00077F87 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieCamUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = SelfieCam;
};
name = Debug;
};
EA836AE82F0ACE8B00077F87 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.SelfieCamUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = SelfieCam;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
EA836ABA2F0ACE8A00077F87 /* Build configuration list for PBXProject "SelfieCam" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA836ADE2F0ACE8B00077F87 /* Debug */,
EA836ADF2F0ACE8B00077F87 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA836AE02F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCam" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA836AE12F0ACE8B00077F87 /* Debug */,
EA836AE22F0ACE8B00077F87 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA836AE32F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA836AE42F0ACE8B00077F87 /* Debug */,
EA836AE52F0ACE8B00077F87 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EA836AE62F0ACE8B00077F87 /* Build configuration list for PBXNativeTarget "SelfieCamUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EA836AE72F0ACE8B00077F87 /* Debug */,
EA836AE82F0ACE8B00077F87 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/RevenueCat/purchases-ios-spm";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.52.1;
};
};
EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "http://192.168.1.128:3000/mbrucedogs/Bedrock";
requirement = {
branch = master;
kind = branch;
};
};
EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "http://192.168.1.128:3000/mbrucedogs/MijickCamera";
requirement = {
branch = develop;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
EA836AEF2F0AD00000077F87 /* RevenueCat */ = {
isa = XCSwiftPackageProductDependency;
package = EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
productName = RevenueCat;
};
EA836AF12F0AD00000077F87 /* RevenueCatUI */ = {
isa = XCSwiftPackageProductDependency;
package = EA836AEE2F0AD00000077F87 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
productName = RevenueCatUI;
};
EA836AF32F0AD00000077F87 /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EA836AF72F0AD00000077F87 /* XCRemoteSwiftPackageReference "Bedrock" */;
productName = Bedrock;
};
EA836AF52F0AD00000077F87 /* MijickCamera */ = {
isa = XCSwiftPackageProductDependency;
package = EA836AF82F0AD00000077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */;
productName = MijickCamera;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EA836AB72F0ACE8A00077F87 /* Project object */;
}

View File

@ -0,0 +1,42 @@
{
"originHash" : "f0492d428a7eee59a60d8a8f71928cd6379f7e9632aa4a32cbd1f1cea00a553b",
"pins" : [
{
"identity" : "bedrock",
"kind" : "remoteSourceControl",
"location" : "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git",
"state" : {
"branch" : "develop",
"revision" : "9f4046bfd2c23e76c30dfefe0ed164405b1b0ee8"
}
},
{
"identity" : "camera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Mijick/Camera",
"state" : {
"revision" : "0f02348fcc8fbbc9224c7fbf444f182dc25d0b40",
"version" : "3.0.3"
}
},
{
"identity" : "purchases-ios-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/RevenueCat/purchases-ios-spm",
"state" : {
"revision" : "6238361173aa15a02b99de0d6c003b24fa5fa444",
"version" : "5.52.1"
}
},
{
"identity" : "timer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Mijick/Timer",
"state" : {
"revision" : "342371c33c3f084d82a4818447ba77d858064c85",
"version" : "2.0.0"
}
}
],
"version" : 3
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>SelfieCam.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,17 @@
//
// SelfieCamApp.swift
// SelfieCam
//
// Created by Matt Bruce on 1/4/26.
//
import SwiftUI
@main
struct SelfieCamApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@ -0,0 +1,11 @@
// Debug.xcconfig
// Configuration for Debug builds
#include? "Secrets.xcconfig"
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
// CI/CD should set these via environment variables
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
// Expose the API key to Info.plist
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)

View File

@ -0,0 +1,11 @@
// Release.xcconfig
// Configuration for Release builds
#include? "Secrets.xcconfig"
// If Secrets.xcconfig doesn't exist (CI/CD), fall back to empty values
// CI/CD should set these via environment variables
REVENUECAT_API_KEY = $(REVENUECAT_API_KEY)
// Expose the API key to Info.plist
REVENUECAT_API_KEY_PLIST = $(REVENUECAT_API_KEY)

View File

@ -0,0 +1,10 @@
// Secrets.xcconfig
//
// ⚠️ DO NOT COMMIT THIS FILE TO VERSION CONTROL
// This file contains sensitive API keys and secrets.
//
// For CI/CD: Set these values via environment variables in your build system.
// RevenueCat API Key
// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key
REVENUECAT_API_KEY = your_revenuecat_public_api_key_here

View File

@ -0,0 +1,12 @@
// Secrets.xcconfig.template
//
// INSTRUCTIONS:
// 1. Copy this file to "Secrets.xcconfig" in the same directory
// 2. Replace the placeholder values with your actual API keys
// 3. NEVER commit Secrets.xcconfig to version control
//
// The actual Secrets.xcconfig file is gitignored for security.
// RevenueCat API Key
// Get this from: RevenueCat Dashboard > Project Settings > API Keys > Public App-Specific API Key
REVENUECAT_API_KEY = your_revenuecat_public_api_key_here

View File

@ -0,0 +1,106 @@
import SwiftUI
import MijickCamera
import Bedrock
/// Main camera view with ring light effect using MijickCamera
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var premiumManager = PremiumManager()
@State private var showSettings = false
@State private var showPaywall = false
// Post-capture state
@State private var capturedImage: UIImage?
@State private var capturedVideoURL: URL?
@State private var showPostCapture = false
/// Ring size clamped to reasonable max
private var effectiveRingSize: CGFloat {
let maxRing = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * 0.2
return min(settings.ringSize, maxRing)
}
var body: some View {
ZStack {
// Ring light background
settings.lightColor
.ignoresSafeArea()
// MijickCamera with default UI
MCamera()
.setCameraPosition(.front) // Default to front camera for selfies
.onImageCaptured { image, _ in
capturedImage = image
showPostCapture = true
}
.onVideoCaptured { url, _ in
capturedVideoURL = url
showPostCapture = true
}
.startSession()
.padding(.horizontal, effectiveRingSize)
.padding(.top, effectiveRingSize)
.padding(.bottom, effectiveRingSize)
// Settings button overlay (top right corner of camera area)
VStack {
HStack {
Spacer()
Button {
showSettings = true
} label: {
Image(systemName: "gearshape.fill")
.font(.title3)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial, in: Circle())
.shadow(radius: Design.Shadow.radiusSmall)
}
.accessibilityLabel("Settings")
}
.padding(.horizontal, effectiveRingSize + Design.Spacing.medium)
.padding(.top, effectiveRingSize + Design.Spacing.medium)
Spacer()
}
}
.ignoresSafeArea()
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
}
.fullScreenCover(isPresented: $showPostCapture) {
PostCapturePreviewView(
capturedImage: capturedImage,
capturedVideoURL: capturedVideoURL,
isAutoSaveEnabled: settings.isAutoSaveEnabled,
onRetake: {
capturedImage = nil
capturedVideoURL = nil
showPostCapture = false
},
onSave: {
saveCapture()
showPostCapture = false
}
)
}
}
// MARK: - Save Capture
private func saveCapture() {
if let image = capturedImage {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
capturedImage = nil
capturedVideoURL = nil
}
}
#Preview {
ContentView()
}

View File

@ -0,0 +1,36 @@
import SwiftUI
import Bedrock
// Grid Overlay as separate view
struct GridOverlay: View {
var isVisible: Bool
var body: some View {
if isVisible {
GeometryReader { geo in
Path { path in
let w = geo.size.width
let h = geo.size.height
let thirdW = w / 3
let thirdH = h / 3
// Vertical lines
path.move(to: CGPoint(x: thirdW, y: 0))
path.addLine(to: CGPoint(x: thirdW, y: h))
path.move(to: CGPoint(x: 2 * thirdW, y: 0))
path.addLine(to: CGPoint(x: 2 * thirdW, y: h))
// Horizontal lines
path.move(to: CGPoint(x: 0, y: thirdH))
path.addLine(to: CGPoint(x: w, y: thirdH))
path.move(to: CGPoint(x: 0, y: 2 * thirdH))
path.addLine(to: CGPoint(x: w, y: 2 * thirdH))
}
.stroke(Color.white, lineWidth: Design.Grid.lineWidth)
.opacity(Design.Grid.opacity)
}
.ignoresSafeArea()
.accessibilityHidden(true)
}
}
}

View File

@ -0,0 +1,232 @@
import SwiftUI
import AVKit
import Bedrock
// MARK: - Post Capture Preview View
/// Full-screen preview shown after photo/video capture
struct PostCapturePreviewView: View {
let capturedImage: UIImage?
let capturedVideoURL: URL?
let isAutoSaveEnabled: Bool
let onRetake: () -> Void
let onSave: () -> Void
@State private var player: AVPlayer?
@State private var showShareSheet = false
@State private var toastMessage: String?
var body: some View {
ZStack {
// Dark background
Color.black.ignoresSafeArea()
// Media preview
mediaPreview
// Controls overlay
VStack {
// Top bar with close button
topBar
Spacer()
// Bottom toolbar
bottomToolbar
}
// Toast notification
if let message = toastMessage {
toastView(message: message)
}
}
.onAppear {
setupVideoPlayerIfNeeded()
if isAutoSaveEnabled {
autoSave()
}
}
.onDisappear {
player?.pause()
}
.sheet(isPresented: $showShareSheet) {
if let image = capturedImage {
ShareSheet(items: [image])
} else if let url = capturedVideoURL {
ShareSheet(items: [url])
}
}
}
// MARK: - Media Preview
@ViewBuilder
private var mediaPreview: some View {
if let image = capturedImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.accessibilityLabel(String(localized: "Captured photo"))
} else if let _ = capturedVideoURL, let player {
VideoPlayer(player: player)
.onAppear {
player.play()
}
.accessibilityLabel(String(localized: "Captured video"))
} else {
ProgressView()
.tint(.white)
}
}
// MARK: - Top Bar
private var topBar: some View {
HStack {
Button {
onRetake()
} label: {
Image(systemName: "xmark")
.font(.title2)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Close preview"))
Spacer()
}
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.medium)
}
// MARK: - Bottom Toolbar
private var bottomToolbar: some View {
HStack(spacing: Design.Spacing.xLarge) {
// Retake button
ToolbarButton(
title: String(localized: "Retake"),
systemImage: "arrow.counterclockwise",
action: onRetake
)
Spacer()
// Save button (if not auto-saved)
if !isAutoSaveEnabled {
ToolbarButton(
title: String(localized: "Save"),
systemImage: "square.and.arrow.down",
action: {
onSave()
showToast(String(localized: "Saved to Photos"))
}
)
Spacer()
}
// Share button
ToolbarButton(
title: String(localized: "Share"),
systemImage: "square.and.arrow.up",
action: { showShareSheet = true }
)
}
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.vertical, Design.Spacing.large)
.background(.ultraThinMaterial)
}
// MARK: - Toast View
private func toastView(message: String) -> some View {
VStack {
Spacer()
Text(message)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(.ultraThinMaterial, in: .capsule)
.padding(.bottom, 100)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut, value: toastMessage)
}
// MARK: - Video Setup
private func setupVideoPlayerIfNeeded() {
guard let url = capturedVideoURL else { return }
player = AVPlayer(url: url)
}
// MARK: - Auto Save
private func autoSave() {
if let image = capturedImage {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
showToast(String(localized: "Saved to Photos"))
}
// Video saving would go here
}
private func showToast(_ message: String) {
withAnimation {
toastMessage = message
}
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation {
toastMessage = nil
}
}
}
}
// MARK: - Toolbar Button
private struct ToolbarButton: View {
let title: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.xxSmall) {
Image(systemName: systemImage)
.font(.title2)
Text(title)
.font(.system(size: Design.BaseFontSize.caption))
}
.foregroundStyle(.white)
}
.accessibilityLabel(title)
}
}
// MARK: - Share Sheet
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
PostCapturePreviewView(
capturedImage: UIImage(systemName: "photo"),
capturedVideoURL: nil,
isAutoSaveEnabled: false,
onRetake: {},
onSave: {}
)
}

View File

@ -0,0 +1,137 @@
import SwiftUI
import RevenueCat
import Bedrock
struct ProPaywallView: View {
@State private var manager = PremiumManager()
@Environment(\.dismiss) private var dismiss
@ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = Design.BaseFontSize.body
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.xLarge) {
// Crown icon
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.hero))
.foregroundStyle(.yellow)
Text(String(localized: "Go Pro"))
.font(.system(size: Design.BaseFontSize.title, weight: .bold))
// Benefits list
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
BenefitRow(image: "paintpalette", text: String(localized: "All Color Presets + Custom Colors"))
BenefitRow(image: "sparkles", text: String(localized: "Advanced Beauty Filters"))
BenefitRow(image: "gradient", text: String(localized: "Directional Gradient Lighting"))
BenefitRow(image: "wand.and.stars", text: String(localized: "Unlimited Boomerang Length"))
BenefitRow(image: "checkmark.seal", text: String(localized: "No Watermarks • Ad-Free"))
}
.frame(maxWidth: .infinity, alignment: .leading)
// Product packages
if manager.availablePackages.isEmpty {
ProgressView()
.padding()
} else {
ForEach(manager.availablePackages, id: \.identifier) { package in
ProductPackageButton(
package: package,
isPremiumUnlocked: manager.isPremiumUnlocked,
onPurchase: {
Task {
_ = try? await manager.purchase(package)
if manager.isPremiumUnlocked {
dismiss()
}
}
}
)
}
}
// Restore purchases
Button(String(localized: "Restore Purchases")) {
Task { try? await manager.restorePurchases() }
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(Design.Spacing.large)
}
.background(Color.Surface.overlay)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "Cancel")) { dismiss() }
.foregroundStyle(.white)
}
}
}
.font(.system(size: bodyFontSize))
.task { try? await manager.loadProducts() }
}
}
// MARK: - Product Package Button
private struct ProductPackageButton: View {
let package: Package
let isPremiumUnlocked: Bool
let onPurchase: () -> Void
var body: some View {
Button(action: onPurchase) {
VStack(spacing: Design.Spacing.small) {
Text(package.storeProduct.localizedTitle)
.font(.headline)
.foregroundStyle(.white)
Text(package.localizedPriceString)
.font(.title2.bold())
.foregroundStyle(.white)
if package.packageType == .annual {
Text(String(localized: "Best Value • Save 33%"))
.font(.caption)
.foregroundStyle(.white.opacity(Design.Opacity.accent))
}
}
.frame(maxWidth: .infinity)
.padding(Design.Spacing.large)
.background(Color.Accent.primary.opacity(Design.Opacity.medium))
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.strokeBorder(Color.Accent.primary, lineWidth: Design.LineWidth.thin)
)
}
.accessibilityLabel(String(localized: "Subscribe to \(package.storeProduct.localizedTitle) for \(package.localizedPriceString)"))
}
}
// MARK: - Benefit Row
struct BenefitRow: View {
let image: String
let text: String
var body: some View {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: image)
.font(.title2)
.foregroundStyle(Color.Accent.primary)
.frame(width: Design.IconSize.xLarge)
Text(text)
.foregroundStyle(.white)
Spacer()
}
}
}
#Preview {
ProPaywallView()
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,600 @@
import SwiftUI
import Bedrock
struct SettingsView: View {
@Bindable var viewModel: SettingsViewModel
@Binding var showPaywall: Bool
@Environment(\.dismiss) private var dismiss
@State private var premiumManager = PremiumManager()
/// Whether premium features are unlocked (for UI gating)
private var isPremiumUnlocked: Bool {
premiumManager.isPremiumUnlocked
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Design.Spacing.medium) {
// MARK: - Ring Light Section
SettingsSectionHeader(title: "Ring Light", systemImage: "light.max")
// Ring Size Slider
ringSizeSlider
// Color Preset
colorPresetSection
// MARK: - Camera Section
SettingsSectionHeader(title: "Camera", systemImage: "camera")
SettingsToggle(
title: String(localized: "Front Flash"),
subtitle: String(localized: "Hides preview during capture for a flash effect"),
isOn: $viewModel.isFrontFlashEnabled
)
.accessibilityHint(String(localized: "Uses the ring light as a flash when taking photos"))
SettingsToggle(
title: String(localized: "True Mirror"),
subtitle: String(localized: "Shows non-flipped preview like a real mirror"),
isOn: $viewModel.isMirrorFlipped
)
.accessibilityHint(String(localized: "When enabled, the preview is not mirrored"))
SettingsToggle(
title: String(localized: "Skin Smoothing"),
subtitle: String(localized: "Applies subtle real-time smoothing"),
isOn: $viewModel.isSkinSmoothingEnabled
)
.accessibilityHint(String(localized: "Applies light skin smoothing to the camera preview"))
SettingsToggle(
title: String(localized: "Grid Overlay"),
subtitle: String(localized: "Shows rule of thirds grid"),
isOn: $viewModel.isGridVisible
)
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
// Timer Selection
timerPicker
// MARK: - Capture Section
SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle")
SettingsToggle(
title: String(localized: "Auto-Save"),
subtitle: String(localized: "Automatically save captures to Photo Library"),
isOn: $viewModel.isAutoSaveEnabled
)
.accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture"))
// MARK: - Pro Section
SettingsSectionHeader(title: "Pro", systemImage: "crown")
proSection
// MARK: - Sync Section
SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud")
iCloudSyncSection
// MARK: - About Section
SettingsSectionHeader(title: "About", systemImage: "info.circle")
acknowledgmentsSection
Spacer(minLength: Design.Spacing.xxxLarge)
}
.padding(.horizontal, Design.Spacing.large)
}
.background(Color.Surface.overlay)
.navigationTitle(String(localized: "Settings"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(String(localized: "Done")) {
dismiss()
}
.foregroundStyle(Color.Accent.primary)
}
}
}
}
// MARK: - Ring Size Slider
private var ringSizeSlider: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
HStack {
Text(String(localized: "Ring Size"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(viewModel.ringSize))pt")
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
HStack(spacing: Design.Spacing.medium) {
// Small ring icon
Image(systemName: "circle")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Slider(
value: $viewModel.ringSize,
in: SettingsViewModel.minRingSize...SettingsViewModel.maxRingSize,
step: 5
)
.tint(Color.Accent.primary)
// Large ring icon
Image(systemName: "circle")
.font(.system(size: Design.BaseFontSize.large))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Text(String(localized: "Adjusts the size of the light ring around the camera preview"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(.vertical, Design.Spacing.xSmall)
.accessibilityLabel(String(localized: "Ring size"))
.accessibilityValue("\(Int(viewModel.ringSize)) points")
}
// MARK: - Color Preset Section
private var colorPresetSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(String(localized: "Light Color"))
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
.foregroundStyle(.white)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 80), spacing: Design.Spacing.small)],
spacing: Design.Spacing.small
) {
// Preset colors
ForEach(RingLightColor.allPresets) { preset in
ColorPresetButton(
preset: preset,
isSelected: viewModel.selectedLightColor == preset,
isPremiumUnlocked: isPremiumUnlocked
) {
// Premium colors require unlock
if preset.isPremium && !isPremiumUnlocked {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
} else {
viewModel.selectedLightColor = preset
}
}
}
// Custom color picker (premium) - one-step: opens picker, selects on change
CustomColorPickerButton(
customColor: Binding(
get: { viewModel.customColor },
set: { viewModel.selectCustomColor($0) }
),
isSelected: viewModel.isCustomColorSelected,
isPremiumUnlocked: isPremiumUnlocked,
onPremiumRequired: {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
}
)
}
}
.padding(.vertical, Design.Spacing.xSmall)
}
// MARK: - Timer Picker
private var timerPicker: some View {
SegmentedPicker(
title: String(localized: "Self-Timer"),
options: TimerOption.allCases.map { ($0.displayName, $0) },
selection: $viewModel.selectedTimer
)
.accessibilityLabel(String(localized: "Select self-timer duration"))
}
// MARK: - Pro Section
private var proSection: some View {
Button {
dismiss()
// Small delay to allow sheet to dismiss before showing paywall
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
showPaywall = true
}
} label: {
HStack(spacing: Design.Spacing.medium) {
Image(systemName: "crown.fill")
.font(.title2)
.foregroundStyle(Color.Status.warning)
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "Upgrade to Pro"))
.font(.system(size: Design.BaseFontSize.medium, weight: .semibold))
.foregroundStyle(.white)
Text(String(localized: "Unlock premium colors, video, and more"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer()
Image(systemName: "chevron.right")
.font(.body)
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(Color.Accent.primary.opacity(Design.Opacity.subtle))
.strokeBorder(Color.Accent.primary.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.thin)
)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Upgrade to Pro"))
.accessibilityHint(String(localized: "Opens upgrade options"))
}
// MARK: - iCloud Sync Section
private var iCloudSyncSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
// Sync toggle
SettingsToggle(
title: String(localized: "Sync Settings"),
subtitle: viewModel.iCloudAvailable
? String(localized: "Sync settings across all your devices")
: String(localized: "Sign in to iCloud to enable sync"),
isOn: $viewModel.iCloudEnabled
)
.disabled(!viewModel.iCloudAvailable)
// Sync status
if viewModel.iCloudEnabled && viewModel.iCloudAvailable {
HStack(spacing: Design.Spacing.small) {
Image(systemName: syncStatusIcon)
.font(.system(size: Design.BaseFontSize.body))
.foregroundStyle(syncStatusColor)
Text(syncStatusText)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Spacer()
Button {
viewModel.forceSync()
} label: {
Text(String(localized: "Sync Now"))
.font(.system(size: Design.BaseFontSize.caption, weight: .medium))
.foregroundStyle(Color.Accent.primary)
}
}
.padding(.top, Design.Spacing.xSmall)
}
}
}
// MARK: - Sync Status Helpers
private var syncStatusIcon: String {
if !viewModel.hasCompletedInitialSync {
return "arrow.triangle.2.circlepath"
}
return viewModel.syncStatus.isEmpty ? "checkmark.icloud" : "icloud"
}
private var syncStatusColor: Color {
if !viewModel.hasCompletedInitialSync {
return Color.Status.warning
}
return Color.Status.success
}
private var syncStatusText: String {
if !viewModel.hasCompletedInitialSync {
return String(localized: "Syncing...")
}
if let lastSync = viewModel.lastSyncDate {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
return String(localized: "Last synced \(formatter.localizedString(for: lastSync, relativeTo: Date()))")
}
return viewModel.syncStatus.isEmpty
? String(localized: "Synced")
: viewModel.syncStatus
}
// MARK: - Acknowledgments Section
private var acknowledgmentsSection: some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
NavigationLink {
LicensesView()
} label: {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(String(localized: "Open Source Licenses"))
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
Text(String(localized: "Third-party libraries used in this app"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.medium)
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
}
.buttonStyle(.plain)
}
}
}
// MARK: - Licenses View
struct LicensesView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
// MijickCamera
licenseCard(
name: "MijickCamera",
url: "https://github.com/Mijick/Camera",
license: "Apache 2.0 License",
description: "Camera framework for SwiftUI. Created by Tomasz Kurylik at Mijick."
)
// RevenueCat
licenseCard(
name: "RevenueCat",
url: "https://github.com/RevenueCat/purchases-ios",
license: "MIT License",
description: "In-app subscriptions made easy."
)
}
.padding(Design.Spacing.large)
}
.background(Color.Surface.overlay)
.navigationTitle(String(localized: "Open Source Licenses"))
.navigationBarTitleDisplayMode(.inline)
}
private func licenseCard(name: String, url: String, license: String, description: String) -> some View {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
Text(name)
.font(.system(size: Design.BaseFontSize.medium, weight: .bold))
.foregroundStyle(.white)
Text(description)
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
HStack {
Label(license, systemImage: "doc.text")
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(Color.Accent.primary)
Spacer()
if let linkURL = URL(string: url) {
Link(destination: linkURL) {
Label(String(localized: "View on GitHub"), systemImage: "arrow.up.right.square")
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(Color.Accent.primary)
}
}
}
.padding(.top, Design.Spacing.xSmall)
}
.padding(Design.Spacing.medium)
.background(Color.Surface.primary, in: RoundedRectangle(cornerRadius: Design.CornerRadius.medium))
}
}
// MARK: - Color Preset Button
private struct ColorPresetButton: View {
let preset: RingLightColor
let isSelected: Bool
let isPremiumUnlocked: Bool
let action: () -> Void
/// Whether this premium color is locked (not available)
private var isLocked: Bool {
preset.isPremium && !isPremiumUnlocked
}
var body: some View {
Button(action: action) {
VStack(spacing: Design.Spacing.xxSmall) {
ZStack {
Circle()
.fill(preset.color)
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay(
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: preset.color.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
)
// Lock overlay for locked premium colors
if isLocked {
Circle()
.fill(.black.opacity(Design.Opacity.medium))
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white)
}
}
Text(preset.name)
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(isSelected ? 1.0 : (isLocked ? Design.Opacity.medium : Design.Opacity.accent)))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
if preset.isPremium {
Image(systemName: isPremiumUnlocked ? "crown.fill" : "crown")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(isPremiumUnlocked ? Color.Status.warning : Color.Status.warning.opacity(Design.Opacity.medium))
}
}
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityLabel(preset.name)
.accessibilityAddTraits(isSelected ? .isSelected : [])
.accessibilityHint(isLocked ? String(localized: "Locked. Tap to unlock with Pro.") : (preset.isPremium ? String(localized: "Premium color") : ""))
}
}
// MARK: - Custom Color Picker Button
/// Custom color picker with premium gating
private struct CustomColorPickerButton: View {
@Binding var customColor: Color
let isSelected: Bool
let isPremiumUnlocked: Bool
let onPremiumRequired: () -> Void
/// Whether the custom color is locked
private var isLocked: Bool { !isPremiumUnlocked }
var body: some View {
if isPremiumUnlocked {
// Premium users get the full color picker
VStack(spacing: Design.Spacing.xxSmall) {
ColorPicker(
selection: $customColor,
supportsOpacity: false
) {
EmptyView()
}
.labelsHidden()
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.clipShape(.circle)
.overlay(
Circle()
.strokeBorder(
isSelected ? Color.Accent.primary : Color.Border.subtle,
lineWidth: isSelected ? Design.LineWidth.thick : Design.LineWidth.thin
)
)
.shadow(
color: customColor.opacity(Design.Opacity.light),
radius: isSelected ? Design.Shadow.radiusSmall : 0
)
Text(String(localized: "Custom"))
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(isSelected ? 1.0 : Design.Opacity.accent))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Image(systemName: "crown.fill")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning)
}
.padding(Design.Spacing.xSmall)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(isSelected ? Color.Accent.primary.opacity(Design.Opacity.subtle) : Color.clear)
)
.accessibilityLabel(String(localized: "Custom color"))
.accessibilityAddTraits(isSelected ? .isSelected : [])
} else {
// Non-premium users see a locked button that shows paywall
Button(action: onPremiumRequired) {
VStack(spacing: Design.Spacing.xxSmall) {
ZStack {
// Rainbow gradient to show what's possible
Circle()
.fill(
AngularGradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .red],
center: .center
)
)
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
.overlay(
Circle()
.strokeBorder(Color.Border.subtle, lineWidth: Design.LineWidth.thin)
)
// Lock overlay
Circle()
.fill(.black.opacity(Design.Opacity.medium))
.frame(width: Design.Size.avatarSmall, height: Design.Size.avatarSmall)
Image(systemName: "lock.fill")
.font(.system(size: Design.BaseFontSize.small))
.foregroundStyle(.white)
}
Text(String(localized: "Custom"))
.font(.system(size: Design.BaseFontSize.xSmall))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
.lineLimit(1)
.minimumScaleFactor(Design.MinScaleFactor.tight)
Image(systemName: "crown")
.font(.system(size: Design.BaseFontSize.xxSmall))
.foregroundStyle(Color.Status.warning.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.xSmall)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Custom color"))
.accessibilityHint(String(localized: "Locked. Tap to unlock with Pro."))
}
}
}
#Preview {
SettingsView(viewModel: SettingsViewModel(), showPaywall: .constant(false))
.preferredColorScheme(.dark)
}

View File

@ -0,0 +1,314 @@
import SwiftUI
import Bedrock
// MARK: - Timer Options
enum TimerOption: String, CaseIterable, Identifiable {
case off = "off"
case three = "3"
case five = "5"
case ten = "10"
var id: String { rawValue }
var displayName: String {
switch self {
case .off: return String(localized: "Off")
case .three: return String(localized: "3s")
case .five: return String(localized: "5s")
case .ten: return String(localized: "10s")
}
}
var seconds: Int {
switch self {
case .off: return 0
case .three: return 3
case .five: return 5
case .ten: return 10
}
}
}
// MARK: - Capture Mode
enum CaptureMode: String, CaseIterable, Identifiable {
case photo = "photo"
case video = "video"
case boomerang = "boomerang"
var id: String { rawValue }
var displayName: String {
switch self {
case .photo: return String(localized: "Photo")
case .video: return String(localized: "Video")
case .boomerang: return String(localized: "Boomerang")
}
}
var systemImage: String {
switch self {
case .photo: return "camera.fill"
case .video: return "video.fill"
case .boomerang: return "arrow.2.squarepath"
}
}
var isPremium: Bool {
switch self {
case .photo: return false
case .video, .boomerang: return true
}
}
}
// MARK: - Settings ViewModel
/// Observable settings view model with iCloud sync across all devices.
/// Uses Bedrock's CloudSyncManager for automatic synchronization.
@MainActor
@Observable
final class SettingsViewModel: RingLightConfigurable {
// MARK: - Ring Size Limits
/// Minimum ring border size in points
static let minRingSize: CGFloat = 10
/// Maximum ring border size in points
static let maxRingSize: CGFloat = 120
/// Default ring border size
static let defaultRingSize: CGFloat = 40
// MARK: - Cloud Sync Manager
/// Manages iCloud sync for settings across all devices
private let cloudSync = CloudSyncManager<SyncedSettings>()
/// Debounce task for slider values
private var debounceTask: Task<Void, Never>?
/// Debounce delay for continuous slider updates (in seconds)
private static let debounceDelay: Duration = .milliseconds(300)
/// Cached ring size for immediate UI updates (before debounced save)
private var _cachedRingSize: CGFloat?
// MARK: - Observable Properties (Synced)
/// Ring border size in points (debounced save)
var ringSize: CGFloat {
get { _cachedRingSize ?? cloudSync.data.ringSize }
set {
_cachedRingSize = newValue
debouncedSave(key: "ringSize") {
self._cachedRingSize = nil
self.updateSettings { $0.ringSize = newValue }
}
}
}
/// Cached light color ID for immediate UI updates
private var _cachedLightColorId: String?
/// ID of the selected light color preset
var lightColorId: String {
get { _cachedLightColorId ?? cloudSync.data.lightColorId }
set {
_cachedLightColorId = newValue
updateSettings { $0.lightColorId = newValue }
}
}
/// Cached custom color for immediate UI updates
private var _cachedCustomColor: Color?
/// Custom color for ring light (premium feature, debounced save)
var customColor: Color {
get {
_cachedCustomColor ?? Color(
red: cloudSync.data.customColorRed,
green: cloudSync.data.customColorGreen,
blue: cloudSync.data.customColorBlue
)
}
set {
_cachedCustomColor = newValue
let rgb = CustomColorRGB(from: newValue)
debouncedSave(key: "customColor") {
self._cachedCustomColor = nil
self.updateSettings {
$0.customColorRed = rgb.red
$0.customColorGreen = rgb.green
$0.customColorBlue = rgb.blue
}
}
}
}
/// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool {
get { cloudSync.data.isFrontFlashEnabled }
set { updateSettings { $0.isFrontFlashEnabled = newValue } }
}
/// Whether the camera preview is flipped to show a true mirror
var isMirrorFlipped: Bool {
get { cloudSync.data.isMirrorFlipped }
set { updateSettings { $0.isMirrorFlipped = newValue } }
}
/// Whether skin smoothing filter is enabled
var isSkinSmoothingEnabled: Bool {
get { cloudSync.data.isSkinSmoothingEnabled }
set { updateSettings { $0.isSkinSmoothingEnabled = newValue } }
}
/// Whether the grid overlay is visible
var isGridVisible: Bool {
get { cloudSync.data.isGridVisible }
set { updateSettings { $0.isGridVisible = newValue } }
}
/// Current camera zoom factor
var currentZoomFactor: Double {
get { cloudSync.data.currentZoomFactor }
set { updateSettings { $0.currentZoomFactor = newValue } }
}
/// Whether captures are auto-saved to Photo Library
var isAutoSaveEnabled: Bool {
get { cloudSync.data.isAutoSaveEnabled }
set { updateSettings { $0.isAutoSaveEnabled = newValue } }
}
// MARK: - Computed Properties
/// Convenience property for border width (same as ringSize)
var borderWidth: CGFloat { ringSize }
var selectedTimer: TimerOption {
get { TimerOption(rawValue: cloudSync.data.selectedTimerRaw) ?? .off }
set { updateSettings { $0.selectedTimerRaw = newValue.rawValue } }
}
var selectedCaptureMode: CaptureMode {
get { CaptureMode(rawValue: cloudSync.data.selectedCaptureModeRaw) ?? .photo }
set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } }
}
var selectedLightColor: RingLightColor {
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
set {
lightColorId = newValue.id
if newValue.isCustom {
customColor = newValue.color
}
}
}
var lightColor: Color {
if lightColorId == RingLightColor.customId {
return customColor
}
return selectedLightColor.color
}
/// Whether custom color is currently selected
var isCustomColorSelected: Bool {
lightColorId == RingLightColor.customId
}
/// Sets the custom color and selects it
func selectCustomColor(_ color: Color) {
customColor = color
lightColorId = RingLightColor.customId
}
// MARK: - Sync Status
/// Whether iCloud sync is available
var iCloudAvailable: Bool { cloudSync.iCloudAvailable }
/// Whether iCloud sync is enabled
var iCloudEnabled: Bool {
get { cloudSync.iCloudEnabled }
set { cloudSync.iCloudEnabled = newValue }
}
/// Last sync date
var lastSyncDate: Date? { cloudSync.lastSyncDate }
/// Current sync status message
var syncStatus: String { cloudSync.syncStatus }
/// Whether initial sync has completed
var hasCompletedInitialSync: Bool { cloudSync.hasCompletedInitialSync }
// MARK: - Initialization
init() {
// CloudSyncManager handles syncing automatically
}
// MARK: - Private Methods
/// Updates settings and saves to cloud immediately
private func updateSettings(_ transform: (inout SyncedSettings) -> Void) {
cloudSync.update { settings in
transform(&settings)
settings.modificationCount += 1
}
}
/// Debounces save operations for continuous values like sliders
private func debouncedSave(key: String, action: @escaping () -> Void) {
// Cancel any pending debounce
debounceTask?.cancel()
// Schedule debounced save
debounceTask = Task {
try? await Task.sleep(for: Self.debounceDelay)
guard !Task.isCancelled else { return }
action()
}
}
// MARK: - Sync Actions
/// Forces a sync with iCloud
func forceSync() {
cloudSync.sync()
}
/// Resets all settings to defaults
func resetToDefaults() {
cloudSync.reset()
}
// MARK: - Validation
var isValidConfiguration: Bool {
ringSize >= Self.minRingSize
}
}
// MARK: - CaptureControlling Conformance
extension SettingsViewModel: CaptureControlling {
func startCountdown() async {
// Countdown handled by CameraViewModel
}
func performCapture() {
// Capture handled by CameraViewModel
}
func performFlashBurst() async {
// Flash handled by CameraViewModel
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,315 @@
{
"sourceLanguage" : "en",
"strings" : {
"%lld points" : {
"comment" : "The value of the ring size slider, displayed in parentheses.",
"isCommentAutoGenerated" : true
},
"%lldpt" : {
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
"isCommentAutoGenerated" : true
},
"3s" : {
"comment" : "Display name for the \"3 seconds\" timer option.",
"isCommentAutoGenerated" : true
},
"5s" : {
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
"isCommentAutoGenerated" : true
},
"10s" : {
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
"isCommentAutoGenerated" : true
},
"Adjusts the size of the light ring around the camera preview" : {
"comment" : "A description of the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Advanced Beauty Filters" : {
"comment" : "Description of a benefit included in the \"Go Pro\" premium subscription.",
"isCommentAutoGenerated" : true
},
"All Color Presets + Custom Colors" : {
"comment" : "Benefit description for the \"All Color Presets + Custom Colors\" benefit.",
"isCommentAutoGenerated" : true
},
"Applies light skin smoothing to the camera preview" : {
"comment" : "A hint for the \"Skin Smoothing\" toggle in the settings view.",
"isCommentAutoGenerated" : true
},
"Applies subtle real-time smoothing" : {
"comment" : "Accessibility hint for the \"Skin Smoothing\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Auto-Save" : {
"comment" : "Title of a toggle that enables automatic saving of captured photos and videos to the user's Photo Library.",
"isCommentAutoGenerated" : true
},
"Automatically save captures to Photo Library" : {
"comment" : "A toggle option in the Settings view that allows the user to enable or disable automatic saving of captured photos and videos to the user's Photo Library.",
"isCommentAutoGenerated" : true
},
"Best Value • Save 33%" : {
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
"isCommentAutoGenerated" : true
},
"Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
"comment" : "The text for a button that dismisses the current view.",
"isCommentAutoGenerated" : true
},
"Captured photo" : {
"comment" : "A label describing a captured photo.",
"isCommentAutoGenerated" : true
},
"Captured video" : {
"comment" : "A label describing a captured video.",
"isCommentAutoGenerated" : true
},
"Close preview" : {
"comment" : "A button label that closes the preview screen.",
"isCommentAutoGenerated" : true
},
"Cool Lavender" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Custom" : {
"comment" : "A label displayed below the rainbow gradient circle in the custom color button.",
"isCommentAutoGenerated" : true
},
"Custom color" : {
"comment" : "An accessibility label for the custom color button.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true
},
"Debug mode: Restore simulated!" : {
"comment" : "Accessibility announcement when restoring purchases in debug mode.",
"isCommentAutoGenerated" : true
},
"Directional Gradient Lighting" : {
"comment" : "Benefit provided with the Pro subscription, such as \"Directional Gradient Lighting\".",
"isCommentAutoGenerated" : true
},
"Done" : {
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
"isCommentAutoGenerated" : true
},
"Front Flash" : {
"comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.",
"isCommentAutoGenerated" : true
},
"Go Pro" : {
"comment" : "The title of the \"Go Pro\" button in the Pro paywall.",
"isCommentAutoGenerated" : true
},
"Grid Overlay" : {
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
"isCommentAutoGenerated" : true
},
"Hides preview during capture for a flash effect" : {
"comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Ice Blue" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Last synced %@" : {
},
"Light Color" : {
"comment" : "A label displayed above a section of the settings view related to light colors.",
"isCommentAutoGenerated" : true
},
"Locked. Tap to unlock with Pro." : {
"comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
},
"Off" : {
"comment" : "The accessibility value for the grid toggle when it is off.",
"isCommentAutoGenerated" : true
},
"Open Source Licenses" : {
"comment" : "A heading displayed above a list of open source licenses used in the app.",
"isCommentAutoGenerated" : true
},
"Opens upgrade options" : {
"comment" : "An accessibility hint for the \"Upgrade to Pro\" button that indicates it opens upgrade options.",
"isCommentAutoGenerated" : true
},
"Photo" : {
},
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true
},
"Purchase successful! Pro features unlocked." : {
"comment" : "Announcement read out to the user when a premium purchase is successful.",
"isCommentAutoGenerated" : true
},
"Purchases restored" : {
"comment" : "Announcement read out to the user when purchases are restored.",
"isCommentAutoGenerated" : true
},
"Pure White" : {
"comment" : "A color preset option for the ring light that displays as pure white.",
"isCommentAutoGenerated" : true
},
"Restore Purchases" : {
"comment" : "A button that restores purchases.",
"isCommentAutoGenerated" : true
},
"Retake" : {
"comment" : "Title for a button that allows the user to retake a captured photo or video.",
"isCommentAutoGenerated" : true
},
"Ring size" : {
"comment" : "An accessibility label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Ring Size" : {
"comment" : "The label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Save" : {
"comment" : "Title for a button that saves the currently captured photo or video to the user's photo library.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true
},
"Select self-timer duration" : {
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
"isCommentAutoGenerated" : true
},
"Self-Timer" : {
"comment" : "Title of the section in the settings view that allows the user to select the duration of the self-timer.",
"isCommentAutoGenerated" : true
},
"Settings" : {
"comment" : "The title of the settings screen.",
"isCommentAutoGenerated" : true
},
"Share" : {
"comment" : "Title for a button that shares the captured media.",
"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
},
"Shows non-flipped preview like a real mirror" : {
"comment" : "Subtitle for the \"True Mirror\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Shows rule of thirds grid" : {
"comment" : "Accessibility hint for the grid overlay toggle.",
"isCommentAutoGenerated" : true
},
"Sign in to iCloud to enable sync" : {
"comment" : "Subtitle of the iCloud sync section when the user is not signed into iCloud.",
"isCommentAutoGenerated" : true
},
"Skin Smoothing" : {
"comment" : "A toggle that enables or disables real-time skin smoothing.",
"isCommentAutoGenerated" : true
},
"Soft Pink" : {
"comment" : "Name of a ring light color preset.",
"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,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Subscribe to %1$@ for %2$@"
}
}
}
},
"Sync Now" : {
"comment" : "A button label that triggers a sync action.",
"isCommentAutoGenerated" : true
},
"Sync Settings" : {
"comment" : "Title of a toggle that allows the user to enable or disable iCloud sync settings.",
"isCommentAutoGenerated" : true
},
"Sync settings across all your devices" : {
"comment" : "Subtitle of the \"Sync Settings\" toggle in the Settings view, describing the functionality when sync is enabled.",
"isCommentAutoGenerated" : true
},
"Synced" : {
"comment" : "Text displayed in the iCloud sync section when the user's settings have been successfully synced.",
"isCommentAutoGenerated" : true
},
"Syncing..." : {
},
"Third-party libraries used in this app" : {
"comment" : "A description of the third-party libraries used in this app.",
"isCommentAutoGenerated" : true
},
"True Mirror" : {
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
"isCommentAutoGenerated" : true
},
"Unlimited Boomerang Length" : {
"comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.",
"isCommentAutoGenerated" : true
},
"Unlock premium colors, video, and more" : {
"comment" : "A description of the benefits of upgrading to the Pro version of the app.",
"isCommentAutoGenerated" : true
},
"Upgrade to Pro" : {
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
"isCommentAutoGenerated" : true
},
"Uses the ring light as a flash when taking photos" : {
"comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.",
"isCommentAutoGenerated" : true
},
"Video" : {
"comment" : "Display name for the \"Video\" capture mode.",
"isCommentAutoGenerated" : true
},
"View on GitHub" : {
"comment" : "A button label that says \"View on GitHub\".",
"isCommentAutoGenerated" : true
},
"Warm Amber" : {
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Warm Cream" : {
"comment" : "A color option for the ring light, named after a warm, creamy shade of white.",
"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
},
"When enabled, the preview is not mirrored" : {
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
"isCommentAutoGenerated" : true
}
},
"version" : "1.1"
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
</dict>
</plist>

View File

@ -0,0 +1,110 @@
import SwiftUI
import Bedrock
// MARK: - Ring Light Color Presets
/// App-specific color presets for the ring light feature.
/// Standard UI colors should use Bedrock's `Color.Surface`, `Color.Accent`, etc.
extension Color {
/// Ring light color presets for selfie lighting.
enum RingLight {
/// Pure white - standard daylight lighting.
static let pureWhite = Color(red: 1.0, green: 1.0, blue: 1.0)
/// Warm cream - soft warm lighting like golden hour.
static let warmCream = Color(red: 1.0, green: 0.98, blue: 0.9)
/// Ice blue - cool lighting for a crisp look.
static let iceBlue = Color(red: 0.9, green: 0.95, blue: 1.0)
/// Soft pink - flattering warm tone.
static let softPink = Color(red: 1.0, green: 0.92, blue: 0.95)
/// Warm amber - sunset-like glow.
static let warmAmber = Color(red: 1.0, green: 0.9, blue: 0.75)
/// Cool lavender - subtle cool tone.
static let coolLavender = Color(red: 0.95, green: 0.92, blue: 1.0)
}
}
// MARK: - Custom Color RGB Storage
/// Stores RGB values for custom colors (Codable-friendly)
struct CustomColorRGB: Codable, Equatable, Sendable {
var red: Double
var green: Double
var blue: Double
static let defaultWhite = CustomColorRGB(red: 1.0, green: 1.0, blue: 1.0)
var color: Color {
Color(red: red, green: green, blue: blue)
}
init(red: Double, green: Double, blue: Double) {
self.red = red
self.green = green
self.blue = blue
}
init(from color: Color) {
let uiColor = UIColor(color)
var r: CGFloat = 0
var g: CGFloat = 0
var b: CGFloat = 0
uiColor.getRed(&r, green: &g, blue: &b, alpha: nil)
self.red = Double(r)
self.green = Double(g)
self.blue = Double(b)
}
}
// MARK: - Ring Light Color Identifier
/// Identifiable wrapper for ring light colors to use in Picker/ForEach.
struct RingLightColor: Identifiable, Equatable, Hashable {
let id: String
let name: String
let color: Color
let isPremium: Bool
let isCustom: Bool
init(id: String, name: String, color: Color, isPremium: Bool, isCustom: Bool = false) {
self.id = id
self.name = name
self.color = color
self.isPremium = isPremium
self.isCustom = isCustom
}
static let allPresets: [RingLightColor] = [
RingLightColor(id: "pureWhite", name: String(localized: "Pure White"), color: .RingLight.pureWhite, isPremium: false),
RingLightColor(id: "warmCream", name: String(localized: "Warm Cream"), color: .RingLight.warmCream, isPremium: false),
RingLightColor(id: "iceBlue", name: String(localized: "Ice Blue"), color: .RingLight.iceBlue, isPremium: true),
RingLightColor(id: "softPink", name: String(localized: "Soft Pink"), color: .RingLight.softPink, isPremium: true),
RingLightColor(id: "warmAmber", name: String(localized: "Warm Amber"), color: .RingLight.warmAmber, isPremium: true),
RingLightColor(id: "coolLavender", name: String(localized: "Cool Lavender"), color: .RingLight.coolLavender, isPremium: true)
]
/// The custom color option (premium only)
static let customId = "custom"
static func custom(with color: Color) -> RingLightColor {
RingLightColor(
id: customId,
name: String(localized: "Custom"),
color: color,
isPremium: true,
isCustom: true
)
}
static func fromId(_ id: String, customColor: Color? = nil) -> RingLightColor {
if id == customId, let customColor {
return custom(with: customColor)
}
return allPresets.first { $0.id == id } ?? allPresets[0]
}
}

View File

@ -0,0 +1,60 @@
import SwiftUI
import Bedrock
// MARK: - Re-export Bedrock Design for convenience
/// Convenience typealias to use Bedrock's Design throughout the app.
typealias Design = Bedrock.Design
// MARK: - App-Specific Design Extensions
/// App-specific additions to Bedrock's Design system.
/// Use `Design.Spacing`, `Design.CornerRadius`, etc. from Bedrock for all standard values.
/// These extensions add domain-specific constants for the Selfie Ring Light app.
extension Bedrock.Design {
/// App-specific size constants (e.g., border sizes for ring light).
enum Size {
static let borderSmall: CGFloat = 2
static let borderMedium: CGFloat = 4
static let borderLarge: CGFloat = 6
static let iconMedium: CGFloat = 24
static let cardWidth: CGFloat = 80
static let cardHeight: CGFloat = 52
}
/// Ring light border thickness options (for UI display, not the multiplier).
enum RingBorder {
static let small: CGFloat = 20
static let medium: CGFloat = 40
static let large: CGFloat = 60
}
/// Grid overlay configuration.
enum Grid {
static let lineWidth: CGFloat = 1
static let opacity: Double = 0.5
}
/// Capture button sizes.
enum Capture {
static let buttonSize: CGFloat = 70
static let innerRing: CGFloat = 62
static let stopSquare: CGFloat = 28
}
/// Camera control sizes.
enum Camera {
static let controlButtonSize: CGFloat = 44
static let flipIconSize: CGFloat = 22
}
/// Font sizes for the app (maps to Bedrock's BaseFontSize for consistency).
enum FontSize {
static let small: CGFloat = BaseFontSize.small
static let body: CGFloat = BaseFontSize.body
static let large: CGFloat = BaseFontSize.large
static let title: CGFloat = BaseFontSize.title
}
}

View File

@ -0,0 +1,147 @@
import RevenueCat
import SwiftUI
@MainActor
@Observable
final class PremiumManager: PremiumManaging {
var availablePackages: [Package] = []
// MARK: - Configuration
/// RevenueCat entitlement identifier - must match your RevenueCat dashboard
private let entitlementIdentifier = "pro"
/// Reads the RevenueCat API key from Info.plist (injected at build time from Secrets.xcconfig)
private static var apiKey: String {
guard let key = Bundle.main.object(forInfoDictionaryKey: "RevenueCatAPIKey") as? String,
!key.isEmpty,
key != "your_revenuecat_public_api_key_here" else {
#if DEBUG
print("⚠️ [PremiumManager] RevenueCat API key not configured. See Configuration/Secrets.xcconfig.template")
#endif
return ""
}
return key
}
// MARK: - Debug Override
/// Check if debug premium is enabled via environment variable.
/// Set "ENABLE_DEBUG_PREMIUM" = "1" in your scheme's environment variables to unlock all premium features during debugging.
private var isDebugPremiumEnabled: Bool {
#if DEBUG
return ProcessInfo.processInfo.environment["ENABLE_DEBUG_PREMIUM"] == "1"
#else
return false
#endif
}
var isPremium: Bool {
// Debug override takes precedence
if isDebugPremiumEnabled {
return true
}
// If API key isn't configured, return false
guard !Self.apiKey.isEmpty else {
return false
}
return Purchases.shared.cachedCustomerInfo?.entitlements[entitlementIdentifier]?.isActive == true
}
var isPremiumUnlocked: Bool { isPremium }
init() {
#if DEBUG
if isDebugPremiumEnabled {
print("🔓 [PremiumManager] Debug premium enabled via environment variable")
}
#endif
// Only configure RevenueCat if we have a valid API key
guard !Self.apiKey.isEmpty else {
#if DEBUG
print("⚠️ [PremiumManager] Skipping RevenueCat configuration - no API key")
#endif
return
}
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.configure(withAPIKey: Self.apiKey)
Task {
try? await loadProducts()
}
}
func loadProducts() async throws {
guard !Self.apiKey.isEmpty else { return }
let offerings = try await Purchases.shared.offerings()
if let current = offerings.current {
availablePackages = current.availablePackages
}
}
func purchase(_ package: Package) async throws -> Bool {
#if DEBUG
if isDebugPremiumEnabled {
// Simulate successful purchase in debug mode
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Debug mode: Purchase simulated!")
)
return true
}
#endif
let result = try await Purchases.shared.purchase(package: package)
if result.customerInfo.entitlements[entitlementIdentifier]?.isActive == true {
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Purchase successful! Pro features unlocked.")
)
return true
}
return false
}
func purchase(productId: String) async throws {
#if DEBUG
if isDebugPremiumEnabled {
return // Already "premium" in debug mode
}
#endif
guard let package = availablePackages.first(where: { $0.storeProduct.productIdentifier == productId }) else {
throw NSError(
domain: "PremiumManager",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Product not found"]
)
}
_ = try await purchase(package)
}
func restorePurchases() async throws {
#if DEBUG
if isDebugPremiumEnabled {
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Debug mode: Restore simulated!")
)
return
}
#endif
_ = try await Purchases.shared.restorePurchases()
UIAccessibility.post(
notification: .announcement,
argument: String(localized: "Purchases restored")
)
}
}

View File

@ -0,0 +1,10 @@
protocol CaptureControlling {
var selectedTimer: TimerOption { get set }
var isGridVisible: Bool { get set }
var currentZoomFactor: Double { get set }
var selectedCaptureMode: CaptureMode { get set }
func startCountdown() async
func performCapture()
func performFlashBurst() async
}

View File

@ -0,0 +1,7 @@
protocol PremiumManaging {
var isPremium: Bool { get }
func loadProducts() async throws
func purchase(productId: String) async throws
func restorePurchases() async throws
}

View File

@ -0,0 +1,22 @@
import SwiftUI
/// Protocol for types that can configure the ring light appearance.
protocol RingLightConfigurable {
/// The size of the ring light border in points
var ringSize: CGFloat { get set }
/// Convenience accessor for border width (same as ringSize)
var borderWidth: CGFloat { get }
/// The color of the ring light
var lightColor: Color { get }
/// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool { get set }
/// Whether the camera preview is mirrored
var isMirrorFlipped: Bool { get set }
/// Whether skin smoothing is enabled
var isSkinSmoothingEnabled: Bool { get set }
}

View File

@ -0,0 +1,143 @@
import Foundation
import SwiftUI
import Bedrock
// MARK: - Synced Settings Data
/// Settings data structure that syncs across all devices via iCloud.
/// Conforms to `PersistableData` for use with Bedrock's `CloudSyncManager`.
struct SyncedSettings: PersistableData, Sendable {
// MARK: - PersistableData Requirements
static var dataIdentifier: String { "selfieRingLightSettings" }
static var empty: SyncedSettings {
SyncedSettings()
}
/// Sync priority based on modification count - higher means more changes made.
/// This ensures the most actively used device's settings win in conflicts.
var syncPriority: Int {
modificationCount
}
var lastModified: Date = .now
// MARK: - Settings Properties
/// How many times settings have been modified (for sync priority)
var modificationCount: Int = 0
/// Ring border size in points (stored as Double for Codable compatibility)
var ringSizeValue: Double = 40
/// ID of the selected light color preset
var lightColorId: String = "pureWhite"
/// Custom color RGB values (for premium custom color picker)
var customColorRed: Double = 1.0
var customColorGreen: Double = 1.0
var customColorBlue: Double = 1.0
/// Whether front flash is enabled (hides preview during capture)
var isFrontFlashEnabled: Bool = true
/// Whether the camera preview is flipped to show a true mirror
var isMirrorFlipped: Bool = false
/// Whether skin smoothing filter is enabled
var isSkinSmoothingEnabled: Bool = true
/// Selected self-timer option raw value
var selectedTimerRaw: String = "off"
/// Whether the grid overlay is visible
var isGridVisible: Bool = false
/// Current camera zoom factor
var currentZoomFactor: Double = 1.0
/// Selected capture mode raw value
var selectedCaptureModeRaw: String = "photo"
/// Whether captures are auto-saved to Photo Library
var isAutoSaveEnabled: Bool = true
// MARK: - Computed Properties
/// Ring size as CGFloat (convenience accessor)
var ringSize: CGFloat {
get { CGFloat(ringSizeValue) }
set { ringSizeValue = Double(newValue) }
}
// MARK: - Initialization
init() {}
init(
ringSize: CGFloat,
lightColorId: String,
isFrontFlashEnabled: Bool,
isMirrorFlipped: Bool,
isSkinSmoothingEnabled: Bool,
selectedTimerRaw: String,
isGridVisible: Bool,
currentZoomFactor: Double,
selectedCaptureModeRaw: String,
modificationCount: Int = 0
) {
self.ringSizeValue = Double(ringSize)
self.lightColorId = lightColorId
self.isFrontFlashEnabled = isFrontFlashEnabled
self.isMirrorFlipped = isMirrorFlipped
self.isSkinSmoothingEnabled = isSkinSmoothingEnabled
self.selectedTimerRaw = selectedTimerRaw
self.isGridVisible = isGridVisible
self.currentZoomFactor = currentZoomFactor
self.selectedCaptureModeRaw = selectedCaptureModeRaw
self.modificationCount = modificationCount
self.lastModified = .now
}
// MARK: - Codable
enum CodingKeys: String, CodingKey {
case modificationCount
case lastModified
case ringSizeValue
case lightColorId
case customColorRed
case customColorGreen
case customColorBlue
case isFrontFlashEnabled
case isMirrorFlipped
case isSkinSmoothingEnabled
case selectedTimerRaw
case isGridVisible
case currentZoomFactor
case selectedCaptureModeRaw
case isAutoSaveEnabled
}
}
// MARK: - Equatable
extension SyncedSettings: Equatable {
static func == (lhs: SyncedSettings, rhs: SyncedSettings) -> Bool {
lhs.ringSizeValue == rhs.ringSizeValue &&
lhs.lightColorId == rhs.lightColorId &&
lhs.customColorRed == rhs.customColorRed &&
lhs.customColorGreen == rhs.customColorGreen &&
lhs.customColorBlue == rhs.customColorBlue &&
lhs.isFrontFlashEnabled == rhs.isFrontFlashEnabled &&
lhs.isMirrorFlipped == rhs.isMirrorFlipped &&
lhs.isSkinSmoothingEnabled == rhs.isSkinSmoothingEnabled &&
lhs.selectedTimerRaw == rhs.selectedTimerRaw &&
lhs.isGridVisible == rhs.isGridVisible &&
lhs.currentZoomFactor == rhs.currentZoomFactor &&
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw &&
lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled
}
}

View File

@ -0,0 +1,17 @@
//
// SelfieCamTests.swift
// SelfieCamTests
//
// Created by Matt Bruce on 1/4/26.
//
import Testing
@testable import SelfieCam
struct SelfieCamTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
}

View File

@ -0,0 +1,41 @@
//
// SelfieCamUITests.swift
// SelfieCamUITests
//
// Created by Matt Bruce on 1/4/26.
//
import XCTest
final class SelfieCamUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}

View File

@ -0,0 +1,33 @@
//
// SelfieCamUITestsLaunchTests.swift
// SelfieCamUITests
//
// Created by Matt Bruce on 1/4/26.
//
import XCTest
final class SelfieCamUITestsLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
true
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
}