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:
Matt Bruce 2026-01-02 16:37:49 -06:00
parent 72bac70ea1
commit f38e0bf22c
6 changed files with 307 additions and 919 deletions

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -2,15 +2,72 @@ import SwiftUI
import MijickCamera
import Bedrock
/// Simple test view to verify MijickCamera works
/// Main camera view with ring light effect using MijickCamera
struct ContentView: View {
@State private var settings = SettingsViewModel()
@State private var premiumManager = PremiumManager()
@State private var showSettings = false
@State private var showPaywall = false
// Post-capture state
@State private var capturedImage: UIImage?
@State private var capturedVideoURL: URL?
@State private var showPostCapture = false
var body: some View {
// Just MCamera - nothing else - to test if it renders
MCamera()
.onImageCaptured { image, _ in
print("Image captured!")
.setCameraScreen { manager, namespace, closeAction in
RingLightCameraScreen(
cameraManager: manager,
namespace: namespace,
closeMCameraAction: closeAction,
settings: settings,
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
onSettingsTapped: { showSettings = true }
)
}
.onImageCaptured { image, _ in
capturedImage = image
showPostCapture = true
}
.onVideoCaptured { url, _ in
capturedVideoURL = url
showPostCapture = true
}
.startSession()
.ignoresSafeArea()
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: settings, showPaywall: $showPaywall)
}
.sheet(isPresented: $showPaywall) {
ProPaywallView()
}
.fullScreenCover(isPresented: $showPostCapture) {
PostCapturePreviewView(
capturedImage: capturedImage,
capturedVideoURL: capturedVideoURL,
isAutoSaveEnabled: settings.isAutoSaveEnabled,
onRetake: {
capturedImage = nil
capturedVideoURL = nil
showPostCapture = false
},
onSave: {
saveCapture()
showPostCapture = false
}
)
}
}
// MARK: - Save Capture
private func saveCapture() {
if let image = capturedImage {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
capturedImage = nil
capturedVideoURL = nil
}
}

View File

