From f38e0bf22c900f6e12ee5a744bbc9886c7c62b75 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 16:37:49 -0600 Subject: [PATCH] Integrate MijickCamera with ring light effect - Add MijickCamera package for camera handling - Create RingLightCameraScreen conforming to MCameraScreen protocol - Ring light background fills screen with settings.lightColor - Camera preview padded inward to create ring effect - Custom capture button and controls overlay - Grid overlay support - Camera flip button using MijickCamera API - Delete old CameraViewModel and CameraPreview (replaced by MijickCamera) - Simplified PostCapturePreviewView for photo/video preview --- .../Features/Camera/CameraPreview.swift | 233 ----------- .../Features/Camera/CameraViewModel.swift | 393 ------------------ .../Features/Camera/ContentView.swift | 65 ++- .../Camera/PostCapturePreviewView.swift | 304 +++++--------- .../Camera/RingLightCameraScreen.swift | 151 +++++++ .../Resources/Localizable.xcstrings | 80 ---- 6 files changed, 307 insertions(+), 919 deletions(-) delete mode 100644 SelfieRingLight/Features/Camera/CameraPreview.swift delete mode 100644 SelfieRingLight/Features/Camera/CameraViewModel.swift create mode 100644 SelfieRingLight/Features/Camera/RingLightCameraScreen.swift 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 acb4993..69908e9 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -2,15 +2,72 @@ import SwiftUI import MijickCamera import Bedrock -/// Simple test view to verify MijickCamera works +/// Main camera view with ring light effect using MijickCamera struct ContentView: View { + @State private var settings = SettingsViewModel() + @State private var premiumManager = PremiumManager() + @State private var showSettings = false + @State private var showPaywall = false + + // Post-capture state + @State private var capturedImage: UIImage? + @State private var capturedVideoURL: URL? + @State private var showPostCapture = false + var body: some View { - // Just MCamera - nothing else - to test if it renders MCamera() - .onImageCaptured { image, _ in - print("Image captured!") + .setCameraScreen { manager, namespace, closeAction in + RingLightCameraScreen( + cameraManager: manager, + namespace: namespace, + closeMCameraAction: closeAction, + settings: settings, + isPremiumUnlocked: premiumManager.isPremiumUnlocked, + onSettingsTapped: { showSettings = true } + ) } + .onImageCaptured { image, _ in + capturedImage = image + showPostCapture = true + } + .onVideoCaptured { url, _ in + capturedVideoURL = url + showPostCapture = true + } + .startSession() .ignoresSafeArea() + .sheet(isPresented: $showSettings) { + SettingsView(viewModel: settings, showPaywall: $showPaywall) + } + .sheet(isPresented: $showPaywall) { + ProPaywallView() + } + .fullScreenCover(isPresented: $showPostCapture) { + PostCapturePreviewView( + capturedImage: capturedImage, + capturedVideoURL: capturedVideoURL, + isAutoSaveEnabled: settings.isAutoSaveEnabled, + onRetake: { + capturedImage = nil + capturedVideoURL = nil + showPostCapture = false + }, + onSave: { + saveCapture() + showPostCapture = false + } + ) + } + } + + // MARK: - Save Capture + + private func saveCapture() { + if let image = capturedImage { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + } + capturedImage = nil + capturedVideoURL = nil } } diff --git a/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift b/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift index 217c9db..248be98 100644 --- a/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift +++ b/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift @@ -2,35 +2,19 @@ import SwiftUI import AVKit import Bedrock -// MARK: - Captured Media Type - -/// Represents captured media for preview -enum CapturedMedia: Equatable { - case photo(UIImage) - case video(URL) - case boomerang(URL) - - var isVideo: Bool { - switch self { - case .photo: return false - case .video, .boomerang: return true - } - } -} - // MARK: - Post Capture Preview View /// Full-screen preview shown after photo/video capture struct PostCapturePreviewView: View { - let media: CapturedMedia - let isPremiumUnlocked: Bool + let capturedImage: UIImage? + let capturedVideoURL: URL? + let isAutoSaveEnabled: Bool let onRetake: () -> Void let onSave: () -> Void - let onShare: () -> Void - let onDismiss: () -> Void - @State private var showEditSheet = false @State private var player: AVPlayer? + @State private var showShareSheet = false + @State private var toastMessage: String? var body: some View { ZStack { @@ -50,18 +34,27 @@ struct PostCapturePreviewView: View { // Bottom toolbar bottomToolbar } + + // Toast notification + if let message = toastMessage { + toastView(message: message) + } } .onAppear { setupVideoPlayerIfNeeded() + if isAutoSaveEnabled { + autoSave() + } } .onDisappear { player?.pause() } - .sheet(isPresented: $showEditSheet) { - PostCaptureEditView( - media: media, - isPremiumUnlocked: isPremiumUnlocked - ) + .sheet(isPresented: $showShareSheet) { + if let image = capturedImage { + ShareSheet(items: [image]) + } else if let url = capturedVideoURL { + ShareSheet(items: [url]) + } } } @@ -69,28 +62,20 @@ struct PostCapturePreviewView: View { @ViewBuilder private var mediaPreview: some View { - switch media { - case .photo(let image): + if let image = capturedImage { Image(uiImage: image) .resizable() .scaledToFit() .accessibilityLabel(String(localized: "Captured photo")) - - case .video(let url), .boomerang(let url): - if let player { - VideoPlayer(player: player) - .onAppear { - player.play() - } - .accessibilityLabel( - media == .boomerang(url) - ? String(localized: "Captured boomerang") - : String(localized: "Captured video") - ) - } else { - ProgressView() - .tint(.white) - } + } else if let _ = capturedVideoURL, let player { + VideoPlayer(player: player) + .onAppear { + player.play() + } + .accessibilityLabel(String(localized: "Captured video")) + } else { + ProgressView() + .tint(.white) } } @@ -99,7 +84,7 @@ struct PostCapturePreviewView: View { private var topBar: some View { HStack { Button { - onDismiss() + onRetake() } label: { Image(systemName: "xmark") .font(.title2) @@ -128,20 +113,25 @@ struct PostCapturePreviewView: View { Spacer() - // Edit button - ToolbarButton( - title: String(localized: "Edit"), - systemImage: "slider.horizontal.3", - action: { showEditSheet = true } - ) - - Spacer() + // Save button (if not auto-saved) + if !isAutoSaveEnabled { + ToolbarButton( + title: String(localized: "Save"), + systemImage: "square.and.arrow.down", + action: { + onSave() + showToast(String(localized: "Saved to Photos")) + } + ) + + Spacer() + } // Share button ToolbarButton( title: String(localized: "Share"), systemImage: "square.and.arrow.up", - action: onShare + action: { showShareSheet = true } ) } .padding(.horizontal, Design.Spacing.xxLarge) @@ -149,26 +139,51 @@ struct PostCapturePreviewView: View { .background(.ultraThinMaterial) } + // MARK: - Toast View + + private func toastView(message: String) -> some View { + VStack { + Spacer() + + Text(message) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .background(.ultraThinMaterial, in: .capsule) + .padding(.bottom, 100) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.easeInOut, value: toastMessage) + } + // MARK: - Video Setup private func setupVideoPlayerIfNeeded() { - switch media { - case .photo: - break - case .video(let url): - player = AVPlayer(url: url) - case .boomerang(let url): - let player = AVPlayer(url: url) - // Loop boomerang videos - NotificationCenter.default.addObserver( - forName: .AVPlayerItemDidPlayToEndTime, - object: player.currentItem, - queue: .main - ) { _ in - player.seek(to: .zero) - player.play() + guard let url = capturedVideoURL else { return } + player = AVPlayer(url: url) + } + + // MARK: - Auto Save + + private func autoSave() { + if let image = capturedImage { + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) + showToast(String(localized: "Saved to Photos")) + } + // Video saving would go here + } + + private func showToast(_ message: String) { + withAnimation { + toastMessage = message + } + + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { + toastMessage = nil } - self.player = player } } } @@ -194,153 +209,24 @@ private struct ToolbarButton: View { } } -// MARK: - Post Capture Edit View +// MARK: - Share Sheet -/// Lightweight editor for captured media -struct PostCaptureEditView: View { - let media: CapturedMedia - let isPremiumUnlocked: Bool +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] - @Environment(\.dismiss) private var dismiss - - @State private var smoothingIntensity: Double = 0.5 - @State private var glowIntensity: Double = 0.3 - - var body: some View { - NavigationStack { - VStack(spacing: Design.Spacing.large) { - // Preview (simplified for now) - mediaPreview - .frame(maxHeight: .infinity) - - // Edit controls - VStack(spacing: Design.Spacing.medium) { - // Smoothing slider - EditSlider( - title: String(localized: "Smoothing"), - value: $smoothingIntensity, - systemImage: "wand.and.stars" - ) - - // Glow effect slider - EditSlider( - title: String(localized: "Ring Glow"), - value: $glowIntensity, - systemImage: "light.max" - ) - - // Premium tools placeholder - if !isPremiumUnlocked { - premiumToolsTeaser - } - } - .padding(.horizontal, Design.Spacing.large) - .padding(.bottom, Design.Spacing.large) - } - .background(Color.Surface.overlay) - .navigationTitle(String(localized: "Edit")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button(String(localized: "Cancel")) { - dismiss() - } - .foregroundStyle(.white) - } - - ToolbarItem(placement: .topBarTrailing) { - Button(String(localized: "Done")) { - // Apply edits and dismiss - dismiss() - } - .foregroundStyle(Color.Accent.primary) - .bold() - } - } - } + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) } - @ViewBuilder - private var mediaPreview: some View { - switch media { - case .photo(let image): - Image(uiImage: image) - .resizable() - .scaledToFit() - .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) - .padding(Design.Spacing.medium) - case .video, .boomerang: - // Video editing would show a frame or thumbnail - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(.gray.opacity(Design.Opacity.medium)) - .overlay { - Image(systemName: "video.fill") - .font(.largeTitle) - .foregroundStyle(.white) - } - .padding(Design.Spacing.medium) - } - } - - private var premiumToolsTeaser: some View { - HStack(spacing: Design.Spacing.small) { - Image(systemName: "crown.fill") - .foregroundStyle(Color.Status.warning) - - Text(String(localized: "Unlock filters, AI remove, and more with Pro")) - .font(.system(size: Design.BaseFontSize.caption)) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - - Spacer() - - Image(systemName: "chevron.right") - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - .padding(Design.Spacing.medium) - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.small) - .fill(Color.Accent.primary.opacity(Design.Opacity.subtle)) - ) - } -} - -// MARK: - Edit Slider - -private struct EditSlider: View { - let title: String - @Binding var value: Double - let systemImage: String - - var body: some View { - VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { - HStack { - Image(systemName: systemImage) - .foregroundStyle(.white.opacity(Design.Opacity.strong)) - - Text(title) - .font(.system(size: Design.BaseFontSize.body, weight: .medium)) - .foregroundStyle(.white) - - Spacer() - - Text("\(Int(value * 100))%") - .font(.system(size: Design.BaseFontSize.caption, design: .rounded)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Slider(value: $value, in: 0...1, step: 0.05) - .tint(Color.Accent.primary) - } - } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #Preview { PostCapturePreviewView( - media: .photo(UIImage(systemName: "photo")!), - isPremiumUnlocked: false, + capturedImage: UIImage(systemName: "photo"), + capturedVideoURL: nil, + isAutoSaveEnabled: false, onRetake: {}, - onSave: {}, - onShare: {}, - onDismiss: {} + onSave: {} ) } diff --git a/SelfieRingLight/Features/Camera/RingLightCameraScreen.swift b/SelfieRingLight/Features/Camera/RingLightCameraScreen.swift new file mode 100644 index 0000000..18f06b7 --- /dev/null +++ b/SelfieRingLight/Features/Camera/RingLightCameraScreen.swift @@ -0,0 +1,151 @@ +import SwiftUI +import MijickCamera +import Bedrock + +/// Custom MijickCamera screen with ring light effect +struct RingLightCameraScreen: MCameraScreen { + // Required by MCameraScreen protocol + @ObservedObject var cameraManager: CameraManager + let namespace: Namespace.ID + let closeMCameraAction: () -> () + + // Our custom properties + let settings: SettingsViewModel + let isPremiumUnlocked: Bool + let onSettingsTapped: () -> Void + + // MARK: - Capture Button Inner Padding + private let captureButtonInnerPadding: CGFloat = 8 + + var body: some View { + ZStack { + // Ring light background - fills entire screen + settings.lightColor + .ignoresSafeArea() + + // Camera preview with ring padding (from MijickCamera) + createCameraOutputView() + .clipShape(RoundedRectangle(cornerRadius: Design.CornerRadius.large)) + .padding(effectiveRingSize) + + // Grid overlay if enabled + GridOverlay(isVisible: settings.isGridVisible) + .padding(effectiveRingSize) + .allowsHitTesting(false) + + // Controls overlay + VStack { + topControlBar + Spacer() + bottomControlBar + } + } + } + + // MARK: - Ring Size + + private var effectiveRingSize: CGFloat { + min(settings.ringSize, maxAllowedRingSize) + } + + private var maxAllowedRingSize: CGFloat { + let screenSize = UIScreen.main.bounds.size + let smallerDimension = min(screenSize.width, screenSize.height) + // Allow ring to take up to 40% of the smaller dimension + return smallerDimension * 0.4 + } + + // MARK: - Top Control Bar + + private var topControlBar: some View { + HStack { + // Grid toggle + Button { + settings.isGridVisible.toggle() + } label: { + Image(systemName: settings.isGridVisible ? "square.grid.3x3.fill" : "square.grid.3x3") + .font(.body) + .foregroundStyle(.white) + .padding(Design.Spacing.small) + .background(.ultraThinMaterial, in: Circle()) + } + .accessibilityLabel("Grid") + .accessibilityValue(settings.isGridVisible ? "On" : "Off") + .accessibilityHint("Toggles the rule of thirds grid overlay") + + Spacer() + + // Settings + Button { + onSettingsTapped() + } label: { + Image(systemName: "gearshape.fill") + .font(.body) + .foregroundStyle(.white) + .padding(Design.Spacing.small) + .background(.ultraThinMaterial, in: Circle()) + } + .accessibilityLabel("Settings") + .accessibilityHint("Opens settings") + } + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.small) + } + + // MARK: - Bottom Control Bar + + private var bottomControlBar: some View { + HStack(spacing: Design.Spacing.xLarge) { + // Camera flip + Button { + Task { + try? await setCameraPosition(cameraPosition == .front ? .back : .front) + } + } label: { + Image(systemName: "arrow.triangle.2.circlepath.camera.fill") + .font(.title2) + .foregroundStyle(.white) + .padding(Design.Spacing.medium) + .background(.ultraThinMaterial, in: Circle()) + } + .accessibilityLabel("Switch Camera") + .accessibilityHint("Switches between front and back camera") + + // Capture button + captureButton + + // Placeholder for symmetry + Color.clear + .frame(width: 44, height: 44) + } + .padding(.horizontal, Design.Spacing.xLarge) + .padding(.bottom, Design.Spacing.large) + } + + // MARK: - Capture Button + + private var captureButton: some View { + Button { + captureOutput() + } label: { + ZStack { + Circle() + .fill(.white) + .frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize) + + Circle() + .strokeBorder(settings.lightColor, lineWidth: Design.LineWidth.thick) + .frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize) + + Circle() + .fill(.white) + .frame( + width: Design.Capture.buttonSize - captureButtonInnerPadding, + height: Design.Capture.buttonSize - captureButtonInnerPadding + ) + } + } + .accessibilityLabel("Capture") + .accessibilityHint("Takes a photo") + } +} diff --git a/SelfieRingLight/Resources/Localizable.xcstrings b/SelfieRingLight/Resources/Localizable.xcstrings index 8016193..688fdd8 100644 --- a/SelfieRingLight/Resources/Localizable.xcstrings +++ b/SelfieRingLight/Resources/Localizable.xcstrings @@ -61,22 +61,10 @@ "comment" : "Display name for the \"Boomerang\" capture mode.", "isCommentAutoGenerated" : true }, - "Camera Access Required" : { - "comment" : "A title displayed in the \"Permission Denied\" view when camera access is required.", - "isCommentAutoGenerated" : true - }, "Cancel" : { "comment" : "The text for a button that dismisses the current view.", "isCommentAutoGenerated" : true }, - "Capture boomerang" : { - "comment" : "A button label for capturing a boomerang.", - "isCommentAutoGenerated" : true - }, - "Capture mode: %@" : { - "comment" : "A label describing the current capture mode. The placeholder is replaced with the actual mode name.", - "isCommentAutoGenerated" : true - }, "Captured boomerang" : { "comment" : "A label describing a captured boomerang.", "isCommentAutoGenerated" : true @@ -89,10 +77,6 @@ "comment" : "A label describing a captured video.", "isCommentAutoGenerated" : true }, - "Center Stage" : { - "comment" : "A button that toggles whether the user is centered in the video feed.", - "isCommentAutoGenerated" : true - }, "Close preview" : { "comment" : "A button label that closes the preview screen.", "isCommentAutoGenerated" : true @@ -109,10 +93,6 @@ "comment" : "An accessibility label for the custom color button.", "isCommentAutoGenerated" : true }, - "Custom rotation" : { - "comment" : "An accessibility label for the custom rotation mode in the content view.", - "isCommentAutoGenerated" : true - }, "Debug mode: Purchase simulated!" : { "comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.", "isCommentAutoGenerated" : true @@ -153,10 +133,6 @@ "comment" : "Name of a ring light color preset.", "isCommentAutoGenerated" : true }, - "Keeps you centered in frame" : { - "comment" : "A hint that explains the purpose of the \"Center Stage\" button.", - "isCommentAutoGenerated" : true - }, "Last synced %@" : { }, @@ -168,10 +144,6 @@ "comment" : "A hint that appears when a user taps on a color preset button.", "isCommentAutoGenerated" : true }, - "No rotation" : { - "comment" : "An accessibility label for a rotation button that is not rotated.", - "isCommentAutoGenerated" : true - }, "No Watermarks • Ad-Free" : { "comment" : "Description of a benefit that comes with the Pro subscription.", "isCommentAutoGenerated" : true @@ -180,14 +152,6 @@ "comment" : "The accessibility value for the grid toggle when it is off.", "isCommentAutoGenerated" : true }, - "On" : { - "comment" : "A label describing a setting that is currently enabled.", - "isCommentAutoGenerated" : true - }, - "Open Settings" : { - "comment" : "A button label that opens the device settings.", - "isCommentAutoGenerated" : true - }, "Open Source Licenses" : { "comment" : "A heading displayed above a list of open source licenses used in the app.", "isCommentAutoGenerated" : true @@ -203,10 +167,6 @@ "comment" : "Voiceover announcement when a photo is captured.", "isCommentAutoGenerated" : true }, - "Please enable camera access in Settings to use SelfieRingLight." : { - "comment" : "A message instructing the user to enable camera access in Settings to use SelfieRingLight.", - "isCommentAutoGenerated" : true - }, "Premium color" : { "comment" : "An accessibility hint for a premium color option in the color preset button.", "isCommentAutoGenerated" : true @@ -243,26 +203,6 @@ "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" : "An accessibility label describing a 90-degree left rotation.", - "isCommentAutoGenerated" : true - }, - "Rotated 90 degrees right" : { - "comment" : "An accessibility label describing a 90-degree clockwise rotation.", - "isCommentAutoGenerated" : true - }, - "Rotated 180 degrees" : { - "comment" : "An accessibility label describing a 180-degree rotation.", - "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 @@ -310,14 +250,6 @@ "comment" : "Name of a ring light color preset.", "isCommentAutoGenerated" : true }, - "Start recording" : { - "comment" : "The label for the button that starts recording a video.", - "isCommentAutoGenerated" : true - }, - "Stop recording" : { - "comment" : "The text label for stopping a video capture.", - "isCommentAutoGenerated" : true - }, "Subscribe to %@ for %@" : { "comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.", "isCommentAutoGenerated" : true, @@ -330,10 +262,6 @@ } } }, - "Switch camera" : { - "comment" : "A button label that says \"Switch camera\".", - "isCommentAutoGenerated" : true - }, "Sync Now" : { "comment" : "A button label that triggers a sync action.", "isCommentAutoGenerated" : true @@ -352,19 +280,11 @@ }, "Syncing..." : { - }, - "Take photo" : { - "comment" : "A button label that says \"Take photo\".", - "isCommentAutoGenerated" : true }, "Third-party libraries used in this app" : { "comment" : "A description of the third-party libraries used in this app.", "isCommentAutoGenerated" : true }, - "Toggle grid" : { - "comment" : "A button that toggles the visibility of the grid in the camera view.", - "isCommentAutoGenerated" : true - }, "True Mirror" : { "comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.", "isCommentAutoGenerated" : true