Documentation: - Update README.md with complete feature list and SelfieCam branding - Update AI_Implementation.md with current architecture and branding details - Add SelfieCam-specific sections to AGENTS.md (premium, branding, camera) Features: - Add branding debug section to SettingsView (icon generator, preview) - Add BrandingConfig.swift with app colors and launch screen config - Add LaunchBackground.colorset for seamless launch experience - Wrap app in AppLaunchView for animated launch screen
26 KiB
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
@Observableclasses 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:
- Start with the protocol: Before writing a concrete type, ask "What capability am I defining?" and express it as a protocol.
- Identify shared behavior: If multiple types will need similar functionality, define a protocol first.
- Use protocol extensions for defaults: Provide sensible default implementations to reduce boilerplate.
- Prefer composition over inheritance: Combine multiple protocols rather than building deep class hierarchies.
When reviewing existing code for reuse:
- Look for duplicated patterns: If you see similar logic across modules, extract a protocol to a shared location.
- Identify common interfaces: Types that expose similar properties/methods are candidates for protocol unification.
- Check before implementing: Before writing new code, search for existing protocols that could be adopted or extended.
- 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-Providersuffixes (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
AnyObjectonly when needed: Prefer value semantics unless reference semantics are required.
Examples
❌ BAD - Concrete implementations without protocols:
// 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:
// 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:
struct ItemListView: View {
@Bindable var viewModel: UserListViewModel
// Tightly coupled to Users
}
✅ GOOD - View works with any DataLoading type:
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
@Observableclasses 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 thanreplacingOccurrences(of: "hello", with: "world"). - Prefer modern Foundation API, for example
URL.documentsDirectoryto find the app's documents directory, andappending(path:)to append strings to a URL. - Never use C-style number formatting such as
Text(String(format: "%.2f", abs(myNumber))); always useText(abs(change), format: .number.precision(.fractionLength(2)))instead. - Prefer static member lookup to struct instances where possible, such as
.circlerather thanCircle(), and.borderedProminentrather thanBorderedProminentButtonStyle(). - 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 tocontains(). - Avoid force unwraps and force
tryunless it is unrecoverable.
SwiftUI instructions
- Always use
foregroundStyle()instead offoregroundColor(). - Always use
clipShape(.rect(cornerRadius:))instead ofcornerRadius(). - Always use the
TabAPI instead oftabItem(). - Never use
ObservableObject; always prefer@Observableclasses 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 useButton. - Never use
Task.sleep(nanoseconds:); always useTask.sleep(for:)instead. - Never use
UIScreen.main.boundsto read the size of the available space. - Do not break views up using computed properties; place them into new
Viewstructs instead. - Do not force specific font sizes; prefer using Dynamic Type instead.
- Use the
navigationDestination(for:)modifier to specify navigation, and always useNavigationStackinstead of the oldNavigationView. - 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
ImageRenderertoUIGraphicsImageRenderer. - Don't apply the
fontWeight()modifier unless there is good reason. If you want to make some text bold, always usebold()instead offontWeight(.bold). - Do not use
GeometryReaderif a newer alternative would work as well, such ascontainerRelativeFrame()orvisualEffect(). - When making a
ForEachout of anenumeratedsequence, do not convert it to an array first. So, preferForEach(x.enumerated(), id: \.element.id)instead ofForEach(Array(x.enumerated()), id: \.element.id). - When hiding scroll view indicators, use the
.scrollIndicators(.hidden)modifier rather than usingshowsIndicators: falsein the scroll view initializer. - Avoid
AnyViewunless 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 aColorextension 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,maxContentWidthbased 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:
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:
// 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 (
.xcstringsfiles) 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
Textviews or with dynamic content, useString(localized:)or create a helper extension: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 modernString(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.largenotcornerRadius: 16 - Font Sizes:
Design.FontSize.bodynotsize: 14 - Opacity Values:
Design.Opacity.strongnot.opacity(0.7) - Colors:
Color.Primary.accentnotColor(red: 0.8, green: 0.6, blue: 0.2) - Line Widths:
Design.LineWidth.mediumnotlineWidth: 2 - Shadow Values:
Design.Shadow.radiusLargenotradius: 10 - Animation Durations:
Design.Animation.quicknotduration: 0.3 - Component Sizes:
Design.Size.iconMediumnotframe(width: 32)
What to do when you see a magic number:
- Check if an appropriate constant already exists in your design constants file
- If not, add a new constant with a semantic name
- Use the constant in place of the raw value
- If it's truly view-specific and used only once, extract to a
private letat the top of the view struct
Examples of violations:
// ❌ 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: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
Colorwith semantic color definitions: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:
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):
accentnotpointSix,largenotsixteen.
Dynamic Type instructions
- Always support Dynamic Type for accessibility; never use fixed font sizes without scaling.
- Use
@ScaledMetricto scale custom font sizes and dimensions based on user accessibility settings: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
relativeTotext style based on the semantic purpose:.largeTitle,.title,.title2,.title3for headings.headline,.subheadlinefor emphasized content.bodyfor main content.callout,.footnote,.caption,.caption2for 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:
// 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:Button("Submit", action: submit) .accessibilityHint("Submits the form and creates your account") - Use
.accessibilityAddTraits()to communicate element type:.isButtonfor tappable elements that aren't SwiftUI Buttons.isHeaderfor section headers.isModalfor modal overlays.updatesFrequentlyfor live-updating content
- Hide purely decorative elements from VoiceOver:
DecorationView() .accessibilityHidden(true) // Decorative element - Group related elements to reduce VoiceOver navigation complexity:
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:
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:
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
- Always keep documentation up to date when adding new functionality or making changes that users or developers need to know about.
- 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.
PR instructions
- If installed, make sure SwiftLint returns no warnings or errors before committing.
- Verify that documentation reflects any new functionality or behavioral changes.
SelfieCam-Specific Guidelines
The following sections are specific to this app's architecture and features.
App Architecture
SelfieCam uses the following architectural patterns:
Dependencies
- Bedrock: Local Swift package for design system, branding, and cloud sync
- MijickCamera: SwiftUI camera framework for capture and preview
- RevenueCat: Subscription management for premium features
Key Protocols
| Protocol | Purpose | Conforming Types |
|---|---|---|
RingLightConfigurable |
Ring light settings (size, color, opacity) | SettingsViewModel |
CaptureControlling |
Capture actions (timer, flash, shutter) | SettingsViewModel |
PremiumManaging |
Subscription state and purchases | PremiumManager |
Premium Features
Adding a New Premium Feature
- Add setting to
SyncedSettingswith an appropriate default value - Use
PremiumGate.get()in the getter:var myPremiumFeature: Bool { get { PremiumGate.get(cloudSync.data.myFeature, default: false, isPremium: isPremiumUnlocked) } set { guard PremiumGate.canSet(isPremium: isPremiumUnlocked) else { return } updateSettings { $0.myFeature = newValue } } } - Add crown icon in the UI to indicate premium status
- Wire up paywall trigger when non-premium users tap the control
Current Premium Features
- Custom ring light colors
- Premium color presets (Ice Blue, Soft Pink, Warm Amber, Cool Lavender)
- Flash sync with ring light color
- HDR mode
- High quality photos
- True mirror mode
- Skin smoothing
- Center Stage
- Extended timers (5s, 10s)
- Video and Boomerang capture modes
Settings & iCloud Sync
How Settings Work
- All settings are stored in
SyncedSettingsstruct CloudSyncManager<SyncedSettings>handles iCloud synchronizationSettingsViewModelexposes properties that read/write through the sync manager- Slider values use debounced saves (300ms) to prevent excessive writes
Adding a New Setting
- Add property to
SyncedSettingswith default value - Add corresponding property in
SettingsViewModel - For premium settings, use
PremiumGateutilities - Add UI in
SettingsView
Branding System
Overview
The app uses Bedrock's branding system for:
- Animated launch screen
- App icon generation
- Consistent color scheme
Key Files
Shared/BrandingConfig.swift- App icon and launch screen configurationResources/Assets.xcassets/LaunchBackground.colorset/- Launch screen background colorApp/SelfieCamApp.swift- Wraps ContentView with AppLaunchView
Modifying Branding
- Update colors in
BrandingConfig.swift→Color.Branding - Update
LaunchBackground.colorsetto match primary color - Adjust icon/launch screen config as needed
- Use Icon Generator in Settings → Debug to create new app icon
Documentation
See Bedrock/Sources/Bedrock/Branding/BRANDING_GUIDE.md for complete branding documentation.
Camera Integration
MijickCamera
The app uses MijickCamera for camera functionality:
import MijickCamera
// Camera position
var cameraPosition: CameraPosition // .front or .back
// Flash modes
var flashMode: CameraFlashMode // .off, .on, .auto
Camera Features
- Front/back camera switching
- Pinch-to-zoom
- Photo capture with quality settings
- Video recording (premium)
- HDR mode (premium)
Ring Light System
How It Works
The ring light is a colored overlay (RingLightOverlay) that surrounds the camera preview:
- Size: Adjustable border width (10-120pt)
- Color: Preset colors or custom color picker
- Opacity: Adjustable brightness (10%-100%)
- Toggle: Can be enabled/disabled
Color Presets
| Color | ID | Premium |
|---|---|---|
| Pure White | pureWhite |
No |
| Warm Cream | warmCream |
No |
| Ice Blue | iceBlue |
Yes |
| Soft Pink | softPink |
Yes |
| Warm Amber | warmAmber |
Yes |
| Cool Lavender | coolLavender |
Yes |
| Custom | custom |
Yes |
Documentation Files
When making changes, update the appropriate documentation:
| File | Purpose |
|---|---|
README.md |
User-facing app overview, setup instructions |
AI_Implementation.md |
Technical architecture, implementation details |
AGENTS.md |
Development guidelines (this file) |
Always commit documentation updates with the related code changes.