@ -2,35 +2,19 @@ import SwiftUI
import AVKit
import Bedrock
// MARK: - Captured Media Type
/// Represents captured media for preview
enum CapturedMedia: Equatable {
case photo(UIImage)
case video(URL)
case boomerang(URL)
var isVideo: Bool {
switch self {
case .photo: return false
case .video, .boomerang: return true
}
}
}
// MARK: - Post Capture Preview View
/// Full-screen preview shown after photo/video capture
struct PostCapturePreviewView: View {
let media: CapturedMedia
let isPremiumUnlocked: Bool
let capturedImage: UIImage?
let capturedVideoURL: URL?
let isAutoSaveEnabled: Bool
let onRetake: () -> Void
let onSave: () -> Void
let onShare: () -> Void
let onDismiss: () -> Void
@State private var showEditSheet = false
@State private var player: AVPlayer?
@State private var showShareSheet = false
@State private var toastMessage: String?
var body: some View {
ZStack {
@ -50,18 +34,27 @@ struct PostCapturePreviewView: View {
// Bottom toolbar
bottomToolbar
}
// Toast notification
if let message = toastMessage {
toastView(message: message)
}
}
.onAppear {
setupVideoPlayerIfNeeded()
if isAutoSaveEnabled {
autoSave()
}
}
.onDisappear {
player?.pause()
}
.sheet(isPresented: $showEditSheet) {
PostCaptureEditView(
media: media,
isPremiumUnlocked: isPremiumUnlocked
)
.sheet(isPresented: $showShareSheet) {
if let image = capturedImage {
ShareSheet(items: [image])
} else if let url = capturedVideoURL {
ShareSheet(items: [url])
}
}
}
@ -69,28 +62,20 @@ struct PostCapturePreviewView: View {
@ViewBuilder
private var mediaPreview: some View {
switch media {
case .photo(let image):
if let image = capturedImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.accessibilityLabel(String(localized: "Captured photo"))
case .video(let url), .boomerang(let url):
if let player {
VideoPlayer(player: player)
.onAppear {
player.play()
}
.accessibilityLabel(
media == .boomerang(url)
? String(localized: "Captured boomerang")
: String(localized: "Captured video")
)
} else {
ProgressView()
.tint(.white)
}
} else if let _ = capturedVideoURL, let player {
VideoPlayer(player: player)
.onAppear {
player.play()
}
.accessibilityLabel(String(localized: "Captured video"))
} else {
ProgressView()
.tint(.white)
}
}
@ -99,7 +84,7 @@ struct PostCapturePreviewView: View {
private var topBar: some View {
HStack {
Button {
onDismiss()
onRetake()
} label: {
Image(systemName: "xmark")
.font(.title2)
@ -128,20 +113,25 @@ struct PostCapturePreviewView: View {
Spacer()
// Edit button
ToolbarButton(
title: String(localized: "Edit"),
systemImage: "slider.horizontal.3",
action: { showEditSheet = true }
)
Spacer()
// Save button (if not auto-saved)
if !isAutoSaveEnabled {
ToolbarButton(
title: String(localized: "Save"),
systemImage: "square.and.arrow.down",
action: {
onSave()
showToast(String(localized: "Saved to Photos"))
}
)
Spacer()
}
// Share button
ToolbarButton(
title: String(localized: "Share"),
systemImage: "square.and.arrow.up",
action: onShare
action: { showShareSheet = true }
)
}
.padding(.horizontal, Design.Spacing.xxLarge)
@ -149,26 +139,51 @@ struct PostCapturePreviewView: View {
.background(.ultraThinMaterial)
}
// MARK: - Toast View
private func toastView(message: String) -> some View {
VStack {
Spacer()
Text(message)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, Design.Spacing.large)
.padding(.vertical, Design.Spacing.medium)
.background(.ultraThinMaterial, in: .capsule)
.padding(.bottom, 100)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut, value: toastMessage)
}
// MARK: - Video Setup
private func setupVideoPlayerIfNeeded() {
switch media {
case .photo:
break
case .video(let url):
player = AVPlayer(url: url)
case .boomerang(let url):
let player = AVPlayer(url: url)
// Loop boomerang videos
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem,
queue: .main
) { _ in
player.seek(to: .zero)
player.play()
guard let url = capturedVideoURL else { return }
player = AVPlayer(url: url)
}
// MARK: - Auto Save
private func autoSave() {
if let image = capturedImage {
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
showToast(String(localized: "Saved to Photos"))
}
// Video saving would go here
}
private func showToast(_ message: String) {
withAnimation {
toastMessage = message
}
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation {
toastMessage = nil
}
self.player = player
}
}
}
@ -194,153 +209,24 @@ private struct ToolbarButton: View {
}
}
// MARK: - Post Capture Edit View
// MARK: - Share Sheet
/// Lightweight editor for captured media
struct PostCaptureEditView: View {
let media: CapturedMedia
let isPremiumUnlocked: Bool
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
@Environment(\.dismiss) private var dismiss
@State private var smoothingIntensity: Double = 0.5
@State private var glowIntensity: Double = 0.3
var body: some View {
NavigationStack {
VStack(spacing: Design.Spacing.large) {
// Preview (simplified for now)
mediaPreview
.frame(maxHeight: .infinity)
// Edit controls
VStack(spacing: Design.Spacing.medium) {
// Smoothing slider
EditSlider(
title: String(localized: "Smoothing"),
value: $smoothingIntensity,
systemImage: "wand.and.stars"
)
// Glow effect slider
EditSlider(
title: String(localized: "Ring Glow"),
value: $glowIntensity,
systemImage: "light.max"
)
// Premium tools placeholder
if !isPremiumUnlocked {
premiumToolsTeaser
}
}
.padding(.horizontal, Design.Spacing.large)
.padding(.bottom, Design.Spacing.large)
}
.background(Color.Surface.overlay)
.navigationTitle(String(localized: "Edit"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(String(localized: "Cancel")) {
dismiss()
}
.foregroundStyle(.white)
}
ToolbarItem(placement: .topBarTrailing) {
Button(String(localized: "Done")) {
// Apply edits and dismiss
dismiss()
}
.foregroundStyle(Color.Accent.primary)
.bold()
}
}
}
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
@ViewBuilder
private var mediaPreview: some View {
switch media {
case .photo(let image):
Image(uiImage: image)
.resizable()
.scaledToFit()
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
.padding(Design.Spacing.medium)
case .video, .boomerang:
// Video editing would show a frame or thumbnail
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(.gray.opacity(Design.Opacity.medium))
.overlay {
Image(systemName: "video.fill")
.font(.largeTitle)
.foregroundStyle(.white)
}
.padding(Design.Spacing.medium)
}
}
private var premiumToolsTeaser: some View {
HStack(spacing: Design.Spacing.small) {
Image(systemName: "crown.fill")
.foregroundStyle(Color.Status.warning)
Text(String(localized: "Unlock filters, AI remove, and more with Pro"))
.font(.system(size: Design.BaseFontSize.caption))
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
.padding(Design.Spacing.medium)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.small)
.fill(Color.Accent.primary.opacity(Design.Opacity.subtle))
)
}
}
// MARK: - Edit Slider
private struct EditSlider: View {
let title: String
@Binding var value: Double
let systemImage: String
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
HStack {
Image(systemName: systemImage)
.foregroundStyle(.white.opacity(Design.Opacity.strong))
Text(title)
.font(.system(size: Design.BaseFontSize.body, weight: .medium))
.foregroundStyle(.white)
Spacer()
Text("\(Int(value * 100))%")
.font(.system(size: Design.BaseFontSize.caption, design: .rounded))
.foregroundStyle(.white.opacity(Design.Opacity.medium))
}
Slider(value: $value, in: 0...1, step: 0.05)
.tint(Color.Accent.primary)
}
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
PostCapturePreviewView(
media: .photo(UIImage(systemName: "photo")!),
isPremiumUnlocked: false,
capturedImage: UIImage(systemName: "photo"),
capturedVideoURL: nil,
isAutoSaveEnabled: false,
onRetake: {},
onSave: {},
onShare: {},
onDismiss: {}
onSave: {}
)
}

View 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")
}
}

View File

@ -61,22 +61,10 @@
"comment" : "Display name for the \"Boomerang\" capture mode.",
"isCommentAutoGenerated" : true
},
"Camera Access Required" : {
"comment" : "A title displayed in the \"Permission Denied\" view when camera access is required.",
"isCommentAutoGenerated" : true
},
"Cancel" : {
"comment" : "The text for a button that dismisses the current view.",
"isCommentAutoGenerated" : true
},
"Capture boomerang" : {
"comment" : "A button label for capturing a boomerang.",
"isCommentAutoGenerated" : true
},
"Capture mode: %@" : {
"comment" : "A label describing the current capture mode. The placeholder is replaced with the actual mode name.",
"isCommentAutoGenerated" : true
},
"Captured boomerang" : {
"comment" : "A label describing a captured boomerang.",
"isCommentAutoGenerated" : true
@ -89,10 +77,6 @@
"comment" : "A label describing a captured video.",
"isCommentAutoGenerated" : true
},
"Center Stage" : {
"comment" : "A button that toggles whether the user is centered in the video feed.",
"isCommentAutoGenerated" : true
},
"Close preview" : {
"comment" : "A button label that closes the preview screen.",
"isCommentAutoGenerated" : true
@ -109,10 +93,6 @@
"comment" : "An accessibility label for the custom color button.",
"isCommentAutoGenerated" : true
},
"Custom rotation" : {
"comment" : "An accessibility label for the custom rotation mode in the content view.",
"isCommentAutoGenerated" : true
},
"Debug mode: Purchase simulated!" : {
"comment" : "Announcement posted to VoiceOver when a premium purchase is simulated in debug mode.",
"isCommentAutoGenerated" : true
@ -153,10 +133,6 @@
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Keeps you centered in frame" : {
"comment" : "A hint that explains the purpose of the \"Center Stage\" button.",
"isCommentAutoGenerated" : true
},
"Last synced %@" : {
},
@ -168,10 +144,6 @@
"comment" : "A hint that appears when a user taps on a color preset button.",
"isCommentAutoGenerated" : true
},
"No rotation" : {
"comment" : "An accessibility label for a rotation button that is not rotated.",
"isCommentAutoGenerated" : true
},
"No Watermarks • Ad-Free" : {
"comment" : "Description of a benefit that comes with the Pro subscription.",
"isCommentAutoGenerated" : true
@ -180,14 +152,6 @@
"comment" : "The accessibility value for the grid toggle when it is off.",
"isCommentAutoGenerated" : true
},
"On" : {
"comment" : "A label describing a setting that is currently enabled.",
"isCommentAutoGenerated" : true
},
"Open Settings" : {
"comment" : "A button label that opens the device settings.",
"isCommentAutoGenerated" : true
},
"Open Source Licenses" : {
"comment" : "A heading displayed above a list of open source licenses used in the app.",
"isCommentAutoGenerated" : true
@ -203,10 +167,6 @@
"comment" : "Voiceover announcement when a photo is captured.",
"isCommentAutoGenerated" : true
},
"Please enable camera access in Settings to use SelfieRingLight." : {
"comment" : "A message instructing the user to enable camera access in Settings to use SelfieRingLight.",
"isCommentAutoGenerated" : true
},
"Premium color" : {
"comment" : "An accessibility hint for a premium color option in the color preset button.",
"isCommentAutoGenerated" : true
@ -243,26 +203,6 @@
"comment" : "The label for the ring size slider in the settings view.",
"isCommentAutoGenerated" : true
},
"Rotate preview" : {
"comment" : "A button that rotates the camera preview by 90 degrees.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees left" : {
"comment" : "An accessibility label describing a 90-degree left rotation.",
"isCommentAutoGenerated" : true
},
"Rotated 90 degrees right" : {
"comment" : "An accessibility label describing a 90-degree clockwise rotation.",
"isCommentAutoGenerated" : true
},
"Rotated 180 degrees" : {
"comment" : "An accessibility label describing a 180-degree rotation.",
"isCommentAutoGenerated" : true
},
"Rotates the camera preview 90 degrees" : {
"comment" : "A hint that explains how to rotate the camera preview.",
"isCommentAutoGenerated" : true
},
"Saved to Photos" : {
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
"isCommentAutoGenerated" : true
@ -310,14 +250,6 @@
"comment" : "Name of a ring light color preset.",
"isCommentAutoGenerated" : true
},
"Start recording" : {
"comment" : "The label for the button that starts recording a video.",
"isCommentAutoGenerated" : true
},
"Stop recording" : {
"comment" : "The text label for stopping a video capture.",
"isCommentAutoGenerated" : true
},
"Subscribe to %@ for %@" : {
"comment" : "A button that triggers a purchase of a premium content package. The label text is generated based on the package's title and price.",
"isCommentAutoGenerated" : true,
@ -330,10 +262,6 @@
}
}
},
"Switch camera" : {
"comment" : "A button label that says \"Switch camera\".",
"isCommentAutoGenerated" : true
},
"Sync Now" : {
"comment" : "A button label that triggers a sync action.",
"isCommentAutoGenerated" : true
@ -352,19 +280,11 @@
},
"Syncing..." : {
},
"Take photo" : {
"comment" : "A button label that says \"Take photo\".",
"isCommentAutoGenerated" : true
},
"Third-party libraries used in this app" : {
"comment" : "A description of the third-party libraries used in this app.",
"isCommentAutoGenerated" : true
},
"Toggle grid" : {
"comment" : "A button that toggles the visibility of the grid in the camera view.",
"isCommentAutoGenerated" : true
},
"True Mirror" : {
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
"isCommentAutoGenerated" : true