Integrate MijickCamera library, remove custom camera code

Major refactor: Replace custom camera implementation with MijickCamera

Added:
- MijickCamera Swift package (v3.0.3)
- MijickTimer dependency (required by MijickCamera)

Changed:
- ContentView now uses MCamera() from MijickCamera
- Ring light wraps around MijickCamera view with padding
- MijickCamera handles all camera logic:
  - Permissions
  - Capture (photo/video)
  - Camera switching
  - Orientation/rotation
  - Zoom/focus gestures
  - Flash

Removed:
- CameraViewModel.swift (replaced by MijickCamera)
- CameraPreview.swift (replaced by MijickCamera)

Kept:
- Ring light background (settings.lightColor)
- Ring size control (settings.ringSize)
- Grid overlay
- Post-capture preview workflow
- Settings view
- Premium features and paywall
- iCloud sync

Benefits:
- Less code to maintain
- Battle-tested camera implementation
- Better rotation handling built-in
- More camera features available (filters, exposure, etc.)
This commit is contained in:
Matt Bruce 2026-01-02 16:16:31 -06:00
parent d93625b2a4
commit 829770b6b7
5 changed files with 160 additions and 956 deletions

View File

@ -10,6 +10,7 @@
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; };
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; };
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; };
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F0F2F08600000DC03E1 /* MijickCamera */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -63,6 +64,7 @@
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -136,6 +138,7 @@
EA766C852F08306200DC03E1 /* RevenueCat */,
EA766C872F08306200DC03E1 /* RevenueCatUI */,
EA766F012F08500000DC03E1 /* Bedrock */,
EA766F0F2F08600000DC03E1 /* MijickCamera */,
);
productName = SelfieRingLight;
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
@ -222,6 +225,7 @@
packageReferences = (
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
@ -638,6 +642,14 @@
kind = branch;
};
};
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Mijick/Camera";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 3.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -656,6 +668,11 @@
package = EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */;
productName = Bedrock;
};
EA766F0F2F08600000DC03E1 /* MijickCamera */ = {
isa = XCSwiftPackageProductDependency;
package = EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */;
productName = MijickCamera;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EA766C242F082A8400DC03E1 /* Project object */;

View File

@ -1,233 +0,0 @@
import SwiftUI
import UIKit
import AVFoundation
struct CameraPreview: UIViewRepresentable {
let viewModel: CameraViewModel
// These properties trigger view updates when they change
var isMirrorFlipped: Bool
var zoomFactor: Double
var manualRotationAngle: CGFloat
var ringLightColor: Color
init(viewModel: CameraViewModel, isMirrorFlipped: Bool, zoomFactor: Double, manualRotationAngle: CGFloat = 0, ringLightColor: Color = .white) {
self.viewModel = viewModel
self.isMirrorFlipped = isMirrorFlipped
self.zoomFactor = zoomFactor
self.manualRotationAngle = manualRotationAngle
self.ringLightColor = ringLightColor
}
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView(viewModel: viewModel)
view.contentMode = .scaleAspectFill
view.clipsToBounds = true
// Add pinch-to-zoom gesture
let pinch = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handlePinch(_:)))
view.addGestureRecognizer(pinch)
return view
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
// Update background color to match ring light
uiView.backgroundColor = UIColor(ringLightColor)
// Update manual rotation
uiView.manualRotationAngle = manualRotationAngle
// Force layout update
uiView.setNeedsLayout()
uiView.layoutIfNeeded()
// Apply mirror transform based on settings
CATransaction.begin()
CATransaction.setDisableActions(true)
if isMirrorFlipped {
uiView.previewLayer?.transform = CATransform3DMakeScale(-1, 1, 1)
} else {
uiView.previewLayer?.transform = CATransform3DIdentity
}
CATransaction.commit()
// Apply zoom if changed
context.coordinator.applyZoom(zoomFactor)
}
func makeCoordinator() -> Coordinator {
Coordinator(viewModel: viewModel)
}
class Coordinator: NSObject {
let viewModel: CameraViewModel
private var lastAppliedZoom: Double = 1.0
init(viewModel: CameraViewModel) {
self.viewModel = viewModel
}
@MainActor
@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
guard gesture.state == .changed else { return }
let newZoom = max(1.0, min(5.0, viewModel.settings.currentZoomFactor * gesture.scale))
viewModel.settings.currentZoomFactor = newZoom
gesture.scale = 1.0
applyZoom(newZoom)
}
func applyZoom(_ zoom: Double) {
guard zoom != lastAppliedZoom else { return }
lastAppliedZoom = zoom
if let device = viewModel.captureSession?.inputs.first.flatMap({ ($0 as? AVCaptureDeviceInput)?.device }) {
do {
try device.lockForConfiguration()
device.videoZoomFactor = max(1.0, min(zoom, device.activeFormat.videoMaxZoomFactor))
device.unlockForConfiguration()
} catch {
print("Error setting zoom: \(error)")
}
}
}
}
}
// MARK: - UIView subclass for camera preview
class CameraPreviewUIView: UIView {
private weak var viewModel: CameraViewModel?
var previewLayer: AVCaptureVideoPreviewLayer?
/// Manual rotation offset from user (0, 90, 180, 270)
var manualRotationAngle: CGFloat = 0
override class var layerClass: AnyClass {
AVCaptureVideoPreviewLayer.self
}
init(viewModel: CameraViewModel) {
self.viewModel = viewModel
super.init(frame: .zero)
backgroundColor = .black
autoresizingMask = [.flexibleWidth, .flexibleHeight]
setupPreviewLayer()
// Listen for orientation changes
NotificationCenter.default.addObserver(
self,
selector: #selector(handleOrientationChange),
name: UIDevice.orientationDidChangeNotification,
object: nil
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func setupPreviewLayer() {
guard let viewModel = viewModel,
let session = viewModel.captureSession else { return }
if let layer = self.layer as? AVCaptureVideoPreviewLayer {
layer.session = session
// Use .resizeAspect to show exactly what will be captured
// The ring light fills any letterbox areas naturally
layer.videoGravity = .resizeAspect
previewLayer = layer
viewModel.previewLayer = layer
// Set initial orientation
updatePreviewOrientation()
}
}
override func layoutSubviews() {
super.layoutSubviews()
// Ensure the preview layer fills the entire view bounds
previewLayer?.frame = bounds
// Setup layer if not already done (can happen if session was nil at init)
if previewLayer == nil {
setupPreviewLayer()
}
// Update orientation on layout changes
updatePreviewOrientation()
}
/// Tracks the last valid orientation for fallback
private var lastValidOrientation: UIDeviceOrientation = .portrait
@objc private func handleOrientationChange() {
updatePreviewOrientation()
}
private func updatePreviewOrientation() {
guard let connection = previewLayer?.connection else { return }
// Get rotation angle based on device orientation
var deviceOrientation = UIDevice.current.orientation
// If device orientation is flat or unknown, try to get from interface orientation
if !deviceOrientation.isValidInterfaceOrientation {
// Use last known good orientation, or get from window scene
if let windowScene = window?.windowScene {
switch windowScene.interfaceOrientation {
case .portrait:
deviceOrientation = .portrait
case .portraitUpsideDown:
deviceOrientation = .portraitUpsideDown
case .landscapeLeft:
deviceOrientation = .landscapeRight // Interface and device are inverted
case .landscapeRight:
deviceOrientation = .landscapeLeft // Interface and device are inverted
case .unknown:
deviceOrientation = lastValidOrientation
@unknown default:
deviceOrientation = lastValidOrientation
}
} else {
deviceOrientation = lastValidOrientation
}
} else {
// Store this as the last valid orientation
lastValidOrientation = deviceOrientation
}
// Calculate base rotation angle (in degrees) for the preview layer
// For front camera in portrait: sensor is landscape, so rotate 90°
let baseRotationAngle: CGFloat
switch deviceOrientation {
case .portrait:
baseRotationAngle = 90
case .portraitUpsideDown:
baseRotationAngle = 270
case .landscapeLeft:
baseRotationAngle = 180
case .landscapeRight:
baseRotationAngle = 0
default:
baseRotationAngle = 90 // Default to portrait
}
// Add manual rotation offset and normalize to 0-360
let totalRotation = (baseRotationAngle + manualRotationAngle).truncatingRemainder(dividingBy: 360)
let finalRotation = totalRotation < 0 ? totalRotation + 360 : totalRotation
// Use modern rotation angle API (iOS 17+)
if connection.isVideoRotationAngleSupported(finalRotation) {
connection.videoRotationAngle = finalRotation
}
}
}

View File

@ -1,393 +0,0 @@
import AVFoundation
import SwiftUI
import Photos
import CoreImage
import UIKit
import Bedrock
@MainActor
@Observable
class CameraViewModel: NSObject {
var isCameraAuthorized = false
var isPhotoLibraryAuthorized = false
var captureSession: AVCaptureSession?
var photoOutput: AVCapturePhotoOutput?
var videoOutput: AVCaptureMovieFileOutput?
var videoDataOutput: AVCaptureVideoDataOutput?
var previewLayer: AVCaptureVideoPreviewLayer?
var isUsingFrontCamera = true
var isRecording = false
var originalBrightness: CGFloat = 0.5
var ciContext = CIContext()
/// Whether the preview should be hidden (for front flash effect)
var isPreviewHidden = false
/// Captured media for preview (nil when no capture pending)
var capturedMedia: CapturedMedia?
/// Whether to show the post-capture preview
var showPostCapturePreview = false
/// Toast message to display
var toastMessage: String?
/// Whether Center Stage is available on this device
var isCenterStageAvailable = false
/// Whether Center Stage is currently enabled
var isCenterStageEnabled = false
/// Manual rotation offset (0, 90, 180, 270 degrees)
/// Allows user to rotate preview independent of device orientation
var manualRotationAngle: CGFloat = 0
let settings = SettingsViewModel() // Shared config
// MARK: - Manual Rotation
/// Cycles through rotation angles: 0 90 180 270 0
func cycleManualRotation() {
manualRotationAngle = (manualRotationAngle + 90).truncatingRemainder(dividingBy: 360)
}
/// Resets manual rotation to match device orientation
func resetManualRotation() {
manualRotationAngle = 0
}
// MARK: - Screen Brightness Handling
/// Gets the current screen from any available window scene
private var currentScreen: UIScreen? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.screen
}
private func saveCurrentBrightness() {
if let screen = currentScreen {
originalBrightness = screen.brightness
}
}
private func setBrightness(_ value: CGFloat) {
currentScreen?.brightness = value
}
func setupCamera() async {
isCameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
isPhotoLibraryAuthorized = await PHPhotoLibrary.requestAuthorization(for: .addOnly) == .authorized
guard isCameraAuthorized else { return }
captureSession = AVCaptureSession()
guard let session = captureSession else { return }
session.beginConfiguration()
// Use .photo preset for optimal photo quality and consistent 4:3 aspect ratio
session.sessionPreset = .photo
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: isUsingFrontCamera ? .front : .back)
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) {
session.addInput(input)
}
photoOutput = AVCapturePhotoOutput()
if let photoOutput, session.canAddOutput(photoOutput) {
session.addOutput(photoOutput)
}
videoOutput = AVCaptureMovieFileOutput()
if let videoOutput, session.canAddOutput(videoOutput) {
session.addOutput(videoOutput)
}
videoDataOutput = AVCaptureVideoDataOutput()
videoDataOutput?.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
if let videoDataOutput, session.canAddOutput(videoDataOutput) {
session.addOutput(videoDataOutput)
}
session.commitConfiguration()
session.startRunning()
// Check Center Stage availability
updateCenterStageAvailability()
UIApplication.shared.isIdleTimerDisabled = true
saveCurrentBrightness()
// Set screen to full brightness for best ring light effect
setBrightness(1.0)
}
// MARK: - Center Stage
/// Updates Center Stage availability based on current camera
private func updateCenterStageAvailability() {
isCenterStageAvailable = AVCaptureDevice.isCenterStageEnabled || checkCenterStageSupport()
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
}
/// Checks if the current device supports Center Stage
private func checkCenterStageSupport() -> Bool {
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
return false
}
// Center Stage is available if the device has it as an active format feature
return device.activeFormat.isCenterStageSupported
}
/// Toggles Center Stage on/off
func toggleCenterStage() {
guard isCenterStageAvailable else { return }
AVCaptureDevice.centerStageControlMode = .app
AVCaptureDevice.isCenterStageEnabled.toggle()
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
}
// MARK: - Orientation
/// Updates video orientation based on device orientation
func updateVideoOrientation(for orientation: UIDeviceOrientation) {
guard let connection = photoOutput?.connection(with: .video) else { return }
// Calculate rotation angle (in degrees)
let rotationAngle: CGFloat
switch orientation {
case .portrait:
rotationAngle = 90
case .portraitUpsideDown:
rotationAngle = 270
case .landscapeLeft:
rotationAngle = 0
case .landscapeRight:
rotationAngle = 180
default:
rotationAngle = 90 // Default to portrait
}
// Use modern rotation angle API (iOS 17+)
if connection.isVideoRotationAngleSupported(rotationAngle) {
connection.videoRotationAngle = rotationAngle
}
// Also update video output connection
if let videoConnection = videoOutput?.connection(with: .video),
videoConnection.isVideoRotationAngleSupported(rotationAngle) {
videoConnection.videoRotationAngle = rotationAngle
}
}
func switchCamera() {
guard let session = captureSession else { return }
session.beginConfiguration()
session.inputs.forEach { session.removeInput($0) }
isUsingFrontCamera.toggle()
let position: AVCaptureDevice.Position = isUsingFrontCamera ? .front : .back
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position)
guard let device, let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) {
session.addInput(input)
}
session.commitConfiguration()
// Update Center Stage availability (only works on front camera)
updateCenterStageAvailability()
}
func capturePhoto() {
// If front flash is enabled, hide the preview to show the ring light
if settings.isFrontFlashEnabled {
performFrontFlashCapture()
} else {
let captureSettings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
}
}
/// Performs photo capture with front flash effect
private func performFrontFlashCapture() {
isPreviewHidden = true
// Brief delay to show the full ring light before capturing
Task {
try? await Task.sleep(for: .milliseconds(150))
let captureSettings = AVCapturePhotoSettings()
photoOutput?.capturePhoto(with: captureSettings, delegate: self)
}
}
/// Restores the preview after front flash capture
func restorePreviewAfterFlash() {
isPreviewHidden = false
}
func startRecording() {
guard let videoOutput = videoOutput, !isRecording else { return }
let url = FileManager.default.temporaryDirectory.appendingPathComponent("video.mov")
videoOutput.startRecording(to: url, recordingDelegate: self)
isRecording = true
}
func stopRecording() {
guard let videoOutput = videoOutput, isRecording else { return }
videoOutput.stopRecording()
isRecording = false
}
func restoreBrightness() {
setBrightness(originalBrightness)
UIApplication.shared.isIdleTimerDisabled = false
}
// Business logic: Check if ready to capture
var canCapture: Bool {
captureSession?.isRunning == true && isPhotoLibraryAuthorized
}
// MARK: - Post-Capture Actions
/// Dismisses the post-capture preview and returns to camera
func dismissPostCapturePreview() {
showPostCapturePreview = false
capturedMedia = nil
}
/// Retakes by dismissing preview (deletes unsaved temp if needed)
func retakeCapture() {
// If auto-save was off and there's temp media, it's discarded
dismissPostCapturePreview()
}
/// Manually saves current capture to Photo Library
func saveCurrentCapture() {
guard let media = capturedMedia else { return }
switch media {
case .photo(let image):
if let data = image.jpegData(compressionQuality: 0.9) {
savePhotoToLibrary(data: data)
showToast(String(localized: "Saved to Photos"))
}
case .video(let url), .boomerang(let url):
saveVideoToLibrary(url: url)
showToast(String(localized: "Saved to Photos"))
}
}
/// Gets shareable items for the current capture
func getShareItems() -> [Any] {
guard let media = capturedMedia else { return [] }
switch media {
case .photo(let image):
return [image]
case .video(let url), .boomerang(let url):
return [url]
}
}
// MARK: - Toast
/// Shows a toast message briefly
func showToast(_ message: String) {
toastMessage = message
Task {
try? await Task.sleep(for: .seconds(2))
if toastMessage == message {
toastMessage = nil
}
}
}
}
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
guard let data = photo.fileDataRepresentation(),
let image = UIImage(data: data) else { return }
Task { @MainActor in
// Restore preview first (in case front flash was used)
restorePreviewAfterFlash()
// Store the captured image for preview
capturedMedia = .photo(image)
// Auto-save if enabled
if settings.isAutoSaveEnabled {
savePhotoToLibrary(data: data)
showToast(String(localized: "Saved to Photos"))
}
// Show post-capture preview
showPostCapturePreview = true
UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured"))
}
}
/// Saves photo data to Photo Library
private func savePhotoToLibrary(data: Data) {
PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
}
}
}
extension CameraViewModel: AVCaptureFileOutputRecordingDelegate {
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
Task { @MainActor in
// Store the video URL for preview
let isBoomerang = settings.selectedCaptureMode == .boomerang
capturedMedia = isBoomerang ? .boomerang(outputFileURL) : .video(outputFileURL)
// Auto-save if enabled
if settings.isAutoSaveEnabled {
saveVideoToLibrary(url: outputFileURL)
showToast(String(localized: "Saved to Photos"))
}
// Show post-capture preview
showPostCapturePreview = true
UIAccessibility.post(notification: .announcement, argument: String(localized: "Video saved"))
}
}
/// Saves video to Photo Library
private func saveVideoToLibrary(url: URL) {
PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil)
}
}
}
extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
// Note: This runs on a background queue and cannot access @MainActor isolated properties directly
// For real skin smoothing, this would need to be implemented with a Metal-based approach
// or by using AVCaptureVideoDataOutput with custom rendering
// Basic skin smoothing placeholder - actual implementation would require:
// 1. CIContext created on this queue
// 2. Rendering to a Metal texture
// 3. Displaying via CAMetalLayer or similar
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let ciImage = CIImage(cvPixelBuffer: imageBuffer)
// Apply light gaussian blur for skin smoothing effect
guard let filter = CIFilter(name: "CIGaussianBlur") else { return }
filter.setValue(ciImage, forKey: kCIInputImageKey)
filter.setValue(1.0, forKey: kCIInputRadiusKey)
// For a complete implementation, render outputImage to the preview layer
_ = filter.outputImage
}
}

