diff --git a/SelfieRingLight.xcodeproj/project.pbxproj b/SelfieRingLight.xcodeproj/project.pbxproj index 416eebb..9dab21d 100644 --- a/SelfieRingLight.xcodeproj/project.pbxproj +++ b/SelfieRingLight.xcodeproj/project.pbxproj @@ -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 */; diff --git a/SelfieRingLight/Features/Camera/CameraPreview.swift b/SelfieRingLight/Features/Camera/CameraPreview.swift deleted file mode 100644 index 126d2c1..0000000 --- a/SelfieRingLight/Features/Camera/CameraPreview.swift +++ /dev/null @@ -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 - } - } -} diff --git a/SelfieRingLight/Features/Camera/CameraViewModel.swift b/SelfieRingLight/Features/Camera/CameraViewModel.swift deleted file mode 100644 index 435539f..0000000 --- a/SelfieRingLight/Features/Camera/CameraViewModel.swift +++ /dev/null @@ -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 - } -} diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift index edf49a9..a055a0b 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -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) + SettingsView(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()) - } - } + .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: - Share Sheet - -/// UIKit wrapper for UIActivityViewController -struct ShareSheet: UIViewControllerRepresentable { - let items: [Any] - func makeUIViewController(context: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: items, applicationActivities: nil) + // MARK: - Capture Handlers + + private func handleImageCaptured(_ image: UIImage) { + capturedImage = image + + if settings.isAutoSaveEnabled { + saveImage(image) + showToast(String(localized: "Saved to Photos")) + } + + 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 { diff --git a/SelfieRingLight/Resources/Localizable.xcstrings b/SelfieRingLight/Resources/Localizable.xcstrings index f25267f..0922fc0 100644 --- a/SelfieRingLight/Resources/Localizable.xcstrings +++ b/SelfieRingLight/Resources/Localizable.xcstrings @@ -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