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
This commit is contained in:
parent
72bac70ea1
commit
f38e0bf22c
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,15 +2,72 @@ import SwiftUI
|
|||||||
import MijickCamera
|
import MijickCamera
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
/// Simple test view to verify MijickCamera works
|
/// Main camera view with ring light effect using MijickCamera
|
||||||
struct ContentView: View {
|
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 {
|
var body: some View {
|
||||||
// Just MCamera - nothing else - to test if it renders
|
|
||||||
MCamera()
|
MCamera()
|
||||||
.onImageCaptured { image, _ in
|
.setCameraScreen { manager, namespace, closeAction in
|
||||||
print("Image captured!")
|
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()
|
.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,35 +2,19 @@ import SwiftUI
|
|||||||
import AVKit
|
import AVKit
|
||||||
import Bedrock
|
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
|
// MARK: - Post Capture Preview View
|
||||||
|
|
||||||
/// Full-screen preview shown after photo/video capture
|
/// Full-screen preview shown after photo/video capture
|
||||||
struct PostCapturePreviewView: View {
|
struct PostCapturePreviewView: View {
|
||||||
let media: CapturedMedia
|
let capturedImage: UIImage?
|
||||||
let isPremiumUnlocked: Bool
|
let capturedVideoURL: URL?
|
||||||
|
let isAutoSaveEnabled: Bool
|
||||||
let onRetake: () -> Void
|
let onRetake: () -> Void
|
||||||
let onSave: () -> Void
|
let onSave: () -> Void
|
||||||
let onShare: () -> Void
|
|
||||||
let onDismiss: () -> Void
|
|
||||||
|
|
||||||
@State private var showEditSheet = false
|
|
||||||
@State private var player: AVPlayer?
|
@State private var player: AVPlayer?
|
||||||
|
@State private var showShareSheet = false
|
||||||
|
@State private var toastMessage: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -50,18 +34,27 @@ struct PostCapturePreviewView: View {
|
|||||||
// Bottom toolbar
|
// Bottom toolbar
|
||||||
bottomToolbar
|
bottomToolbar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
if let message = toastMessage {
|
||||||
|
toastView(message: message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
setupVideoPlayerIfNeeded()
|
setupVideoPlayerIfNeeded()
|
||||||
|
if isAutoSaveEnabled {
|
||||||
|
autoSave()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showEditSheet) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
PostCaptureEditView(
|
if let image = capturedImage {
|
||||||
media: media,
|
ShareSheet(items: [image])
|
||||||
isPremiumUnlocked: isPremiumUnlocked
|
} else if let url = capturedVideoURL {
|
||||||
)
|
ShareSheet(items: [url])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,28 +62,20 @@ struct PostCapturePreviewView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mediaPreview: some View {
|
private var mediaPreview: some View {
|
||||||
switch media {
|
if let image = capturedImage {
|
||||||
case .photo(let image):
|
|
||||||
Image(uiImage: image)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFit()
|
.scaledToFit()
|
||||||
.accessibilityLabel(String(localized: "Captured photo"))
|
.accessibilityLabel(String(localized: "Captured photo"))
|
||||||
|
} else if let _ = capturedVideoURL, let player {
|
||||||
case .video(let url), .boomerang(let url):
|
VideoPlayer(player: player)
|
||||||
if let player {
|
.onAppear {
|
||||||
VideoPlayer(player: player)
|
player.play()
|
||||||
.onAppear {
|
}
|
||||||
player.play()
|
.accessibilityLabel(String(localized: "Captured video"))
|
||||||
}
|
} else {
|
||||||
.accessibilityLabel(
|
ProgressView()
|
||||||
media == .boomerang(url)
|
.tint(.white)
|
||||||
? String(localized: "Captured boomerang")
|
|
||||||
: String(localized: "Captured video")
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.tint(.white)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,7 +84,7 @@ struct PostCapturePreviewView: View {
|
|||||||
private var topBar: some View {
|
private var topBar: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Button {
|
Button {
|
||||||
onDismiss()
|
onRetake()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
@ -128,20 +113,25 @@ struct PostCapturePreviewView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// Edit button
|
// Save button (if not auto-saved)
|
||||||
ToolbarButton(
|
if !isAutoSaveEnabled {
|
||||||
title: String(localized: "Edit"),
|
ToolbarButton(
|
||||||
systemImage: "slider.horizontal.3",
|
title: String(localized: "Save"),
|
||||||
action: { showEditSheet = true }
|
systemImage: "square.and.arrow.down",
|
||||||
)
|
action: {
|
||||||
|
onSave()
|
||||||
|
showToast(String(localized: "Saved to Photos"))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
// Share button
|
// Share button
|
||||||
ToolbarButton(
|
ToolbarButton(
|
||||||
title: String(localized: "Share"),
|
title: String(localized: "Share"),
|
||||||
systemImage: "square.and.arrow.up",
|
systemImage: "square.and.arrow.up",
|
||||||
action: onShare
|
action: { showShareSheet = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||||
@ -149,26 +139,51 @@ struct PostCapturePreviewView: View {
|
|||||||
.background(.ultraThinMaterial)
|
.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
|
// MARK: - Video Setup
|
||||||
|
|
||||||
private func setupVideoPlayerIfNeeded() {
|
private func setupVideoPlayerIfNeeded() {
|
||||||
switch media {
|
guard let url = capturedVideoURL else { return }
|
||||||
case .photo:
|
player = AVPlayer(url: url)
|
||||||
break
|
}
|
||||||
case .video(let url):
|
|
||||||
player = AVPlayer(url: url)
|
// MARK: - Auto Save
|
||||||
case .boomerang(let url):
|
|
||||||
let player = AVPlayer(url: url)
|
private func autoSave() {
|
||||||
// Loop boomerang videos
|
if let image = capturedImage {
|
||||||
NotificationCenter.default.addObserver(
|
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||||
forName: .AVPlayerItemDidPlayToEndTime,
|
showToast(String(localized: "Saved to Photos"))
|
||||||
object: player.currentItem,
|
}
|
||||||
queue: .main
|
// Video saving would go here
|
||||||
) { _ in
|
}
|
||||||
player.seek(to: .zero)
|
|
||||||
player.play()
|
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 ShareSheet: UIViewControllerRepresentable {
|
||||||
struct PostCaptureEditView: View {
|
let items: [Any]
|
||||||
let media: CapturedMedia
|
|
||||||
let isPremiumUnlocked: Bool
|
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
@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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
PostCapturePreviewView(
|
PostCapturePreviewView(
|
||||||
media: .photo(UIImage(systemName: "photo")!),
|
capturedImage: UIImage(systemName: "photo"),
|
||||||
isPremiumUnlocked: false,
|
capturedVideoURL: nil,
|
||||||
|
isAutoSaveEnabled: false,
|
||||||
onRetake: {},
|
onRetake: {},
|
||||||
onSave: {},
|
onSave: {}
|
||||||
onShare: {},
|
|
||||||
onDismiss: {}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
151
SelfieRingLight/Features/Camera/RingLightCameraScreen.swift
Normal file
151
SelfieRingLight/Features/Camera/RingLightCameraScreen.swift
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -61,22 +61,10 @@
|
|||||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Camera Access Required" : {
|
|
||||||
"comment" : "A title displayed in the \"Permission Denied\" view when camera access is required.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
"comment" : "The text for a button that dismisses the current view.",
|
"comment" : "The text for a button that dismisses the current view.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Captured boomerang" : {
|
||||||
"comment" : "A label describing a captured boomerang.",
|
"comment" : "A label describing a captured boomerang.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -89,10 +77,6 @@
|
|||||||
"comment" : "A label describing a captured video.",
|
"comment" : "A label describing a captured video.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Center Stage" : {
|
|
||||||
"comment" : "A button that toggles whether the user is centered in the video feed.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Close preview" : {
|
"Close preview" : {
|
||||||
"comment" : "A button label that closes the preview screen.",
|
"comment" : "A button label that closes the preview screen.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -109,10 +93,6 @@
|
|||||||
"comment" : "An accessibility label for the custom color button.",
|
"comment" : "An accessibility label for the custom color button.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Custom rotation" : {
|
|
||||||
"comment" : "An accessibility label for the custom rotation mode in the content view.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Debug mode: Purchase simulated!" : {
|
"Debug mode: Purchase simulated!" : {
|
||||||
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -153,10 +133,6 @@
|
|||||||
"comment" : "Name of a ring light color preset.",
|
"comment" : "Name of a ring light color preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Keeps you centered in frame" : {
|
|
||||||
"comment" : "A hint that explains the purpose of the \"Center Stage\" button.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Last synced %@" : {
|
"Last synced %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -168,10 +144,6 @@
|
|||||||
"comment" : "A hint that appears when a user taps on a color preset button.",
|
"comment" : "A hint that appears when a user taps on a color preset button.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"No rotation" : {
|
|
||||||
"comment" : "An accessibility label for a rotation button that is not rotated.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"No Watermarks • Ad-Free" : {
|
"No Watermarks • Ad-Free" : {
|
||||||
"comment" : "Description of a benefit that comes with the Pro subscription.",
|
"comment" : "Description of a benefit that comes with the Pro subscription.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -180,14 +152,6 @@
|
|||||||
"comment" : "The accessibility value for the grid toggle when it is off.",
|
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Open Source Licenses" : {
|
||||||
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -203,10 +167,6 @@
|
|||||||
"comment" : "Voiceover announcement when a photo is captured.",
|
"comment" : "Voiceover announcement when a photo is captured.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Premium color" : {
|
||||||
"comment" : "An accessibility hint for a premium color option in the color preset button.",
|
"comment" : "An accessibility hint for a premium color option in the color preset button.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -243,26 +203,6 @@
|
|||||||
"comment" : "The label for the ring size slider in the settings view.",
|
"comment" : "The label for the ring size slider in the settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"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" : {
|
"Saved to Photos" : {
|
||||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -310,14 +250,6 @@
|
|||||||
"comment" : "Name of a ring light color preset.",
|
"comment" : "Name of a ring light color preset.",
|
||||||
"isCommentAutoGenerated" : true
|
"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 %@" : {
|
"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.",
|
"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,
|
"isCommentAutoGenerated" : true,
|
||||||
@ -330,10 +262,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Switch camera" : {
|
|
||||||
"comment" : "A button label that says \"Switch camera\".",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Sync Now" : {
|
"Sync Now" : {
|
||||||
"comment" : "A button label that triggers a sync action.",
|
"comment" : "A button label that triggers a sync action.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -352,19 +280,11 @@
|
|||||||
},
|
},
|
||||||
"Syncing..." : {
|
"Syncing..." : {
|
||||||
|
|
||||||
},
|
|
||||||
"Take photo" : {
|
|
||||||
"comment" : "A button label that says \"Take photo\".",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
},
|
||||||
"Third-party libraries used in this app" : {
|
"Third-party libraries used in this app" : {
|
||||||
"comment" : "A description of the third-party libraries used in this app.",
|
"comment" : "A description of the third-party libraries used in this app.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Toggle grid" : {
|
|
||||||
"comment" : "A button that toggles the visibility of the grid in the camera view.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"True Mirror" : {
|
"True Mirror" : {
|
||||||
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
|
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user