Compare commits

...

7 Commits

24 changed files with 336 additions and 97 deletions

41
APP_STORE_CONNECT.md Normal file
View File

@ -0,0 +1,41 @@
# SelfieCam App Store Connect Copy
## Promotional Text (max 170)
Take better selfies in any light with a customizable screen ring light, pro camera controls, and quick timer capture.
Character count: 117 / 170
## Keywords (max 100)
selfie,camera,ring light,portrait,beauty,low light,timer,flash,creator,mirror,grid,hdr,photo editor
Character count: 99 / 100
## Description (max 4000)
SelfieCam is a professional selfie camera built for better lighting and cleaner results.
Perfect for creators, makeup artists, video calls, and anyone who wants more flattering selfies in any environment.
Core features:
- Customizable screen ring light with adjustable size, color, and brightness
- Front/back camera switching with smooth full-screen preview
- Flash controls (Off, On, Auto) plus front flash support
- Self-timer options with visual countdown
- Pinch-to-zoom and optional composition grid
- Post-capture preview with quick sharing
Pro features:
- Premium lighting presets and custom ring light colors
- HDR mode and higher photo quality options
- True Mirror mode
- Skin smoothing and advanced capture controls
- Extended timer options
Built with accessibility in mind:
- VoiceOver-friendly controls and labels
- Dynamic Type support for readable text
SelfieCam also supports iCloud settings sync, so your preferences stay consistent across devices.
Download SelfieCam and get studio-style selfie lighting straight from your screen.
Character count: 1055 / 4000

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

View File

@ -503,7 +503,7 @@
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";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Selfie Cam.app/Selfie Cam";
};
name = Debug;
};
@ -525,7 +525,7 @@
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";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Selfie Cam.app/Selfie Cam";
};
name = Release;
};

View File

