Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-04 16:03:50 -06:00
parent 5499b10b2a
commit fe7ab135af
8 changed files with 123 additions and 88 deletions

View File

@ -553,7 +553,6 @@ SelfieCam uses the following architectural patterns:
- Skin smoothing - Skin smoothing
- Center Stage - Center Stage
- Extended timers (5s, 10s) - Extended timers (5s, 10s)
- Video and Boomerang capture modes
## Settings & iCloud Sync ## Settings & iCloud Sync
@ -621,7 +620,6 @@ var flashMode: CameraFlashMode // .off, .on, .auto
- Front/back camera switching - Front/back camera switching
- Pinch-to-zoom - Pinch-to-zoom
- Photo capture with quality settings - Photo capture with quality settings
- Video recording (premium)
- HDR mode (premium) - HDR mode (premium)

View File

@ -75,7 +75,6 @@ Features/
- Post-capture preview with share functionality - Post-capture preview with share functionality
- Auto-save option to Photo Library - Auto-save option to Photo Library
- Front flash using screen brightness - Front flash using screen brightness
- Support for photo, video, and boomerang modes
### 4. Freemium Model ### 4. Freemium Model
- Built with **RevenueCat** for subscription management - Built with **RevenueCat** for subscription management
@ -128,7 +127,6 @@ var isMirrorFlipped: Bool {
| Skin smoothing | Off | Configurable | | Skin smoothing | Off | Configurable |
| Flash sync | Off | Configurable | | Flash sync | Off | Configurable |
| Center stage | Off | Configurable | | Center stage | Off | Configurable |
| Capture modes | Photo | Photo, Video, Boomerang |
--- ---

View File

@ -15,10 +15,8 @@ Perfect for low-light selfies, content creation, video calls, makeup application
- **Front Flash**: Uses screen brightness for front camera flash effect - **Front Flash**: Uses screen brightness for front camera flash effect
- Real-time camera preview with smooth performance - Real-time camera preview with smooth performance
### Capture Modes ### Capture
- **Photo capture** with high-quality output - **Photo capture** with high-quality output
- **Video recording** (Premium)
- **Boomerang mode** for looping short videos (Premium)
- Self-timer with 3-second (free), 5-second, and 10-second (Premium) options - Self-timer with 3-second (free), 5-second, and 10-second (Premium) options
- Pinch-to-zoom gesture support - Pinch-to-zoom gesture support
- Rule-of-thirds grid overlay (toggleable) - Rule-of-thirds grid overlay (toggleable)
@ -34,7 +32,6 @@ Perfect for low-light selfies, content creation, video calls, makeup application
- **Skin Smoothing**: Real-time subtle skin smoothing filter - **Skin Smoothing**: Real-time subtle skin smoothing filter
- **Center Stage**: Automatic subject tracking/centering - **Center Stage**: Automatic subject tracking/centering
- **Extended Timers**: 5-second and 10-second self-timer options - **Extended Timers**: 5-second and 10-second self-timer options
- **Video & Boomerang**: Video recording and looping video capture
- Ad-free experience - Ad-free experience
### iCloud Sync ### iCloud Sync
@ -139,15 +136,15 @@ Add `REVENUECAT_API_KEY` as a secret in your Xcode Cloud workflow.
## Privacy ## Privacy
- Camera access required for preview and capture - Camera access required for preview and capture
- Photo Library access required to save photos/videos - Photo Library access required to save photos
- Microphone access required for video recording - Microphone access may be requested by the camera framework (not actively used)
- iCloud access for settings synchronization (optional) - iCloud access for settings synchronization (optional)
- No data collection, no analytics, no tracking - No data collection, no analytics, no tracking
## Monetization ## Monetization
Freemium model with optional "Pro" subscription: Freemium model with optional "Pro" subscription:
- **Free**: Basic ring light, standard colors (Pure White, Warm Cream), photo capture, 3s timer, grid, zoom - **Free**: Basic ring light, standard colors (Pure White, Warm Cream), photo capture, 3s timer, grid, zoom
- **Pro**: Full color palette, custom colors, HDR, high quality, flash sync, true mirror, skin smoothing, center stage, extended timers, video, boomerang - **Pro**: Full color palette, custom colors, HDR, high quality, flash sync, true mirror, skin smoothing, center stage, extended timers
Implemented with RevenueCat for reliable subscription management. Implemented with RevenueCat for reliable subscription management.

View File

@ -422,9 +422,9 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ; DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos and videos, and enable advanced features like Center Stage auto-framing."; INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record clear audio when capturing videos, ensuring your voiceovers and ambient sounds are captured along with your video content."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos and videos to your device, making them available in the Photos app and other compatible applications."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;
@ -459,9 +459,9 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ; DEVELOPMENT_TEAM = 6R7KLBPBLZ;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos and videos, and enable advanced features like Center Stage auto-framing."; INFOPLIST_KEY_NSCameraUsageDescription = "SelfieCam needs camera access to display your live selfie preview, apply real-time filters and ring light effects, capture high-quality photos, and enable advanced features like Center Stage auto-framing.";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access to record clear audio when capturing videos, ensuring your voiceovers and ambient sounds are captured along with your video content."; INFOPLIST_KEY_NSMicrophoneUsageDescription = "SelfieCam needs microphone access for the camera framework to initialize properly. Audio is not recorded.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos and videos to your device, making them available in the Photos app and other compatible applications."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "SelfieCam needs photo library access to automatically save your captured photos to your device, making them available in the Photos app and other compatible applications.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground; INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchBackground;

View File

@ -39,7 +39,7 @@ struct ContentView: View {
.ignoresSafeArea() // Only camera ignores safe area to fill screen .ignoresSafeArea() // Only camera ignores safe area to fill screen
} }
// Photo review overlay // Photo review overlay - handles its own safe area
if showPhotoReview, let photo = capturedPhoto { if showPhotoReview, let photo = capturedPhoto {
PhotoReviewView( PhotoReviewView(
photo: photo, photo: photo,
@ -53,24 +53,25 @@ struct ContentView: View {
} }
) )
.transition(.opacity) .transition(.opacity)
.ignoresSafeArea() // Photo review also fills screen
} }
} }
// Settings button overlay - respects safe area naturally // Settings button overlay - only show when NOT in photo review mode
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
Button { if !showPhotoReview {
showSettings = true Button {
} label: { showSettings = true
Image(systemName: "gearshape.fill") } label: {
.font(.title3) Image(systemName: "gearshape.fill")
.foregroundStyle(.white) .font(.title3)
.padding(Design.Spacing.medium) .foregroundStyle(.white)
.background(.ultraThinMaterial, in: Circle()) .padding(Design.Spacing.medium)
.shadow(radius: Design.Shadow.radiusSmall) .background(.ultraThinMaterial, in: Circle())
.shadow(radius: Design.Shadow.radiusSmall)
}
.accessibilityLabel("Settings")
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.small)
} }
.accessibilityLabel("Settings")
.padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.small)
} }
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview) .animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview)
.onAppear { .onAppear {

View File

@ -1,6 +1,6 @@
// //
// PhotoReviewView.swift // PhotoReviewView.swift
// CameraTester // SelfieCam
// //
// Created by Matt Bruce on 1/2/26. // Created by Matt Bruce on 1/2/26.
// //
@ -16,58 +16,81 @@ struct PhotoReviewView: View {
let saveError: String? let saveError: String?
let onRetake: () -> Void let onRetake: () -> Void
let onSave: () -> Void let onSave: () -> Void
// Layout constants
private let toolbarHeight: CGFloat = 100
private let topButtonSize: CGFloat = 44 // Retake, Close (smaller, out of the way)
private let bottomButtonSize: CGFloat = 64 // Share, Save (larger, main actions)
var body: some View { var body: some View {
ZStack { ZStack {
// Photo display // Black background
Color.black Color.black
.ignoresSafeArea() .ignoresSafeArea()
// Photo - centered in available space
Image(uiImage: photo.image) Image(uiImage: photo.image)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.ignoresSafeArea() .ignoresSafeArea()
}
// Top toolbar // Top toolbar with gradient background
VStack { .overlay(alignment: .top) {
ZStack(alignment: .bottom) {
// Gradient background for visibility
LinearGradient(
colors: [Color.black.opacity(0.7), Color.black.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: toolbarHeight)
.ignoresSafeArea(edges: .top)
// Buttons
HStack { HStack {
// Retake button // Retake button
Button(action: onRetake) { Button(action: onRetake) {
Image(systemName: "arrow.triangle.2.circlepath") Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.white) .foregroundStyle(.white)
.frame(width: 44, height: 44) .frame(width: topButtonSize, height: topButtonSize)
.background( .background(.ultraThinMaterial, in: Circle())
Circle()
.fill(Color.black.opacity(0.6))
)
} }
.accessibilityLabel("Retake photo")
Spacer() Spacer()
// Close button // Close button
Button(action: onRetake) { Button(action: onRetake) {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 20, weight: .medium)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.white) .foregroundStyle(.white)
.frame(width: 44, height: 44) .frame(width: topButtonSize, height: topButtonSize)
.background( .background(.ultraThinMaterial, in: Circle())
Circle()
.fill(Color.black.opacity(0.6))
)
} }
.accessibilityLabel("Close")
} }
.padding(.horizontal, Design.Spacing.large) .padding(.horizontal, Design.Spacing.large)
.padding(.top, Design.Spacing.large) .padding(.bottom, Design.Spacing.medium)
}
Spacer() }
// Bottom toolbar with gradient background
// Bottom action bar .overlay(alignment: .bottom) {
ZStack(alignment: .top) {
// Gradient background for visibility
LinearGradient(
colors: [Color.black.opacity(0), Color.black.opacity(0.85)],
startPoint: .top,
endPoint: .bottom
)
.frame(height: 160)
.ignoresSafeArea(edges: .bottom)
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
// Save status or error // Save status or error
if let error = saveError { if let error = saveError {
Text(error) Text(error)
.foregroundColor(.red) .foregroundStyle(.red)
.font(.system(size: 14, weight: .medium)) .font(.system(size: 14, weight: .medium))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.vertical, Design.Spacing.small) .padding(.vertical, Design.Spacing.small)
@ -81,38 +104,37 @@ struct PhotoReviewView: View {
ProgressView() ProgressView()
.tint(.white) .tint(.white)
Text("Saving...") Text("Saving...")
.foregroundColor(.white) .foregroundStyle(.white)
.font(.system(size: 16, weight: .medium)) .font(.system(size: 16, weight: .medium))
} }
.padding(.vertical, Design.Spacing.small)
} }
// Action buttons // Action buttons - same size, different styling
HStack(spacing: Design.Spacing.xLarge) { HStack(spacing: Design.Spacing.xLarge * 2) {
// Share button // Share button (frosted glass = secondary)
ShareButton(photo: photo.image) ShareButton(photo: photo.image)
.frame(width: bottomButtonSize, height: bottomButtonSize)
.background(.ultraThinMaterial, in: Circle())
// Save button // Save button (solid white = primary)
Button(action: onSave) { Button(action: onSave) {
ZStack { Image(systemName: "checkmark")
Circle() .font(.system(size: 26, weight: .bold))
.fill(Color.white) .foregroundStyle(.black)
.frame(width: 80, height: 80) .frame(width: bottomButtonSize, height: bottomButtonSize)
.background(.white, in: Circle())
Image(systemName: "checkmark") .shadow(color: .black.opacity(0.3), radius: Design.Shadow.radiusMedium)
.font(.system(size: 24, weight: .bold))
.foregroundColor(.black)
}
.shadow(radius: 5)
} }
.disabled(isSaving) .disabled(isSaving)
.accessibilityLabel("Save photo")
.accessibilityHint("Saves the photo to your library")
} }
.padding(.bottom, Design.Spacing.large)
} }
.padding(.horizontal, Design.Spacing.large) .padding(.top, Design.Spacing.xLarge)
.padding(.bottom, Design.Spacing.large)
} }
} }
.accessibilityElement(children: .contain)
.accessibilityLabel("Photo review") .accessibilityLabel("Photo review")
.accessibilityHint("Use the buttons at the bottom to save or share your photo")
} }
} }

