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:
parent
e628897bff
commit
6e1ce6d262
@ -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 */
|
||||
|
||||
233
SelfieRingLight/Features/Camera/CameraPreview.swift
Normal file
233
SelfieRingLight/Features/Camera/CameraPreview.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
393
SelfieRingLight/Features/Camera/CameraViewModel.swift
Normal file
393
SelfieRingLight/Features/Camera/CameraViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
.onVideoCaptured { url, _ in
|
||||
handleVideoCaptured(url)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
// Ring light border overlay
|
||||
ringLightOverlay(ringSize: effectiveRingSize)
|
||||
}
|
||||
.overlay {
|
||||
// Grid overlay
|
||||
if settings.isGridVisible {
|
||||
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)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// MARK: - Controls Overlay (on top of preview)
|
||||
controlsOverlay(ringSize: effectiveRingSize)
|
||||
|
||||
// MARK: - Permission Denied View
|
||||
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
|
||||
permissionDeniedView
|
||||
}
|
||||
.overlay {
|
||||
// Top controls
|
||||
VStack {
|
||||
topControlBar
|
||||
.padding(.top, effectiveRingSize + Design.Spacing.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
|
||||
}
|
||||
.overlay {
|
||||
// Toast
|
||||
if let message = toastMessage {
|
||||
|
||||
// MARK: - Toast Notification
|
||||
if let message = viewModel.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 settings.ringSize > newMax {
|
||||
settings.ringSize = newMax
|
||||
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: $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 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)
|
||||
)
|
||||
|
||||
settings.lightColor
|
||||
.mask(
|
||||
Rectangle()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.frame(width: innerRect.width, height: innerRect.height)
|
||||
.blendMode(.destinationOut)
|
||||
)
|
||||
.compositingGroup()
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
/// 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")
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleCenterStage() {
|
||||
AVCaptureDevice.centerStageControlMode = .app
|
||||
AVCaptureDevice.isCenterStageEnabled.toggle()
|
||||
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
||||
// 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: - Post Capture View
|
||||
// MARK: - Capture Button
|
||||
|
||||
@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 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"))
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
showPostCapturePreview = true
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user