@ -21,10 +21,11 @@ struct RootView: View {
/// Whether to show the paywall (shared between views)
@State private var showPaywall = false
private let isScreenshotUITest = ProcessInfo.processInfo.arguments.contains("-ui-testing-screenshots")
var body: some View {
ZStack {
if hasCompletedOnboarding {
if isScreenshotUITest || hasCompletedOnboarding {
// Main app content
ContentView()
.preferredColorScheme(.dark)

View File

@ -13,6 +13,7 @@ struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var showSettings = false
@State private var showPaywall = false
private let isScreenshotUITest = ProcessInfo.processInfo.arguments.contains("-ui-testing-screenshots")
@State private var capturedPhoto: CapturedPhoto?
@State private var showPhotoReview = false
@ -32,15 +33,25 @@ struct ContentView: View {
ZStack {
// Camera view - wrapped in EquatableView to prevent re-evaluation on settings changes
if !showPhotoReview {
EquatableView(content: CameraContainerView(
settings: settings,
sessionKey: cameraSessionKey,
cameraPosition: settings.cameraPosition,
onImageCaptured: { image in
handlePhotoCaptured(image)
}
))
.ignoresSafeArea() // Only camera ignores safe area to fill screen
if isScreenshotUITest {
ScreenshotCameraPlaceholder(
showSettings: $showSettings,
ringWidth: 70.0,
ringColor: .white,
ringOpacity: 0.7
)
.ignoresSafeArea()
} else {
EquatableView(content: CameraContainerView(
settings: settings,
sessionKey: cameraSessionKey,
cameraPosition: settings.cameraPosition,
onImageCaptured: { image in
handlePhotoCaptured(image)
}
))
.ignoresSafeArea() // Only camera ignores safe area to fill screen
}
}
// Photo review overlay - handles its own safe area
@ -67,7 +78,7 @@ struct ContentView: View {
)
// Settings button overlay - only show when NOT in photo review mode
.overlay(alignment: .topTrailing) {
if !showPhotoReview {
if !showPhotoReview && !isScreenshotUITest {
Button {
showSettings = true
} label: {
@ -83,6 +94,13 @@ struct ContentView: View {
.padding(.top, Design.Spacing.small)
}
}
.overlay(alignment: .topLeading) {
if isScreenshotUITest {
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("screenshot-mode-active")
}
}
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview)
.onAppear {
// Initialize tracking of camera position
@ -220,7 +238,3 @@ struct CameraContainerView: View, Equatable {
.startSession()
}
}
#Preview {
ContentView()
}

View File

@ -0,0 +1,151 @@
import SwiftUI
import Bedrock
struct ScreenshotCameraPlaceholder: View {
@Binding var showSettings: Bool
let ringWidth: CGFloat
let ringColor: Color
let ringOpacity: Double
init(
showSettings: Binding<Bool> = .constant(false),
ringWidth: CGFloat = 70,
ringColor: Color = .white,
ringOpacity: Double = 0.7
) {
self._showSettings = showSettings
self.ringWidth = ringWidth
self.ringColor = ringColor
self.ringOpacity = ringOpacity
}
private var backgroundImage: UIImage? {
guard let imageURL = Bundle.main.url(forResource: "image", withExtension: "png") else {
return nil
}
return UIImage(contentsOfFile: imageURL.path)
}
@ViewBuilder
private func cameraImageLayer(size: CGSize) -> some View {
if let backgroundImage {
Image(uiImage: backgroundImage)
.resizable()
.scaledToFill()
.frame(width: size.width, height: size.height, alignment: .center)
.clipped()
} else {
Color.black
.frame(width: size.width, height: size.height)
}
}
var body: some View {
GeometryReader { geometry in
let size = geometry.size
ZStack {
cameraImageLayer(size: size)
ringColor
.opacity(ringOpacity)
.ignoresSafeArea()
cameraImageLayer(size: size)
.mask {
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.padding(ringWidth)
}
PlaceholderGridOverlay(ringWidth: ringWidth)
.allowsHitTesting(false)
VStack(spacing: 0) {
HStack {
Spacer()
PlaceholderTopButton(
systemImage: "gearshape.fill",
accessibilityLabel: "Settings",
action: { showSettings = true }
)
}
.padding(.top, ringWidth + Design.Spacing.medium)
.padding(.trailing, Design.Spacing.medium)
Spacer()
VStack(spacing: Design.Spacing.medium) {
ZoomControlView(
zoomFactor: 1.0,
isCenterStageActive: false
)
CaptureButton(action: { })
.padding(.bottom, Design.Spacing.large)
}
}
}
}
.ignoresSafeArea()
.accessibilityIdentifier("screenshot-camera-placeholder")
.overlay(alignment: .topLeading) {
Color.clear
.frame(width: 1, height: 1)
.accessibilityIdentifier("screenshot-camera-placeholder")
}
}
}
private struct PlaceholderTopButton: View {
let systemImage: String
let accessibilityLabel: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.title3)
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(Color.black.opacity(Design.Opacity.medium), in: Circle())
.overlay {
Circle()
.strokeBorder(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin)
}
.shadow(radius: Design.Shadow.radiusSmall)
}
.accessibilityLabel(accessibilityLabel)
}
}
private struct PlaceholderGridOverlay: View {
let ringWidth: CGFloat
var body: some View {
GeometryReader { geometry in
let insetRect = CGRect(
x: ringWidth,
y: ringWidth,
width: geometry.size.width - (ringWidth * 2),
height: geometry.size.height - (ringWidth * 2)
)
Path { path in
for index in 1...2 {
let x = insetRect.minX + (insetRect.width * CGFloat(index) / 3)
path.move(to: CGPoint(x: x, y: insetRect.minY))
path.addLine(to: CGPoint(x: x, y: insetRect.maxY))
}
for index in 1...2 {
let y = insetRect.minY + (insetRect.height * CGFloat(index) / 3)
path.move(to: CGPoint(x: insetRect.minX, y: y))
path.addLine(to: CGPoint(x: insetRect.maxX, y: y))
}
}
.stroke(Color.white.opacity(Design.Opacity.subtle), lineWidth: Design.LineWidth.thin)
}
}
}
#Preview {
ScreenshotCameraPlaceholder(ringWidth: 70.0, ringColor: .white, ringOpacity: 0.7)
}

View File

@ -203,6 +203,7 @@ struct SettingsView: View {
}
}
}
.accessibilityIdentifier("settings-sheet")
}
// MARK: - Ring Size Slider

View File

@ -997,6 +997,7 @@
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"es-MX" : {
@ -1021,6 +1022,7 @@
},
"Debug mode: Restore simulated!" : {
"comment" : "Accessibility announcement when restoring purchases in debug mode.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true,
"localizations" : {
"es-MX" : {
@ -1808,14 +1810,6 @@
}
}
},
"Onboarding Reset" : {
"comment" : "The title of an alert that confirms the onboarding state has been reset.",
"isCommentAutoGenerated" : true
},
"Onboarding will show again when you restart the app." : {
"comment" : "A message displayed in an alert when the \"Reset Onboarding\" button is tapped.",
"isCommentAutoGenerated" : true
},
"One Time" : {
"comment" : "A description of a one-time purchase option.",
"isCommentAutoGenerated" : true
@ -2133,10 +2127,6 @@
}
}
},
"Reset Onboarding" : {
"comment" : "A button label that resets the onboarding state.",
"isCommentAutoGenerated" : true
},
"Restore Purchases" : {
"comment" : "A button that restores purchases.",
"isCommentAutoGenerated" : true,
@ -2787,10 +2777,6 @@
}
}
},
"Show onboarding flow on next app launch" : {
"comment" : "A description of what the \"Reset Onboarding\" button does.",
"isCommentAutoGenerated" : true
},
"Shows a grid overlay to help compose your shot" : {
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
"isCommentAutoGenerated" : true,

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -1,44 +0,0 @@
//
// Color+Codable.swift
// CameraTester
//
// Created by Matt Bruce on 1/3/26.
//
import SwiftUI
// MARK: - Color Codable Extension
extension Color: Codable {
enum CodingKeys: String, CodingKey {
case red, green, blue, opacity
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let red = try container.decode(Double.self, forKey: .red)
let green = try container.decode(Double.self, forKey: .green)
let blue = try container.decode(Double.self, forKey: .blue)
let opacity = try container.decode(Double.self, forKey: .opacity)
self.init(red: red, green: green, blue: blue, opacity: opacity)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
// Convert Color to RGB components
let uiColor = UIColor(self)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
try container.encode(Double(red), forKey: .red)
try container.encode(Double(green), forKey: .green)
try container.encode(Double(blue), forKey: .blue)
try container.encode(Double(alpha), forKey: .opacity)
}
}

View File

@ -11,17 +11,22 @@ import SwiftUI
struct CameraSettings: Codable {
var photoQuality: PhotoQuality
var isRingLightEnabled: Bool
var ringLightColor: Color
var ringLightColorRGB: CustomColorRGB
var ringLightSize: CGFloat
var ringLightOpacity: Double
var flashMode: CameraFlashMode
var isFlashSyncedWithRingLight: Bool
var ringLightColor: Color {
get { ringLightColorRGB.color }
set { ringLightColorRGB = CustomColorRGB(from: newValue) }
}
// Default settings
static let `default` = CameraSettings(
photoQuality: .high,
isRingLightEnabled: true,
ringLightColor: .white,
ringLightColorRGB: .defaultWhite,
ringLightSize: 25,
ringLightOpacity: 1.0,
flashMode: .off,

View File

@ -8,34 +8,125 @@
import XCTest
final class SelfieCamUITests: XCTestCase {
private var app: XCUIApplication!
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
app = XCUIApplication()
addUIInterruptionMonitor(withDescription: "System Permission Alerts") { alert in
let preferredButtons = [
"Allow",
"Allow While Using App",
"OK",
"Continue"
]
// In UI tests its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
for title in preferredButtons where alert.buttons[title].exists {
alert.buttons[title].tap()
return true
}
if let firstButton = alert.buttons.allElementsBoundByIndex.first {
firstButton.tap()
return true
}
return false
}
}
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()
func testAppStorePortraitScreenshots() throws {
app.launchArguments = ["-ui-testing-screenshots"]
app.launchEnvironment = [
"ENABLE_DEBUG_PREMIUM": "1"
]
app.launch()
if !waitForCameraScreen(timeout: 8) {
app.terminate()
app.launch()
}
// Use XCTAssert and related functions to verify your tests produce the correct results.
saveScreenshot(named: "01-launch-screen")
XCTAssertTrue(waitForCameraScreen(timeout: 25), "Camera screen did not appear")
XCTAssertTrue(app.otherElements["screenshot-camera-placeholder"].waitForExistence(timeout: 5), "Screenshot camera placeholder did not appear")
RunLoop.current.run(until: Date().addingTimeInterval(0.6))
saveScreenshot(named: "02-camera-ring-light")
let settingsButton = app.buttons["Settings"]
XCTAssertTrue(settingsButton.waitForExistence(timeout: 10), "Settings button not found")
settingsButton.tap()
XCTAssertTrue(app.otherElements["settings-sheet"].waitForExistence(timeout: 8), "Settings sheet did not appear")
RunLoop.current.run(until: Date().addingTimeInterval(0.8))
saveScreenshot(named: "03-settings-popup")
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
// MARK: - Helpers
@MainActor
private func waitForCameraScreen(timeout: TimeInterval) -> Bool {
let onboardingActions = [
"Get Started",
"Continue",
"Enable Camera",
"Enable Microphone",
"Enable Photos",
"Done"
]
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.otherElements["screenshot-mode-active"].exists {
return true
}
_ = tapFirstExistingButton(labels: onboardingActions)
tapFirstSpringboardButton(labels: ["Allow", "Allow While Using App", "OK", "Continue"])
if app.alerts.firstMatch.exists {
app.tap()
}
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
}
return app.otherElements["screenshot-mode-active"].exists
}
@MainActor
private func tapFirstSpringboardButton(labels: [String]) {
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
for label in labels {
let button = springboard.buttons[label]
if button.waitForExistence(timeout: 0.2) {
button.tap()
return
}
}
}
@MainActor
private func tapFirstExistingButton(labels: [String]) -> Bool {
for label in labels {
let button = app.buttons[label]
if button.exists {
button.tap()
return true
}
}
return false
}
@MainActor
private func saveScreenshot(named name: String) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}

View File

@ -21,13 +21,6 @@ final class SelfieCamUITestsLaunchTests: XCTestCase {
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)
XCTAssertTrue(app.state == .runningForeground)
}
}