View File

@ -1,214 +1,105 @@
import SwiftUI
import MijickCamera
import Bedrock
import Photos
struct ContentView: View {
@State private var viewModel = CameraViewModel()
@State private var settings = SettingsViewModel()
@State private var premiumManager = PremiumManager()
@State private var showPaywall = false
@State private var showSettings = false
@State private var showShareSheet = false
@State private var toastMessage: String?
// Captured media for post-capture preview
@State private var capturedImage: UIImage?
@State private var capturedVideoURL: URL?
@State private var showPostCapturePreview = false
var body: some View {
GeometryReader { geometry in
let maxRingSize = calculateMaxRingSize(for: geometry)
let effectiveRingSize = min(viewModel.settings.ringSize, maxRingSize)
let effectiveRingSize = min(settings.ringSize, maxRingSize)
ZStack {
// MARK: - Ring Light Background
ringLightBackground
settings.lightColor
.ignoresSafeArea()
// MARK: - Camera Preview (full screen with ring border)
cameraPreviewArea(ringSize: effectiveRingSize)
// MARK: - Camera with MijickCamera
MCamera()
.onImageCaptured { image, _ in
handleImageCaptured(image)
}
.onVideoCaptured { url, _ in
handleVideoCaptured(url)
}
.clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large))
.padding(effectiveRingSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: effectiveRingSize)
// MARK: - Grid Overlay
if viewModel.settings.isGridVisible && !viewModel.isPreviewHidden {
if settings.isGridVisible {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
.allowsHitTesting(false)
}
// MARK: - Controls Overlay (on top of preview)
controlsOverlay(ringSize: effectiveRingSize)
// MARK: - Permission Denied View
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
permissionDeniedView
// MARK: - Top Controls Overlay
VStack {
topControlBar
.padding(.top, effectiveRingSize + Design.Spacing.small)
Spacer()
}
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
// MARK: - Toast Notification
if let message = viewModel.toastMessage {
if let message = toastMessage {
toastView(message: message)
}
}
.onChange(of: geometry.size) { _, newSize in
// Update max ring size when screen size changes
let newMax = min(newSize.width, newSize.height) / 4
if viewModel.settings.ringSize > newMax {
viewModel.settings.ringSize = newMax
if settings.ringSize > newMax {
settings.ringSize = newMax
}
}
}
.ignoresSafeArea()
.task {
await viewModel.setupCamera()
}
.onDisappear {
viewModel.restoreBrightness()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
viewModel.updateVideoOrientation(for: UIDevice.current.orientation)
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel.settings, showPaywall: $showPaywall)
}
.fullScreenCover(isPresented: $viewModel.showPostCapturePreview) {
if let media = viewModel.capturedMedia {
PostCapturePreviewView(
media: media,
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
onRetake: {
viewModel.retakeCapture()
},
onSave: {
viewModel.saveCurrentCapture()
},
onShare: {
showShareSheet = true
},
onDismiss: {
viewModel.dismissPostCapturePreview()
}
)
.sheet(isPresented: $showShareSheet) {
ShareSheet(items: viewModel.getShareItems())
}
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.fullScreenCover(isPresented: $showPostCapturePreview) {
postCaptureView
}
}
// MARK: - Max Ring Size Calculation
/// Calculates maximum ring size based on screen dimensions
/// Ring should not exceed 1/4 of the smaller dimension
private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat {
min(geometry.size.width, geometry.size.height) / 4
}
// MARK: - Ring Light Background
@ViewBuilder
private var ringLightBackground: some View {
// Always use the selected light color - premium checks are done in Settings
viewModel.settings.lightColor
.ignoresSafeArea()
}
// MARK: - Camera Preview Area
@ViewBuilder
private func cameraPreviewArea(ringSize: CGFloat) -> some View {
if viewModel.isCameraAuthorized {
// Show preview unless front flash is active
if !viewModel.isPreviewHidden {
CameraPreview(
viewModel: viewModel,
isMirrorFlipped: viewModel.settings.isMirrorFlipped,
zoomFactor: viewModel.settings.currentZoomFactor,
manualRotationAngle: viewModel.manualRotationAngle,
ringLightColor: viewModel.settings.lightColor
)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: viewModel.manualRotationAngle)
}
} else {
// Show placeholder while requesting permission
Rectangle()
.fill(viewModel.settings.lightColor)
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
.padding(ringSize)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
.overlay {
if viewModel.captureSession == nil {
ProgressView()
.tint(.white)
.scaleEffect(1.5)
}
}
}
}
// MARK: - Controls Overlay
private func controlsOverlay(ringSize: CGFloat) -> some View {
VStack {
// Top bar
topControlBar
.padding(.top, ringSize + Design.Spacing.small)
Spacer()
// Bottom capture controls
bottomControlBar
.padding(.bottom, ringSize + Design.Spacing.medium)
}
.padding(.horizontal, ringSize + Design.Spacing.small)
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
}
// MARK: - Top Control Bar
private var topControlBar: some View {
HStack {
// Center Stage button (only shown when available)
if viewModel.isCenterStageAvailable {
Button {
viewModel.toggleCenterStage()
} label: {
Image(systemName: viewModel.isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle")
.font(.body)
.foregroundStyle(viewModel.isCenterStageEnabled ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Center Stage"))
.accessibilityValue(viewModel.isCenterStageEnabled ? "On" : "Off")
.accessibilityHint(String(localized: "Keeps you centered in frame"))
}
// Rotate preview button
Button {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
viewModel.cycleManualRotation()
}
} label: {
Image(systemName: rotationIconName)
.font(.body)
.foregroundStyle(viewModel.manualRotationAngle != 0 ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Rotate preview"))
.accessibilityValue(rotationAccessibilityValue)
.accessibilityHint(String(localized: "Rotates the camera preview 90 degrees"))
Spacer()
// Grid toggle
Button {
viewModel.settings.isGridVisible.toggle()
settings.isGridVisible.toggle()
} label: {
Image(systemName: "square.grid.3x3")
.font(.body)
.foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white)
.foregroundStyle(settings.isGridVisible ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
.background(.ultraThinMaterial, in: Circle())
}
.accessibilityLabel(String(localized: "Toggle grid"))
.accessibilityValue(viewModel.settings.isGridVisible ? "On" : "Off")
.accessibilityValue(settings.isGridVisible ? "On" : "Off")
// Settings button
Button {
@ -218,182 +109,34 @@ struct ContentView: View {
.font(.body)
.foregroundStyle(.white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: .circle)
.background(.ultraThinMaterial, in: Circle())
}
.accessibilityLabel(String(localized: "Settings"))
}
}
/// Icon name for rotation button based on current rotation
private var rotationIconName: String {
switch viewModel.manualRotationAngle {
case 90:
return "rotate.right.fill"
case 180:
return "arrow.up.arrow.down"
case 270:
return "rotate.left.fill"
default:
return "rotate.right"
}
}
// MARK: - Post Capture View
/// Accessibility value for rotation button
private var rotationAccessibilityValue: String {
switch viewModel.manualRotationAngle {
case 0:
return String(localized: "No rotation")
case 90:
return String(localized: "Rotated 90 degrees right")
case 180:
return String(localized: "Rotated 180 degrees")
case 270:
return String(localized: "Rotated 90 degrees left")
default:
return String(localized: "Custom rotation")
}
}
// MARK: - Bottom Control Bar
private var bottomControlBar: some View {
HStack {
// Switch camera button
Button {
viewModel.switchCamera()
} label: {
Image(systemName: "camera.rotate.fill")
.font(.title2)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Switch camera"))
Spacer()
// Capture button
captureButton
Spacer()
// Capture mode selector
captureModeMenu
}
}
// MARK: - Capture Button
private var captureButton: some View {
Button {
captureAction()
} label: {
ZStack {
Circle()
.fill(.white)
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
Circle()
.stroke(.white, lineWidth: Design.LineWidth.thick)
.frame(width: Design.Capture.buttonSize + Design.Spacing.small, height: Design.Capture.buttonSize + Design.Spacing.small)
// Show red stop square when recording
if viewModel.isRecording {
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
.fill(.red)
.frame(width: Design.Capture.stopSquare, height: Design.Capture.stopSquare)
}
}
}
.accessibilityLabel(captureButtonLabel)
.disabled(!viewModel.canCapture)
}
// MARK: - Capture Mode Menu
private var captureModeMenu: some View {
Menu {
ForEach(CaptureMode.allCases) { mode in
Button {
if !mode.isPremium || premiumManager.isPremiumUnlocked {
viewModel.settings.selectedCaptureMode = mode
} else {
showPaywall = true
}
} label: {
HStack {
Label(mode.displayName, systemImage: mode.systemImage)
if mode.isPremium && !premiumManager.isPremiumUnlocked {
Image(systemName: "crown.fill")
}
}
}
}
} label: {
Image(systemName: viewModel.settings.selectedCaptureMode.systemImage)
.font(.title2)
.foregroundStyle(.white)
.padding(Design.Spacing.medium)
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Capture mode: \(viewModel.settings.selectedCaptureMode.displayName)"))
}
// MARK: - Permission Denied View
private var permissionDeniedView: some View {
VStack(spacing: Design.Spacing.large) {
Image(systemName: "camera.fill")
.font(.system(size: Design.BaseFontSize.hero))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
Text("Camera Access Required")
.font(.title2.bold())
.foregroundStyle(.white)
Text("Please enable camera access in Settings to use SelfieRingLight.")
.font(.body)
.foregroundStyle(.white.opacity(Design.Opacity.strong))
.multilineTextAlignment(.center)
.padding(.horizontal, Design.Spacing.xLarge)
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(Design.Opacity.heavy))
}
// MARK: - Capture Action
private func captureAction() {
switch viewModel.settings.selectedCaptureMode {
case .photo:
viewModel.capturePhoto()
case .video:
if viewModel.isRecording {
viewModel.stopRecording()
} else {
viewModel.startRecording()
}
case .boomerang:
// TODO: Implement boomerang capture
viewModel.capturePhoto()
}
}
private var captureButtonLabel: String {
switch viewModel.settings.selectedCaptureMode {
case .photo:
return String(localized: "Take photo")
case .video:
return viewModel.isRecording ? String(localized: "Stop recording") : String(localized: "Start recording")
case .boomerang:
return String(localized: "Capture boomerang")
@ViewBuilder
private var postCaptureView: some View {
if let image = capturedImage {
PostCapturePreviewView(
media: .photo(image),
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
onRetake: { dismissPostCapture() },
onSave: { saveImage(image) },
onShare: {},
onDismiss: { dismissPostCapture() }
)
} else if let url = capturedVideoURL {
PostCapturePreviewView(
media: .video(url),
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
onRetake: { dismissPostCapture() },
onSave: { saveVideo(url) },
onShare: {},
onDismiss: { dismissPostCapture() }
)
}
}
@ -415,19 +158,61 @@ struct ContentView: View {
}
.accessibilityLabel(message)
}
// MARK: - Capture Handlers
private func handleImageCaptured(_ image: UIImage) {
capturedImage = image
if settings.isAutoSaveEnabled {
saveImage(image)
showToast(String(localized: "Saved to Photos"))
}
// MARK: - Share Sheet
/// UIKit wrapper for UIActivityViewController
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
showPostCapturePreview = true
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
private func handleVideoCaptured(_ url: URL) {
capturedVideoURL = url
if settings.isAutoSaveEnabled {
saveVideo(url)
showToast(String(localized: "Saved to Photos"))
}
showPostCapturePreview = true
}
private func dismissPostCapture() {
showPostCapturePreview = false
capturedImage = nil
capturedVideoURL = nil
}
private func saveImage(_ image: UIImage) {
guard let data = image.jpegData(compressionQuality: 0.9) else { return }
PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
}
}
private func saveVideo(_ url: URL) {
PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil)
}
}
private func showToast(_ message: String) {
toastMessage = message
Task {
try? await Task.sleep(for: .seconds(2))
if toastMessage == message {
toastMessage = nil
}
}
}
}
#Preview {

View File

@ -109,6 +109,10 @@
"comment" : "An accessibility label for the custom color button.",
"isCommentAutoGenerated" : true
},
"Custom rotation" : {
"comment" : "Accessibility value for the rotate preview button when the user has customized the rotation angle.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true
@ -164,6 +168,10 @@
"comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true
},
"No rotation" : {
"comment" : "Accessibility value for the rotation button when the preview is not rotated.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
@ -231,6 +239,26 @@
"comment" : "The label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Rotate preview" : {
"comment" : "A button that rotates the camera preview by 90 degrees.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees left" : {
"comment" : "Accessibility value for the \"Rotate preview\" button when the camera preview is rotated 90 degrees to the left.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees right" : {
"comment" : "Accessibility value for the \"Rotate preview\" button when the camera preview is rotated 90 degrees to the right.",
"isCommentAutoGenerated" : true
},
"Rotated 180 degrees" : {
"comment" : "Accessibility value of the \"Rotate preview\" button when the preview is 180 degrees rotated.",
"isCommentAutoGenerated" : true
},
"Rotates the camera preview 90 degrees" : {
"comment" : "A hint that explains how to rotate the camera preview.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true