View File

@ -15,20 +15,15 @@ struct ShareButton: View {
@State private var isShareSheetPresented = false @State private var isShareSheetPresented = false
var body: some View { var body: some View {
Button(action: { Button {
isShareSheetPresented = true isShareSheetPresented = true
}) { } label: {
ZStack { Image(systemName: "square.and.arrow.up")
Circle() .font(.system(size: 20, weight: .medium))
.fill(Color.black.opacity(0.6)) .foregroundStyle(.white)
.frame(width: 80, height: 80) .frame(maxWidth: .infinity, maxHeight: .infinity)
Image(systemName: "square.and.arrow.up")
.font(.system(size: 24, weight: .medium))
.foregroundColor(.white)
}
.shadow(radius: 5)
} }
.accessibilityLabel("Share photo")
.sheet(isPresented: $isShareSheetPresented) { .sheet(isPresented: $isShareSheetPresented) {
ShareSheet(activityItems: [photo]) ShareSheet(activityItems: [photo])
} }

View File

@ -507,6 +507,7 @@
}, },
"Boomerang" : { "Boomerang" : {
"comment" : "Display name for the \"Boomerang\" capture mode.", "comment" : "Display name for the \"Boomerang\" capture mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -769,6 +770,10 @@
} }
} }
}, },
"Close" : {
"comment" : "A button label that closes the view.",
"isCommentAutoGenerated" : true
},
"Close preview" : { "Close preview" : {
"comment" : "A button label that closes the preview screen.", "comment" : "A button label that closes the preview screen.",
"extractionState" : "stale", "extractionState" : "stale",
@ -1691,6 +1696,7 @@
} }
}, },
"Photo" : { "Photo" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
"stringUnit" : { "stringUnit" : {
@ -1957,6 +1963,10 @@
} }
} }
}, },
"Retake photo" : {
"comment" : "A button that, when tapped, allows the user to retake a photo.",
"isCommentAutoGenerated" : true
},
"Ring Light" : { "Ring Light" : {
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -2148,6 +2158,10 @@
} }
} }
}, },
"Save photo" : {
"comment" : "A button that saves the currently displayed photo to the user's library.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : { "Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.", "comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"extractionState" : "stale", "extractionState" : "stale",
@ -2173,6 +2187,10 @@
} }
} }
}, },
"Saves the photo to your library" : {
"comment" : "An accessibility hint for the save button in the photo review view.",
"isCommentAutoGenerated" : true
},
"Saving..." : { "Saving..." : {
"comment" : "A text that appears while a photo is being saved.", "comment" : "A text that appears while a photo is being saved.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -2390,6 +2408,10 @@
} }
} }
}, },
"Share photo" : {
"comment" : "An accessibility label for the share button.",
"isCommentAutoGenerated" : true
},
"Show colored light ring around camera preview" : { "Show colored light ring around camera preview" : {
"comment" : "Subtitle for the \"Enable Ring Light\" toggle in the Settings view.", "comment" : "Subtitle for the \"Enable Ring Light\" toggle in the Settings view.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -3016,6 +3038,7 @@
}, },
"Use the buttons at the bottom to save or share your photo" : { "Use the buttons at the bottom to save or share your photo" : {
"comment" : "An accessibility hint for the photo review view, instructing the user on how to interact with the view.", "comment" : "An accessibility hint for the photo review view, instructing the user on how to interact with the view.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {
@ -3065,6 +3088,7 @@
}, },
"Video" : { "Video" : {
"comment" : "Display name for the \"Video\" capture mode.", "comment" : "Display name for the \"Video\" capture mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"es-MX" : { "es-MX" : {