Revert to custom camera implementation - MijickCamera not working

MijickCamera wasn't rendering the camera preview properly in our
view hierarchy. Reverting to our custom AVFoundation-based camera
implementation which was working correctly.

Reverted:
- Restored CameraViewModel.swift
- Restored CameraPreview.swift
- Restored ContentView.swift to pre-MijickCamera version
- Removed MijickCamera package dependency

Kept:
- Open Source Licenses section (now just shows RevenueCat)
- All other features and fixes

Our custom camera code handles:
- Camera preview with proper orientation
- Photo/video capture
- Front flash effect
- Center Stage support
- Manual rotation
- Zoom gestures
This commit is contained in:
Matt Bruce 2026-01-02 16:26:41 -06:00
parent e628897bff
commit 6e1ce6d262
4 changed files with 949 additions and 189 deletions

View File

@ -10,7 +10,6 @@
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 */
@ -64,7 +63,6 @@
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -138,7 +136,6 @@
EA766C852F08306200DC03E1 /* RevenueCat */,
EA766C872F08306200DC03E1 /* RevenueCatUI */,
EA766F012F08500000DC03E1 /* Bedrock */,
EA766F0F2F08600000DC03E1 /* MijickCamera */,
);
productName = SelfieRingLight;
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
@ -225,7 +222,6 @@
packageReferences = (
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
@ -642,14 +638,6 @@
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 */

View File

@ -0,0 +1,233 @@
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

@ -0,0 +1,393 @@
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,119 +1,161 @@
import SwiftUI
import MijickCamera
import Bedrock
import Photos
import AVFoundation
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var viewModel = CameraViewModel()
@State private var premiumManager = PremiumManager()
@State private var showPaywall = false
@State private var showSettings = 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
// Center Stage support
@State private var isCenterStageAvailable = false
@State private var isCenterStageEnabled = false
@State private var showShareSheet = false
var body: some View {
GeometryReader { geometry in
let maxRingSize = calculateMaxRingSize(for: geometry)
let effectiveRingSize = min(settings.ringSize, maxRingSize)
let effectiveRingSize = min(viewModel.settings.ringSize, maxRingSize)
// Use MCamera as the base, with ring light as border
MCamera()
.onImageCaptured { image, _ in
handleImageCaptured(image)
ZStack {
// MARK: - Ring Light Background
ringLightBackground
// MARK: - Camera Preview (full screen with ring border)
cameraPreviewArea(ringSize: effectiveRingSize)
// MARK: - Grid Overlay
if viewModel.settings.isGridVisible && !viewModel.isPreviewHidden {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
}
.onVideoCaptured { url, _ in
handleVideoCaptured(url)
// MARK: - Controls Overlay (on top of preview)
controlsOverlay(ringSize: effectiveRingSize)
// MARK: - Permission Denied View
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
permissionDeniedView
}
.ignoresSafeArea()
.overlay {
// Ring light border overlay
ringLightOverlay(ringSize: effectiveRingSize)
// MARK: - Toast Notification
if let message = viewModel.toastMessage {
toastView(message: message)
}
.overlay {
// Grid overlay
if settings.isGridVisible {
GridOverlay(isVisible: true)
.padding(effectiveRingSize)
.allowsHitTesting(false)
}
}
.overlay {
// Top controls
VStack {
topControlBar
.padding(.top, effectiveRingSize + Design.Spacing.small)
Spacer()
}
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
}
.overlay {
// Toast
if let message = toastMessage {
toastView(message: message)
}
}
.onChange(of: geometry.size) { _, newSize in
let newMax = min(newSize.width, newSize.height) / 4
if settings.ringSize > newMax {
settings.ringSize = newMax
}
}
.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
}
}
}
.onAppear {
checkCenterStageAvailability()
.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: settings, showPaywall: $showPaywall)
SettingsView(viewModel: viewModel.settings, showPaywall: $showPaywall)
}
.fullScreenCover(isPresented: $showPostCapturePreview) {
postCaptureView
.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())
}
}
}
}
// 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 Overlay
// MARK: - Ring Light Background
/// Creates a ring light effect as a border around the camera
@ViewBuilder
private func ringLightOverlay(ringSize: CGFloat) -> some View {
// Use a rectangle with a large inner cutout to create the ring effect
GeometryReader { geo in
let innerRect = CGRect(
x: ringSize,
y: ringSize,
width: geo.size.width - (ringSize * 2),
height: geo.size.height - (ringSize * 2)
)
private var ringLightBackground: some View {
// Always use the selected light color - premium checks are done in Settings
viewModel.settings.lightColor
.ignoresSafeArea()
}
settings.lightColor
.mask(
Rectangle()
.overlay(
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
.frame(width: innerRect.width, height: innerRect.height)
.blendMode(.destinationOut)
)
.compositingGroup()
// 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
)
.allowsHitTesting(false)
.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)
}
@ -122,35 +164,51 @@ struct ContentView: View {
private var topControlBar: some View {
HStack {
// Center Stage button (only shown when available)
if isCenterStageAvailable {
if viewModel.isCenterStageAvailable {
Button {
toggleCenterStage()
viewModel.toggleCenterStage()
} label: {
Image(systemName: isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle")
Image(systemName: viewModel.isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle")
.font(.body)
.foregroundStyle(isCenterStageEnabled ? .yellow : .white)
.foregroundStyle(viewModel.isCenterStageEnabled ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: Circle())
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Center Stage"))
.accessibilityValue(isCenterStageEnabled ? "On" : "Off")
.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 {
settings.isGridVisible.toggle()
viewModel.settings.isGridVisible.toggle()
} label: {
Image(systemName: "square.grid.3x3")
.font(.body)
.foregroundStyle(settings.isGridVisible ? .yellow : .white)
.foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: Circle())
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Toggle grid"))
.accessibilityValue(settings.isGridVisible ? "On" : "Off")
.accessibilityValue(viewModel.settings.isGridVisible ? "On" : "Off")
// Settings button
Button {
@ -160,52 +218,182 @@ struct ContentView: View {
.font(.body)
.foregroundStyle(.white)
.padding(Design.Spacing.small)
.background(.ultraThinMaterial, in: Circle())
.background(.ultraThinMaterial, in: .circle)
}
.accessibilityLabel(String(localized: "Settings"))
}
}
// MARK: - Center Stage
private func checkCenterStageAvailability() {
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
isCenterStageAvailable = false
return
/// 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"
}
isCenterStageAvailable = device.activeFormat.isCenterStageSupported
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
}
private func toggleCenterStage() {
AVCaptureDevice.centerStageControlMode = .app
AVCaptureDevice.isCenterStageEnabled.toggle()
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
/// 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: - Post Capture View
// MARK: - Bottom Control Bar
@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() }
)
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")
}
}
@ -227,61 +415,19 @@ struct ContentView: View {
}
.accessibilityLabel(message)
}
}
// MARK: - Capture Handlers
// MARK: - Share Sheet
private func handleImageCaptured(_ image: UIImage) {
capturedImage = image
/// UIKit wrapper for UIActivityViewController
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
if settings.isAutoSaveEnabled {
saveImage(image)
showToast(String(localized: "Saved to Photos"))
}
showPostCapturePreview = true
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
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
}
}
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {