initial commit
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
commit
1be8c073d3
523
AGENTS.md
Normal file
523
AGENTS.md
Normal 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.
|
||||
661
SelfieCam.xcodeproj/project.pbxproj
Normal file
661
SelfieCam.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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>
|
||||
17
SelfieCam/App/SelfieCamApp.swift
Normal file
17
SelfieCam/App/SelfieCamApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
11
SelfieCam/Configuration/Debug.xcconfig
Normal file
11
SelfieCam/Configuration/Debug.xcconfig
Normal 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)
|
||||
11
SelfieCam/Configuration/Release.xcconfig
Normal file
11
SelfieCam/Configuration/Release.xcconfig
Normal 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)
|
||||
10
SelfieCam/Configuration/Secrets.xcconfig
Normal file
10
SelfieCam/Configuration/Secrets.xcconfig
Normal 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
|
||||
12
SelfieCam/Configuration/Secrets.xcconfig.template
Normal file
12
SelfieCam/Configuration/Secrets.xcconfig.template
Normal 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
|
||||
106
SelfieCam/Features/Camera/ContentView.swift
Normal file
106
SelfieCam/Features/Camera/ContentView.swift
Normal 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()
|
||||
}
|
||||
36
SelfieCam/Features/Camera/GridOverlay.swift
Normal file
36
SelfieCam/Features/Camera/GridOverlay.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
232
SelfieCam/Features/Camera/PostCapturePreviewView.swift
Normal file
232
SelfieCam/Features/Camera/PostCapturePreviewView.swift
Normal 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: {}
|
||||
)
|
||||
}
|
||||
137
SelfieCam/Features/Paywall/ProPaywallView.swift
Normal file
137
SelfieCam/Features/Paywall/ProPaywallView.swift
Normal 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)
|
||||
}
|
||||
600
SelfieCam/Features/Settings/SettingsView.swift
Normal file
600
SelfieCam/Features/Settings/SettingsView.swift
Normal 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)
|
||||
}
|
||||
314
SelfieCam/Features/Settings/SettingsViewModel.swift
Normal file
314
SelfieCam/Features/Settings/SettingsViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
6
SelfieCam/Resources/Assets.xcassets/Contents.json
Normal file
6
SelfieCam/Resources/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
315
SelfieCam/Resources/Localizable.xcstrings
Normal file
315
SelfieCam/Resources/Localizable.xcstrings
Normal 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"
|
||||
}
|
||||
8
SelfieCam/SelfieCam.entitlements
Normal file
8
SelfieCam/SelfieCam.entitlements
Normal 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>
|
||||
110
SelfieCam/Shared/Color+Extensions.swift
Normal file
110
SelfieCam/Shared/Color+Extensions.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
60
SelfieCam/Shared/DesignConstants.swift
Normal file
60
SelfieCam/Shared/DesignConstants.swift
Normal 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
|
||||
}
|
||||
}
|
||||
147
SelfieCam/Shared/Premium/PremiumManager.swift
Normal file
147
SelfieCam/Shared/Premium/PremiumManager.swift
Normal 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")
|
||||
)
|
||||
}
|
||||
}
|
||||
10
SelfieCam/Shared/Protocols/CaptureControlling.swift
Normal file
10
SelfieCam/Shared/Protocols/CaptureControlling.swift
Normal 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
|
||||
}
|
||||
7
SelfieCam/Shared/Protocols/PremiumManaging.swift
Normal file
7
SelfieCam/Shared/Protocols/PremiumManaging.swift
Normal 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
|
||||
}
|
||||
22
SelfieCam/Shared/Protocols/RingLightConfigurable.swift
Normal file
22
SelfieCam/Shared/Protocols/RingLightConfigurable.swift
Normal 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 }
|
||||
}
|
||||
143
SelfieCam/Shared/Storage/SyncedSettings.swift
Normal file
143
SelfieCam/Shared/Storage/SyncedSettings.swift
Normal 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
|
||||
}
|
||||
}
|
||||
17
SelfieCamTests/SelfieCamTests.swift
Normal file
17
SelfieCamTests/SelfieCamTests.swift
Normal 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.
|
||||
}
|
||||
|
||||
}
|
||||
41
SelfieCamUITests/SelfieCamUITests.swift
Normal file
41
SelfieCamUITests/SelfieCamUITests.swift
Normal 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 it’s 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
SelfieCamUITests/SelfieCamUITestsLaunchTests.swift
Normal file
33
SelfieCamUITests/SelfieCamUITestsLaunchTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user