Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
fd25942f59
commit
915088f180
@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "f0492d428a7eee59a60d8a8f71928cd6379f7e9632aa4a32cbd1f1cea00a553b",
|
"originHash" : "6833d23e21d5837a21eb6a505b1a3ec76c3c83999c658fd917d3d4e7c53f82a3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "bedrock",
|
"identity" : "bedrock",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git",
|
"location" : "http://192.168.1.128:3000/mbrucedogs/Bedrock",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "develop",
|
"branch" : "master",
|
||||||
"revision" : "9f4046bfd2c23e76c30dfefe0ed164405b1b0ee8"
|
"revision" : "8e788ef2121024b25ac6150d857140af6bcd64c2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"identity" : "camera",
|
"identity" : "mijickcamera",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/Mijick/Camera",
|
"location" : "http://192.168.1.128:3000/mbrucedogs/MijickCamera",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "0f02348fcc8fbbc9224c7fbf444f182dc25d0b40",
|
"branch" : "develop",
|
||||||
"version" : "3.0.3"
|
"revision" : "55940f0d52a69ab1959ec80118956ddbeca085f9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -2,47 +2,100 @@ import SwiftUI
|
|||||||
import MijickCamera
|
import MijickCamera
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
|
||||||
/// Main camera view with ring light effect using MijickCamera
|
// MARK: - Camera Settings Observable
|
||||||
|
/// Shared observable class for camera settings to prevent MCamera recreation on changes
|
||||||
|
@Observable @MainActor
|
||||||
|
final class CameraSettingsState {
|
||||||
|
var photoQuality: PhotoQuality
|
||||||
|
var isRingLightEnabled: Bool
|
||||||
|
var ringLightColor: Color
|
||||||
|
var ringLightSize: CGFloat
|
||||||
|
var ringLightOpacity: Double
|
||||||
|
var flashMode: CameraFlashMode
|
||||||
|
var isFlashSyncedWithRingLight: Bool
|
||||||
|
var hdrMode: CameraHDRMode
|
||||||
|
var isGridVisible: Bool
|
||||||
|
var cameraPosition: CameraPosition
|
||||||
|
|
||||||
|
init(settings: SettingsViewModel) {
|
||||||
|
self.photoQuality = settings.photoQuality
|
||||||
|
self.isRingLightEnabled = settings.isRingLightEnabled
|
||||||
|
self.ringLightColor = settings.lightColor
|
||||||
|
self.ringLightSize = settings.ringSize
|
||||||
|
self.ringLightOpacity = settings.ringLightOpacity
|
||||||
|
self.flashMode = settings.flashMode
|
||||||
|
self.isFlashSyncedWithRingLight = settings.isFlashSyncedWithRingLight
|
||||||
|
self.hdrMode = settings.hdrMode
|
||||||
|
self.isGridVisible = settings.isGridVisible
|
||||||
|
self.cameraPosition = settings.cameraPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(from settings: SettingsViewModel) {
|
||||||
|
self.photoQuality = settings.photoQuality
|
||||||
|
self.isRingLightEnabled = settings.isRingLightEnabled
|
||||||
|
self.ringLightColor = settings.lightColor
|
||||||
|
self.ringLightSize = settings.ringSize
|
||||||
|
self.ringLightOpacity = settings.ringLightOpacity
|
||||||
|
self.flashMode = settings.flashMode
|
||||||
|
self.isFlashSyncedWithRingLight = settings.isFlashSyncedWithRingLight
|
||||||
|
self.hdrMode = settings.hdrMode
|
||||||
|
self.isGridVisible = settings.isGridVisible
|
||||||
|
self.cameraPosition = settings.cameraPosition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var settings = SettingsViewModel()
|
@State private var settings = SettingsViewModel()
|
||||||
@State private var premiumManager = PremiumManager()
|
@State private var premiumManager = PremiumManager()
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var showPaywall = false
|
@State private var showPaywall = false
|
||||||
|
|
||||||
// Post-capture state
|
@State private var capturedPhoto: CapturedPhoto?
|
||||||
@State private var capturedImage: UIImage?
|
@State private var showPhotoReview = false
|
||||||
@State private var capturedVideoURL: URL?
|
@State private var isSavingPhoto = false
|
||||||
@State private var showPostCapture = false
|
@State private var saveError: String?
|
||||||
|
|
||||||
/// Ring size clamped to reasonable max
|
/// Camera settings in a shared observable to prevent MCamera recreation
|
||||||
private var effectiveRingSize: CGFloat {
|
@State private var cameraSettings: CameraSettingsState?
|
||||||
let maxRing = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * 0.2
|
|
||||||
return min(settings.ringSize, maxRing)
|
/// Unique key to force MCamera recreation after photo capture
|
||||||
}
|
/// Incrementing this value creates a new camera session with fresh AVCapturePhotoOutput
|
||||||
|
@State private var cameraSessionKey = UUID()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Ring light background
|
// Camera view - only recreates when sessionKey changes due to Equatable
|
||||||
settings.lightColor
|
if !showPhotoReview {
|
||||||
.ignoresSafeArea()
|
CameraContainerView(
|
||||||
|
cameraSettings: cameraSettings ?? CameraSettingsState(settings: settings),
|
||||||
// MijickCamera with default UI
|
sessionKey: cameraSessionKey,
|
||||||
MCamera()
|
onImageCaptured: { image in
|
||||||
.setCameraPosition(.front) // Default to front camera for selfies
|
capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
|
||||||
.onImageCaptured { image, _ in
|
showPhotoReview = true
|
||||||
capturedImage = image
|
isSavingPhoto = false
|
||||||
showPostCapture = true
|
saveError = nil
|
||||||
|
print("Photo captured successfully")
|
||||||
}
|
}
|
||||||
.onVideoCaptured { url, _ in
|
)
|
||||||
capturedVideoURL = url
|
|
||||||
showPostCapture = true
|
|
||||||
}
|
}
|
||||||
.startSession()
|
|
||||||
.padding(.horizontal, effectiveRingSize)
|
|
||||||
.padding(.top, effectiveRingSize)
|
|
||||||
.padding(.bottom, effectiveRingSize)
|
|
||||||
|
|
||||||
// Settings button overlay (top right corner of camera area)
|
// Photo review overlay
|
||||||
|
if showPhotoReview, let photo = capturedPhoto {
|
||||||
|
PhotoReviewView(
|
||||||
|
photo: photo,
|
||||||
|
isSaving: isSavingPhoto,
|
||||||
|
saveError: saveError,
|
||||||
|
onRetake: {
|
||||||
|
resetCameraForNextCapture()
|
||||||
|
},
|
||||||
|
onSave: {
|
||||||
|
savePhotoToLibrary(photo.image)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings button overlay
|
||||||
VStack {
|
VStack {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -59,45 +112,117 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.accessibilityLabel("Settings")
|
.accessibilityLabel("Settings")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, effectiveRingSize + Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
.padding(.top, effectiveRingSize + Design.Spacing.medium)
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview)
|
||||||
|
.onAppear {
|
||||||
|
cameraSettings = CameraSettingsState(settings: settings)
|
||||||
|
}
|
||||||
|
.onChange(of: settings.photoQuality) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.isRingLightEnabled) { _, newValue in
|
||||||
|
updateCameraSettings()
|
||||||
|
if settings.isFlashSyncedWithRingLight {
|
||||||
|
settings.flashMode = newValue ? .on : .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: settings.lightColor) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.ringSize) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.ringLightOpacity) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.flashMode) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.isFlashSyncedWithRingLight) { _, newValue in
|
||||||
|
updateCameraSettings()
|
||||||
|
if newValue {
|
||||||
|
settings.flashMode = settings.isRingLightEnabled ? .on : .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: settings.hdrMode) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.isGridVisible) { _, _ in updateCameraSettings() }
|
||||||
|
.onChange(of: settings.cameraPosition) { _, _ in updateCameraSettings() }
|
||||||
.sheet(isPresented: $showSettings) {
|
.sheet(isPresented: $showSettings) {
|
||||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
ProPaywallView()
|
ProPaywallView()
|
||||||
}
|
}
|
||||||
.fullScreenCover(isPresented: $showPostCapture) {
|
|
||||||
PostCapturePreviewView(
|
|
||||||
capturedImage: capturedImage,
|
|
||||||
capturedVideoURL: capturedVideoURL,
|
|
||||||
isAutoSaveEnabled: settings.isAutoSaveEnabled,
|
|
||||||
onRetake: {
|
|
||||||
capturedImage = nil
|
|
||||||
capturedVideoURL = nil
|
|
||||||
showPostCapture = false
|
|
||||||
},
|
|
||||||
onSave: {
|
|
||||||
saveCapture()
|
|
||||||
showPostCapture = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateCameraSettings() {
|
||||||
|
cameraSettings?.update(from: settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resets state and regenerates camera session key to create a fresh camera instance
|
||||||
|
private func resetCameraForNextCapture() {
|
||||||
|
capturedPhoto = nil
|
||||||
|
showPhotoReview = false
|
||||||
|
isSavingPhoto = false
|
||||||
|
saveError = nil
|
||||||
|
// Generate new key to force MCamera recreation with fresh AVCapturePhotoOutput
|
||||||
|
cameraSessionKey = UUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePhotoToLibrary(_ image: UIImage) {
|
||||||
|
isSavingPhoto = true
|
||||||
|
saveError = nil
|
||||||
|
|
||||||
|
let quality = cameraSettings?.photoQuality ?? .high
|
||||||
|
|
||||||
|
Task {
|
||||||
|
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.isSavingPhoto = false
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
print("Photo saved successfully")
|
||||||
|
// Auto-dismiss after successful save and reset camera for next capture
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(for: .seconds(1.5))
|
||||||
|
await MainActor.run {
|
||||||
|
self.resetCameraForNextCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
print("Failed to save photo: \(error)")
|
||||||
|
self.saveError = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Container View
|
||||||
|
/// Wrapper view for MCamera - only recreates when sessionKey changes
|
||||||
|
struct CameraContainerView: View, Equatable {
|
||||||
|
let cameraSettings: CameraSettingsState
|
||||||
|
let sessionKey: UUID
|
||||||
|
let onImageCaptured: (UIImage) -> Void
|
||||||
|
|
||||||
|
// Only compare sessionKey for equality - ignore settings and callback changes
|
||||||
|
static func == (lhs: CameraContainerView, rhs: CameraContainerView) -> Bool {
|
||||||
|
lhs.sessionKey == rhs.sessionKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let _ = print("CameraContainerView body evaluated - sessionKey: \(sessionKey)")
|
||||||
|
MCamera()
|
||||||
|
.setCameraScreen { cameraManager, namespace, closeAction in
|
||||||
|
CustomCameraScreen(
|
||||||
|
cameraManager: cameraManager,
|
||||||
|
namespace: namespace,
|
||||||
|
closeMCameraAction: closeAction,
|
||||||
|
cameraSettings: cameraSettings,
|
||||||
|
onPhotoCaptured: onImageCaptured
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.setCapturedMediaScreen(nil)
|
||||||
|
.setCameraPosition(cameraSettings.cameraPosition)
|
||||||
// MARK: - Save Capture
|
.startSession()
|
||||||
|
|
||||||
private func saveCapture() {
|
|
||||||
if let image = capturedImage {
|
|
||||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
|
||||||
}
|
|
||||||
capturedImage = nil
|
|
||||||
capturedVideoURL = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,9 +51,9 @@ struct PostCapturePreviewView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: $showShareSheet) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
if let image = capturedImage {
|
if let image = capturedImage {
|
||||||
ShareSheet(items: [image])
|
ShareSheet(activityItems: [image])
|
||||||
} else if let url = capturedVideoURL {
|
} else if let url = capturedVideoURL {
|
||||||
ShareSheet(items: [url])
|
ShareSheet(activityItems: [url])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,17 +209,7 @@ private struct ToolbarButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Share Sheet
|
|
||||||
|
|
||||||
struct ShareSheet: UIViewControllerRepresentable {
|
|
||||||
let items: [Any]
|
|
||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
||||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
PostCapturePreviewView(
|
PostCapturePreviewView(
|
||||||
|
|||||||
38
SelfieCam/Features/Camera/Views/CaptureButton.swift
Normal file
38
SelfieCam/Features/Camera/Views/CaptureButton.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// CaptureButton.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Capture Button
|
||||||
|
|
||||||
|
struct CaptureButton: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
private let outerSize: CGFloat = 80
|
||||||
|
private let innerSize: CGFloat = 68
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
ZStack {
|
||||||
|
// Outer ring
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white, lineWidth: 4)
|
||||||
|
.frame(width: outerSize, height: outerSize)
|
||||||
|
|
||||||
|
// Inner fill
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white)
|
||||||
|
.frame(width: innerSize, height: innerSize)
|
||||||
|
}
|
||||||
|
.shadow(radius: Design.Shadow.radiusMedium)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Take photo")
|
||||||
|
.accessibilityHint("Double tap to capture a photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
711
SelfieCam/Features/Camera/Views/CustomCameraScreen.swift
Normal file
711
SelfieCam/Features/Camera/Views/CustomCameraScreen.swift
Normal file
@ -0,0 +1,711 @@
|
|||||||
|
//
|
||||||
|
// CustomCameraScreen.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
|
// MARK: - Custom Camera Screen
|
||||||
|
|
||||||
|
struct CustomCameraScreen: MCameraScreen {
|
||||||
|
@ObservedObject var cameraManager: CameraManager
|
||||||
|
let namespace: Namespace.ID
|
||||||
|
let closeMCameraAction: () -> ()
|
||||||
|
|
||||||
|
/// Shared camera settings state - using Observable class prevents MCamera recreation
|
||||||
|
var cameraSettings: CameraSettingsState
|
||||||
|
|
||||||
|
/// Callback when photo is captured - bypasses MijickCamera's callback system
|
||||||
|
var onPhotoCaptured: ((UIImage) -> Void)?
|
||||||
|
|
||||||
|
// Convenience accessors for settings
|
||||||
|
private var photoQuality: PhotoQuality {
|
||||||
|
get { cameraSettings.photoQuality }
|
||||||
|
nonmutating set { cameraSettings.photoQuality = newValue }
|
||||||
|
}
|
||||||
|
private var isRingLightEnabled: Bool {
|
||||||
|
get { cameraSettings.isRingLightEnabled }
|
||||||
|
nonmutating set { cameraSettings.isRingLightEnabled = newValue }
|
||||||
|
}
|
||||||
|
private var ringLightColor: Color {
|
||||||
|
get { cameraSettings.ringLightColor }
|
||||||
|
nonmutating set { cameraSettings.ringLightColor = newValue }
|
||||||
|
}
|
||||||
|
private var ringLightSize: CGFloat {
|
||||||
|
get { cameraSettings.ringLightSize }
|
||||||
|
nonmutating set { cameraSettings.ringLightSize = newValue }
|
||||||
|
}
|
||||||
|
private var ringLightOpacity: Double {
|
||||||
|
get { cameraSettings.ringLightOpacity }
|
||||||
|
nonmutating set { cameraSettings.ringLightOpacity = newValue }
|
||||||
|
}
|
||||||
|
private var flashMode: CameraFlashMode {
|
||||||
|
get { cameraSettings.flashMode }
|
||||||
|
nonmutating set { cameraSettings.flashMode = newValue }
|
||||||
|
}
|
||||||
|
private var isFlashSyncedWithRingLight: Bool {
|
||||||
|
get { cameraSettings.isFlashSyncedWithRingLight }
|
||||||
|
nonmutating set { cameraSettings.isFlashSyncedWithRingLight = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Center Stage state
|
||||||
|
@State private var isCenterStageEnabled: Bool = AVCaptureDevice.isCenterStageEnabled
|
||||||
|
|
||||||
|
// Controls panel expansion state
|
||||||
|
@State private var isControlsExpanded: Bool = false
|
||||||
|
|
||||||
|
// Ring light settings overlay state
|
||||||
|
@State private var showRingLightColorPicker: Bool = false
|
||||||
|
@State private var showRingLightSizeSlider: Bool = false
|
||||||
|
@State private var showRingLightOpacitySlider: Bool = false
|
||||||
|
|
||||||
|
// Screen flash state for front camera
|
||||||
|
@State private var isShowingScreenFlash: Bool = false
|
||||||
|
@State private var originalBrightness: CGFloat = UIScreen.main.brightness
|
||||||
|
|
||||||
|
// Pinch to zoom gesture state
|
||||||
|
@GestureState private var magnification: CGFloat = 1.0
|
||||||
|
@State private var lastMagnification: CGFloat = 1.0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Camera preview with pinch gesture - Metal layer doesn't respect SwiftUI clipping
|
||||||
|
createCameraOutputView()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.gesture(
|
||||||
|
MagnificationGesture()
|
||||||
|
.updating($magnification) { currentState, gestureState, transaction in
|
||||||
|
gestureState = currentState
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let newZoom = lastMagnification * value
|
||||||
|
lastMagnification = newZoom
|
||||||
|
// Clamp to reasonable range
|
||||||
|
let clampedZoom = min(max(newZoom, 1.0), 5.0)
|
||||||
|
do {
|
||||||
|
try setZoomFactor(clampedZoom)
|
||||||
|
} catch {
|
||||||
|
print("Failed to set zoom factor: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ring light overlay - covers corners and creates rounded inner edge
|
||||||
|
// When ring light is off, still show black corners to maintain rounded appearance
|
||||||
|
RingLightOverlay(
|
||||||
|
color: isRingLightEnabled ? ringLightColor : .black,
|
||||||
|
width: isRingLightEnabled ? ringLightSize : Design.CornerRadius.large,
|
||||||
|
opacity: isRingLightEnabled ? ringLightOpacity : 1.0,
|
||||||
|
cornerRadius: Design.CornerRadius.large
|
||||||
|
)
|
||||||
|
.allowsHitTesting(false) // Allow touches to pass through to camera view
|
||||||
|
|
||||||
|
// UI overlay - responsive to orientation
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let isLandscape = geometry.size.width > geometry.size.height
|
||||||
|
|
||||||
|
if isLandscape {
|
||||||
|
// Landscape layout: full-width centered controls, capture button on left with zoom above
|
||||||
|
ZStack {
|
||||||
|
// Centered controls across entire screen
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Top controls area - expandable panel (centered)
|
||||||
|
ExpandableControlsPanel(
|
||||||
|
isExpanded: $isControlsExpanded,
|
||||||
|
hasActiveSettings: hasActiveSettings,
|
||||||
|
activeSettingsIcons: activeSettingsIcons,
|
||||||
|
flashMode: flashMode,
|
||||||
|
flashIcon: flashIcon,
|
||||||
|
onFlashTap: toggleFlash,
|
||||||
|
isFlashSyncedWithRingLight: isFlashSyncedWithRingLight,
|
||||||
|
onFlashSyncTap: toggleFlashSync,
|
||||||
|
hdrMode: cameraSettings.hdrMode,
|
||||||
|
hdrIcon: hdrIcon,
|
||||||
|
onHDRTap: toggleHDR,
|
||||||
|
isGridVisible: isGridVisible,
|
||||||
|
gridIcon: gridIcon,
|
||||||
|
onGridTap: toggleGrid,
|
||||||
|
photoQuality: photoQuality,
|
||||||
|
onQualityTap: cycleQuality,
|
||||||
|
isCenterStageAvailable: isCenterStageAvailable,
|
||||||
|
isCenterStageEnabled: isCenterStageEnabled,
|
||||||
|
onCenterStageTap: toggleCenterStage,
|
||||||
|
isFrontCamera: cameraPosition == .front,
|
||||||
|
onFlipCameraTap: flipCamera,
|
||||||
|
isRingLightEnabled: isRingLightEnabled,
|
||||||
|
onRingLightTap: toggleRingLight,
|
||||||
|
ringLightColor: ringLightColor,
|
||||||
|
onRingLightColorTap: toggleRingLightColorPicker,
|
||||||
|
ringLightSize: ringLightSize,
|
||||||
|
onRingLightSizeTap: toggleRingLightSizeSlider,
|
||||||
|
ringLightOpacity: ringLightOpacity,
|
||||||
|
onRingLightOpacityTap: toggleRingLightOpacitySlider
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left side overlay - Capture Button only
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
CaptureButton(action: { performCapture() })
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.leading, Design.Spacing.large)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Portrait layout: controls on top, capture button at bottom
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Top controls area - expandable panel
|
||||||
|
ExpandableControlsPanel(
|
||||||
|
isExpanded: $isControlsExpanded,
|
||||||
|
hasActiveSettings: hasActiveSettings,
|
||||||
|
activeSettingsIcons: activeSettingsIcons,
|
||||||
|
flashMode: flashMode,
|
||||||
|
flashIcon: flashIcon,
|
||||||
|
onFlashTap: toggleFlash,
|
||||||
|
isFlashSyncedWithRingLight: isFlashSyncedWithRingLight,
|
||||||
|
onFlashSyncTap: toggleFlashSync,
|
||||||
|
hdrMode: cameraSettings.hdrMode,
|
||||||
|
hdrIcon: hdrIcon,
|
||||||
|
onHDRTap: toggleHDR,
|
||||||
|
isGridVisible: isGridVisible,
|
||||||
|
gridIcon: gridIcon,
|
||||||
|
onGridTap: toggleGrid,
|
||||||
|
photoQuality: photoQuality,
|
||||||
|
onQualityTap: cycleQuality,
|
||||||
|
isCenterStageAvailable: isCenterStageAvailable,
|
||||||
|
isCenterStageEnabled: isCenterStageEnabled,
|
||||||
|
onCenterStageTap: toggleCenterStage,
|
||||||
|
isFrontCamera: cameraPosition == .front,
|
||||||
|
onFlipCameraTap: flipCamera,
|
||||||
|
isRingLightEnabled: isRingLightEnabled,
|
||||||
|
onRingLightTap: toggleRingLight,
|
||||||
|
ringLightColor: ringLightColor,
|
||||||
|
onRingLightColorTap: toggleRingLightColorPicker,
|
||||||
|
ringLightSize: ringLightSize,
|
||||||
|
onRingLightSizeTap: toggleRingLightSizeSlider,
|
||||||
|
ringLightOpacity: ringLightOpacity,
|
||||||
|
onRingLightOpacityTap: toggleRingLightOpacitySlider
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.top, Design.Spacing.medium)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bottom controls
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Zoom indicator (shows "Center Stage" when active)
|
||||||
|
ZoomControlView(
|
||||||
|
zoomFactor: zoomFactor,
|
||||||
|
isCenterStageActive: isCenterStageEnabled
|
||||||
|
)
|
||||||
|
|
||||||
|
// Capture Button
|
||||||
|
CaptureButton(action: { performCapture() })
|
||||||
|
.padding(.bottom, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring light color picker overlay
|
||||||
|
if showRingLightColorPicker {
|
||||||
|
ColorPickerOverlay(
|
||||||
|
selectedColor: Binding(
|
||||||
|
get: { cameraSettings.ringLightColor },
|
||||||
|
set: { cameraSettings.ringLightColor = $0 }
|
||||||
|
),
|
||||||
|
isPresented: $showRingLightColorPicker
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring light size slider overlay
|
||||||
|
if showRingLightSizeSlider {
|
||||||
|
SizeSliderOverlay(
|
||||||
|
selectedSize: Binding(
|
||||||
|
get: { cameraSettings.ringLightSize },
|
||||||
|
set: { cameraSettings.ringLightSize = $0 }
|
||||||
|
),
|
||||||
|
isPresented: $showRingLightSizeSlider
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring light opacity slider overlay
|
||||||
|
if showRingLightOpacitySlider {
|
||||||
|
OpacitySliderOverlay(
|
||||||
|
selectedOpacity: Binding(
|
||||||
|
get: { cameraSettings.ringLightOpacity },
|
||||||
|
set: { cameraSettings.ringLightOpacity = $0 }
|
||||||
|
),
|
||||||
|
isPresented: $showRingLightOpacitySlider
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen flash overlay for front camera
|
||||||
|
if isShowingScreenFlash {
|
||||||
|
screenFlashColor
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.05), value: isShowingScreenFlash)
|
||||||
|
.gesture(
|
||||||
|
// Only add tap gesture when there are overlays to dismiss
|
||||||
|
(isControlsExpanded || showRingLightColorPicker || showRingLightSizeSlider || showRingLightOpacitySlider) ?
|
||||||
|
TapGesture().onEnded {
|
||||||
|
// Collapse panel when tapping outside
|
||||||
|
if isControlsExpanded {
|
||||||
|
isControlsExpanded = false
|
||||||
|
}
|
||||||
|
// Hide overlays when tapping outside
|
||||||
|
if showRingLightColorPicker {
|
||||||
|
showRingLightColorPicker = false
|
||||||
|
}
|
||||||
|
if showRingLightSizeSlider {
|
||||||
|
showRingLightSizeSlider = false
|
||||||
|
}
|
||||||
|
if showRingLightOpacitySlider {
|
||||||
|
showRingLightOpacitySlider = false
|
||||||
|
}
|
||||||
|
} : nil
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
// Set flash mode from saved settings
|
||||||
|
setFlashMode(cameraSettings.flashMode.toMijickFlashMode)
|
||||||
|
// Tell MijickCamera whether to disable iOS flash (only matters if sync is on)
|
||||||
|
updateFlashSyncState()
|
||||||
|
// Initialize zoom gesture state
|
||||||
|
lastMagnification = zoomFactor
|
||||||
|
}
|
||||||
|
.onChange(of: isFlashSyncedWithRingLight) { _, _ in
|
||||||
|
// Only update when sync setting changes, not on every color change
|
||||||
|
updateFlashSyncState()
|
||||||
|
}
|
||||||
|
.onChange(of: cameraManager.capturedMedia) { _, newMedia in
|
||||||
|
// Directly observe capture completion - bypasses MijickCamera's callback issues
|
||||||
|
if let media = newMedia, let image = media.getImage() {
|
||||||
|
print("CustomCameraScreen detected captured media!")
|
||||||
|
onPhotoCaptured?(image)
|
||||||
|
// Clear the captured media so next capture works
|
||||||
|
cameraManager.setCapturedMedia(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Active Settings Detection
|
||||||
|
|
||||||
|
/// Returns true if any setting is in a non-default state
|
||||||
|
private var hasActiveSettings: Bool {
|
||||||
|
flashMode != .off || cameraSettings.hdrMode != .off || isGridVisible || isCenterStageEnabled || isRingLightEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns icons for currently active settings (for collapsed pill display)
|
||||||
|
private var activeSettingsIcons: [String] {
|
||||||
|
var icons: [String] = []
|
||||||
|
if flashMode != .off {
|
||||||
|
icons.append(flashIcon)
|
||||||
|
}
|
||||||
|
if cameraSettings.hdrMode != .off {
|
||||||
|
icons.append(hdrIcon)
|
||||||
|
}
|
||||||
|
if isGridVisible {
|
||||||
|
icons.append(gridIcon)
|
||||||
|
}
|
||||||
|
if isRingLightEnabled {
|
||||||
|
icons.append("circle.fill")
|
||||||
|
}
|
||||||
|
if isCenterStageEnabled {
|
||||||
|
icons.append("person.crop.rectangle.fill")
|
||||||
|
}
|
||||||
|
return icons
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Control Icons
|
||||||
|
private var flashIcon: String {
|
||||||
|
flashMode.icon
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hdrIcon: String {
|
||||||
|
switch cameraSettings.hdrMode {
|
||||||
|
case .off: return "circle.lefthalf.filled"
|
||||||
|
case .auto: return "circle.lefthalf.filled"
|
||||||
|
case .on: return "circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gridIcon: String {
|
||||||
|
isGridVisible ? "grid" : "grid"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCenterStageAvailable: Bool {
|
||||||
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return device.activeFormat.isCenterStageSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
private func toggleFlash() {
|
||||||
|
let nextMode: CameraFlashMode
|
||||||
|
switch flashMode {
|
||||||
|
case .off: nextMode = .auto
|
||||||
|
case .auto: nextMode = .on
|
||||||
|
case .on: nextMode = .off
|
||||||
|
}
|
||||||
|
flashMode = nextMode
|
||||||
|
// Update MijickCamera's flash mode so it knows to use iOS Retina Flash
|
||||||
|
setFlashMode(nextMode.toMijickFlashMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleFlashSync() {
|
||||||
|
isFlashSyncedWithRingLight.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleHDR() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let nextMode: CameraHDRMode
|
||||||
|
switch cameraSettings.hdrMode {
|
||||||
|
case .off: nextMode = .auto
|
||||||
|
case .auto: nextMode = .on
|
||||||
|
case .on: nextMode = .off
|
||||||
|
}
|
||||||
|
try setHDRMode(nextMode.toMijickHDRMode)
|
||||||
|
cameraSettings.hdrMode = nextMode
|
||||||
|
} catch {
|
||||||
|
print("Failed to set HDR mode: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleGrid() {
|
||||||
|
setGridVisibility(!isGridVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flipCamera() {
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let newPosition: CameraPosition = (cameraPosition == .front) ? .back : .front
|
||||||
|
try await setCameraPosition(newPosition)
|
||||||
|
} catch {
|
||||||
|
print("Failed to flip camera: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleCenterStage() {
|
||||||
|
// Get the current camera device using AVFoundation
|
||||||
|
let deviceTypes: [AVCaptureDevice.DeviceType] = [
|
||||||
|
.builtInWideAngleCamera,
|
||||||
|
.builtInUltraWideCamera,
|
||||||
|
.builtInTelephotoCamera
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let device = AVCaptureDevice.default(deviceTypes[0], for: .video, position: cameraPosition == .front ? .front : .back) else {
|
||||||
|
print("No camera device available for Center Stage toggle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Configure Center Stage globally (these are static properties)
|
||||||
|
try device.lockForConfiguration()
|
||||||
|
|
||||||
|
// Set control mode to app-controlled
|
||||||
|
if device.activeFormat.isCenterStageSupported {
|
||||||
|
AVCaptureDevice.centerStageControlMode = .app
|
||||||
|
AVCaptureDevice.isCenterStageEnabled = !isCenterStageEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
device.unlockForConfiguration()
|
||||||
|
|
||||||
|
// Update our state
|
||||||
|
isCenterStageEnabled.toggle()
|
||||||
|
|
||||||
|
print("Center Stage toggled to: \(isCenterStageEnabled)")
|
||||||
|
} catch {
|
||||||
|
print("Failed to toggle Center Stage: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cycleQuality() {
|
||||||
|
let allCases = PhotoQuality.allCases
|
||||||
|
let currentIndex = allCases.firstIndex(of: photoQuality) ?? 0
|
||||||
|
let nextIndex = (currentIndex + 1) % allCases.count
|
||||||
|
photoQuality = allCases[nextIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRingLight() {
|
||||||
|
isRingLightEnabled.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFlashSyncState() {
|
||||||
|
// Tell MijickCamera whether we're handling flash ourselves (sync enabled)
|
||||||
|
// or if iOS should handle it (sync disabled)
|
||||||
|
// We use .white as a placeholder - the actual color comes from ringLightColor in SwiftUI
|
||||||
|
if isFlashSyncedWithRingLight {
|
||||||
|
setScreenFlashColor(.white) // Non-nil = we handle flash, disable iOS flash
|
||||||
|
} else {
|
||||||
|
setScreenFlashColor(nil) // nil = let iOS handle Retina Flash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The color to use for screen flash overlay
|
||||||
|
private var screenFlashColor: Color {
|
||||||
|
isFlashSyncedWithRingLight ? ringLightColor : .white
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether to use custom screen flash (front camera + flash on + sync enabled)
|
||||||
|
private var shouldUseCustomScreenFlash: Bool {
|
||||||
|
cameraPosition == .front && flashMode != .off && isFlashSyncedWithRingLight
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Performs capture with screen flash if needed
|
||||||
|
private func performCapture() {
|
||||||
|
print("performCapture called - shouldUseCustomScreenFlash: \(shouldUseCustomScreenFlash)")
|
||||||
|
if shouldUseCustomScreenFlash {
|
||||||
|
// Save original brightness and boost to max
|
||||||
|
originalBrightness = UIScreen.main.brightness
|
||||||
|
UIScreen.main.brightness = 1.0
|
||||||
|
|
||||||
|
// Show flash overlay
|
||||||
|
isShowingScreenFlash = true
|
||||||
|
|
||||||
|
// Wait for camera to adjust to bright screen, then capture
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
print("Calling captureOutput() with custom flash")
|
||||||
|
captureOutput()
|
||||||
|
|
||||||
|
// Keep flash visible briefly after capture
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
isShowingScreenFlash = false
|
||||||
|
UIScreen.main.brightness = originalBrightness
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal capture (iOS handles Retina Flash for front camera if needed)
|
||||||
|
print("Calling captureOutput() without custom flash")
|
||||||
|
captureOutput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRingLightColorPicker() {
|
||||||
|
showRingLightColorPicker = true
|
||||||
|
showRingLightSizeSlider = false // Hide other overlay
|
||||||
|
isControlsExpanded = false // Collapse controls panel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRingLightSizeSlider() {
|
||||||
|
showRingLightSizeSlider = true
|
||||||
|
showRingLightColorPicker = false // Hide other overlay
|
||||||
|
showRingLightOpacitySlider = false // Hide other overlay
|
||||||
|
isControlsExpanded = false // Collapse controls panel
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleRingLightOpacitySlider() {
|
||||||
|
showRingLightOpacitySlider = true
|
||||||
|
showRingLightColorPicker = false // Hide other overlay
|
||||||
|
showRingLightSizeSlider = false // Hide other overlay
|
||||||
|
isControlsExpanded = false // Collapse controls panel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Picker Overlay
|
||||||
|
|
||||||
|
struct ColorPickerOverlay: View {
|
||||||
|
@Binding var selectedColor: Color
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
private let colors: [Color] = [
|
||||||
|
.white, .red, .orange, .yellow, .green, .blue, .purple, .pink,
|
||||||
|
.gray, .black, Color(red: 1.0, green: 0.5, blue: 0.0), // Coral
|
||||||
|
Color(red: 0.5, green: 1.0, blue: 0.5), // Mint
|
||||||
|
Color(red: 0.5, green: 0.5, blue: 1.0), // Periwinkle
|
||||||
|
Color(red: 1.0, green: 0.5, blue: 1.0), // Magenta
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Semi-transparent background
|
||||||
|
Color.black.opacity(Design.Opacity.medium)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Color picker content
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Header
|
||||||
|
Text("Ring Light Color")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
|
||||||
|
// Color grid
|
||||||
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 4), spacing: Design.Spacing.small) {
|
||||||
|
ForEach(colors, id: \.self) { color in
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(selectedColor == color ? 1.0 : 0.3), lineWidth: selectedColor == color ? 3 : 1)
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
selectedColor = color
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done button
|
||||||
|
Button("Done") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(Color.black.opacity(Design.Opacity.strong))
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Opacity Slider Overlay
|
||||||
|
|
||||||
|
struct OpacitySliderOverlay: View {
|
||||||
|
@Binding var selectedOpacity: Double
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
private let minOpacity: Double = 0.1
|
||||||
|
private let maxOpacity: Double = 1.0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Semi-transparent background
|
||||||
|
Color.black.opacity(Design.Opacity.medium)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Opacity slider content
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Header
|
||||||
|
Text("Ring Light Brightness")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
|
||||||
|
// Current opacity display as percentage
|
||||||
|
Text("\(Int(selectedOpacity * 100))%")
|
||||||
|
.font(.system(size: 24, weight: .bold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.frame(width: 80)
|
||||||
|
|
||||||
|
// Slider
|
||||||
|
Slider(value: $selectedOpacity, in: minOpacity...maxOpacity, step: 0.05)
|
||||||
|
.tint(Color.white)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Opacity range labels
|
||||||
|
HStack {
|
||||||
|
Text("10%")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.7))
|
||||||
|
Spacer()
|
||||||
|
Text("100%")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Done button
|
||||||
|
Button("Done") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(Color.black.opacity(Design.Opacity.strong))
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Size Slider Overlay
|
||||||
|
|
||||||
|
struct SizeSliderOverlay: View {
|
||||||
|
@Binding var selectedSize: CGFloat
|
||||||
|
@Binding var isPresented: Bool
|
||||||
|
|
||||||
|
private let minSize: CGFloat = 50
|
||||||
|
private let maxSize: CGFloat = 100
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Semi-transparent background
|
||||||
|
Color.black.opacity(Design.Opacity.medium)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Size slider content
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Header
|
||||||
|
Text("Ring Light Size")
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
|
||||||
|
// Current size display
|
||||||
|
Text("\(Int(selectedSize))")
|
||||||
|
.font(.system(size: 24, weight: .bold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.frame(width: 60)
|
||||||
|
|
||||||
|
// Slider
|
||||||
|
Slider(value: $selectedSize, in: minSize...maxSize, step: 5)
|
||||||
|
.tint(Color.white)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Size range labels
|
||||||
|
HStack {
|
||||||
|
Text("\(Int(minSize))")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.7))
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(maxSize))")
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
|
||||||
|
// Done button
|
||||||
|
Button("Done") {
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
.padding(Design.Spacing.large)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||||
|
.fill(Color.black.opacity(Design.Opacity.strong))
|
||||||
|
)
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
SelfieCam/Features/Camera/Views/ExpandableControlsPanel.swift
Normal file
254
SelfieCam/Features/Camera/Views/ExpandableControlsPanel.swift
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
//
|
||||||
|
// ExpandableControlsPanel.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
|
// MARK: - Expandable Controls Panel
|
||||||
|
|
||||||
|
struct ExpandableControlsPanel: View {
|
||||||
|
@Binding var isExpanded: Bool
|
||||||
|
|
||||||
|
// Collapsed state info
|
||||||
|
let hasActiveSettings: Bool
|
||||||
|
let activeSettingsIcons: [String]
|
||||||
|
|
||||||
|
// Control properties
|
||||||
|
let flashMode: CameraFlashMode
|
||||||
|
let flashIcon: String
|
||||||
|
let onFlashTap: () -> Void
|
||||||
|
let isFlashSyncedWithRingLight: Bool
|
||||||
|
let onFlashSyncTap: () -> Void
|
||||||
|
|
||||||
|
let hdrMode: CameraHDRMode
|
||||||
|
let hdrIcon: String
|
||||||
|
let onHDRTap: () -> Void
|
||||||
|
|
||||||
|
let isGridVisible: Bool
|
||||||
|
let gridIcon: String
|
||||||
|
let onGridTap: () -> Void
|
||||||
|
|
||||||
|
let photoQuality: PhotoQuality
|
||||||
|
let onQualityTap: () -> Void
|
||||||
|
|
||||||
|
let isCenterStageAvailable: Bool
|
||||||
|
let isCenterStageEnabled: Bool
|
||||||
|
let onCenterStageTap: () -> Void
|
||||||
|
|
||||||
|
let isFrontCamera: Bool
|
||||||
|
let onFlipCameraTap: () -> Void
|
||||||
|
|
||||||
|
let isRingLightEnabled: Bool
|
||||||
|
let onRingLightTap: () -> Void
|
||||||
|
|
||||||
|
let ringLightColor: Color
|
||||||
|
let onRingLightColorTap: () -> Void
|
||||||
|
|
||||||
|
let ringLightSize: CGFloat
|
||||||
|
let onRingLightSizeTap: () -> Void
|
||||||
|
|
||||||
|
let ringLightOpacity: Double
|
||||||
|
let onRingLightOpacityTap: () -> Void
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
private let collapsedHeight: CGFloat = 36
|
||||||
|
private let iconSize: CGFloat = 16
|
||||||
|
private let maxVisibleIcons: Int = 3
|
||||||
|
|
||||||
|
// Grid layout
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header row (always visible) - acts as the collapsed pill
|
||||||
|
Button(action: { isExpanded.toggle() }) {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
// Chevron that rotates
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: iconSize, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.rotationEffect(.degrees(isExpanded ? 180 : 0))
|
||||||
|
|
||||||
|
// Show active setting icons when collapsed
|
||||||
|
if !isExpanded && hasActiveSettings {
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
ForEach(activeSettingsIcons.prefix(maxVisibleIcons), id: \.self) { icon in
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: iconSize, weight: .medium))
|
||||||
|
.foregroundStyle(Color.yellow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isExpanded {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: collapsedHeight)
|
||||||
|
.frame(maxWidth: isExpanded ? .infinity : nil)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Camera controls")
|
||||||
|
.accessibilityHint(isExpanded ? "Tap to collapse settings" : "Tap to expand camera settings")
|
||||||
|
|
||||||
|
// Expandable content
|
||||||
|
if isExpanded {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: Design.Spacing.large) {
|
||||||
|
// Camera Controls Section
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Section header
|
||||||
|
Text("Camera Controls")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.8))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Controls grid
|
||||||
|
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||||
|
// Flash
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: flashIcon,
|
||||||
|
label: flashLabel,
|
||||||
|
isActive: flashMode != .off,
|
||||||
|
action: onFlashTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flash Sync
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: isFlashSyncedWithRingLight ? "link" : "link.slash",
|
||||||
|
label: "SYNC",
|
||||||
|
isActive: isFlashSyncedWithRingLight,
|
||||||
|
action: onFlashSyncTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// HDR
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: hdrIcon,
|
||||||
|
label: hdrLabel,
|
||||||
|
isActive: hdrMode != .off,
|
||||||
|
action: onHDRTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: gridIcon,
|
||||||
|
label: "GRID",
|
||||||
|
isActive: isGridVisible,
|
||||||
|
action: onGridTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// Quality
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: photoQuality.icon,
|
||||||
|
label: photoQuality.rawValue.uppercased(),
|
||||||
|
isActive: false,
|
||||||
|
action: onQualityTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// Center Stage (if available)
|
||||||
|
if isCenterStageAvailable {
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle",
|
||||||
|
label: "STAGE",
|
||||||
|
isActive: isCenterStageEnabled,
|
||||||
|
action: onCenterStageTap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip Camera
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: "arrow.triangle.2.circlepath.camera",
|
||||||
|
label: isFrontCamera ? "FRONT" : "BACK",
|
||||||
|
isActive: false,
|
||||||
|
action: onFlipCameraTap
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring Light Section
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Section header
|
||||||
|
Text("Ring Light")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(Color.white.opacity(0.8))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Ring light controls grid
|
||||||
|
LazyVGrid(columns: columns, spacing: Design.Spacing.medium) {
|
||||||
|
// Ring Light Enable/Disable
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: isRingLightEnabled ? "circle.fill" : "circle",
|
||||||
|
label: "ENABLE",
|
||||||
|
isActive: isRingLightEnabled,
|
||||||
|
action: onRingLightTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ring Color
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: "circle.fill",
|
||||||
|
label: "COLOR",
|
||||||
|
isActive: false,
|
||||||
|
action: onRingLightColorTap
|
||||||
|
)
|
||||||
|
.foregroundStyle(ringLightColor)
|
||||||
|
|
||||||
|
// Ring Size
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: "circle",
|
||||||
|
label: "SIZE",
|
||||||
|
isActive: false,
|
||||||
|
action: onRingLightSizeTap
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ring Brightness
|
||||||
|
ExpandedControlItem(
|
||||||
|
icon: "circle.fill",
|
||||||
|
label: "BRIGHT",
|
||||||
|
isActive: false,
|
||||||
|
action: onRingLightOpacityTap
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.white.opacity(ringLightOpacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, Design.Spacing.small)
|
||||||
|
.padding(.bottom, Design.Spacing.medium)
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 400) // Limit max height to prevent excessive scrolling
|
||||||
|
.scrollIndicators(.hidden) // Hide scroll indicators for cleaner look
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.small)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: isExpanded ? Design.CornerRadius.large : collapsedHeight / 2)
|
||||||
|
.fill(Color.black.opacity(isExpanded ? Design.Opacity.strong : Design.Opacity.medium))
|
||||||
|
)
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: isExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var flashLabel: String {
|
||||||
|
switch flashMode {
|
||||||
|
case .off: return "FLASH"
|
||||||
|
case .auto: return "AUTO"
|
||||||
|
case .on: return "ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hdrLabel: String {
|
||||||
|
switch hdrMode {
|
||||||
|
case .off: return "HDR"
|
||||||
|
case .auto: return "AUTO"
|
||||||
|
case .on: return "ON"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
SelfieCam/Features/Camera/Views/ExpandedControlItem.swift
Normal file
45
SelfieCam/Features/Camera/Views/ExpandedControlItem.swift
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
//
|
||||||
|
// ExpandedControlItem.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Expanded Control Item
|
||||||
|
|
||||||
|
struct ExpandedControlItem: View {
|
||||||
|
let icon: String
|
||||||
|
let label: String
|
||||||
|
let isActive: Bool
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
private let iconSize: CGFloat = 28
|
||||||
|
private let buttonSize: CGFloat = 56
|
||||||
|
private let labelFontSize: CGFloat = 10
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
VStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(isActive ? Color.yellow : Color.black.opacity(Design.Opacity.light))
|
||||||
|
.frame(width: buttonSize, height: buttonSize)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: iconSize, weight: .medium))
|
||||||
|
.foregroundStyle(isActive ? Color.black : Color.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: labelFontSize, weight: .medium))
|
||||||
|
.foregroundStyle(isActive ? Color.yellow : Color.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("\(label)")
|
||||||
|
.accessibilityValue(isActive ? "On" : "Off")
|
||||||
|
}
|
||||||
|
}
|
||||||
163
SelfieCam/Features/Camera/Views/PhotoReviewView.swift
Normal file
163
SelfieCam/Features/Camera/Views/PhotoReviewView.swift
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
//
|
||||||
|
// PhotoReviewView.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Photo Review View
|
||||||
|
|
||||||
|
struct PhotoReviewView: View {
|
||||||
|
let photo: CapturedPhoto
|
||||||
|
let isSaving: Bool
|
||||||
|
let saveError: String?
|
||||||
|
let onRetake: () -> Void
|
||||||
|
let onSave: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Photo display
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
Image(uiImage: photo.image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Top toolbar
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
// Retake button
|
||||||
|
Button(action: onRetake) {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
Button(action: onRetake) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.6))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
.padding(.top, Design.Spacing.large)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Bottom action bar
|
||||||
|
VStack(spacing: Design.Spacing.medium) {
|
||||||
|
// Save status or error
|
||||||
|
if let error = saveError {
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(Color.red.opacity(0.2))
|
||||||
|
)
|
||||||
|
} else if isSaving {
|
||||||
|
HStack(spacing: Design.Spacing.small) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(.white)
|
||||||
|
Text("Saving...")
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
HStack(spacing: Design.Spacing.xLarge) {
|
||||||
|
// Share button
|
||||||
|
ShareButton(photo: photo.image)
|
||||||
|
|
||||||
|
// Save button
|
||||||
|
Button(action: onSave) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.white)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 24, weight: .bold))
|
||||||
|
.foregroundColor(.black)
|
||||||
|
}
|
||||||
|
.shadow(radius: 5)
|
||||||
|
}
|
||||||
|
.disabled(isSaving)
|
||||||
|
}
|
||||||
|
.padding(.bottom, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, Design.Spacing.large)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Photo review")
|
||||||
|
.accessibilityHint("Use the buttons at the bottom to save or share your photo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share Button
|
||||||
|
|
||||||
|
struct ShareButton: View {
|
||||||
|
let photo: UIImage
|
||||||
|
@State private var isShareSheetPresented = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
isShareSheetPresented = true
|
||||||
|
}) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.6))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.font(.system(size: 24, weight: .medium))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.shadow(radius: 5)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $isShareSheetPresented) {
|
||||||
|
ShareSheet(activityItems: [photo])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Share Sheet
|
||||||
|
|
||||||
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
let activityItems: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
let controller = UIActivityViewController(
|
||||||
|
activityItems: activityItems,
|
||||||
|
applicationActivities: nil
|
||||||
|
)
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
|
||||||
|
// No updates needed
|
||||||
|
}
|
||||||
|
}
|
||||||
61
SelfieCam/Features/Camera/Views/RingLightOverlay.swift
Normal file
61
SelfieCam/Features/Camera/Views/RingLightOverlay.swift
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// RingLightOverlay.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Ring Light Overlay
|
||||||
|
|
||||||
|
struct RingLightOverlay: View {
|
||||||
|
let color: Color
|
||||||
|
let width: CGFloat
|
||||||
|
let opacity: Double
|
||||||
|
var cornerRadius: CGFloat = 24
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Use a filled rectangle with rounded rectangle cutout
|
||||||
|
// This ensures the inner edge has visible rounded corners
|
||||||
|
Rectangle()
|
||||||
|
.fill(color.opacity(opacity))
|
||||||
|
.reverseMask {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.padding(width) // Inset by the border width to create the ring effect
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corner Mask Overlay
|
||||||
|
/// Creates a black overlay with a rounded rectangle cutout to mask camera preview corners
|
||||||
|
|
||||||
|
struct CornerMaskOverlay: View {
|
||||||
|
let cornerRadius: CGFloat
|
||||||
|
var maskColor: Color = .black
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(maskColor)
|
||||||
|
.reverseMask {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reverse Mask Extension
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func reverseMask<Mask: View>(@ViewBuilder _ mask: () -> Mask) -> some View {
|
||||||
|
self.mask(
|
||||||
|
Rectangle()
|
||||||
|
.overlay(
|
||||||
|
mask()
|
||||||
|
.blendMode(.destinationOut)
|
||||||
|
)
|
||||||
|
.compositingGroup()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
55
SelfieCam/Features/Camera/Views/ZoomControlView.swift
Normal file
55
SelfieCam/Features/Camera/Views/ZoomControlView.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// ZoomControlView.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Bedrock
|
||||||
|
|
||||||
|
// MARK: - Zoom Control View
|
||||||
|
|
||||||
|
struct ZoomControlView: View {
|
||||||
|
let zoomFactor: CGFloat
|
||||||
|
var isCenterStageActive: Bool = false
|
||||||
|
|
||||||
|
// Layout constants
|
||||||
|
private let fontSize: CGFloat = 16
|
||||||
|
private let iconSize: CGFloat = 14
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
if isCenterStageActive {
|
||||||
|
// Show Center Stage indicator instead of zoom level
|
||||||
|
// (pinch to zoom is disabled when Center Stage is active)
|
||||||
|
HStack(spacing: Design.Spacing.xSmall) {
|
||||||
|
Image(systemName: "person.crop.rectangle.fill")
|
||||||
|
.font(.system(size: iconSize, weight: .medium))
|
||||||
|
Text("Center Stage")
|
||||||
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color.yellow)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(Color.black.opacity(Design.Opacity.medium))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(String(format: "%.1fx", zoomFactor))
|
||||||
|
.font(.system(size: fontSize, weight: .medium))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
|
.fill(Color.black.opacity(Design.Opacity.medium))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.accessibilityLabel(isCenterStageActive ? "Center Stage active" : "Zoom \(String(format: "%.1f", zoomFactor)) times")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
@ -59,6 +60,37 @@ struct SettingsView: View {
|
|||||||
)
|
)
|
||||||
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
|
.accessibilityHint(String(localized: "Shows a grid overlay to help compose your shot"))
|
||||||
|
|
||||||
|
// Flash Mode
|
||||||
|
flashModePicker
|
||||||
|
|
||||||
|
// Flash Sync
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Flash Sync"),
|
||||||
|
subtitle: String(localized: "Use ring light color for flash"),
|
||||||
|
isOn: $viewModel.isFlashSyncedWithRingLight
|
||||||
|
)
|
||||||
|
.accessibilityHint(String(localized: "Syncs flash color with ring light color"))
|
||||||
|
|
||||||
|
// HDR Mode
|
||||||
|
hdrModePicker
|
||||||
|
|
||||||
|
// Photo Quality
|
||||||
|
photoQualityPicker
|
||||||
|
|
||||||
|
// Camera Position
|
||||||
|
cameraPositionPicker
|
||||||
|
|
||||||
|
// Ring Light Enabled
|
||||||
|
SettingsToggle(
|
||||||
|
title: String(localized: "Ring Light Enabled"),
|
||||||
|
subtitle: String(localized: "Show ring light around camera"),
|
||||||
|
isOn: $viewModel.isRingLightEnabled
|
||||||
|
)
|
||||||
|
.accessibilityHint(String(localized: "Enables or disables the ring light overlay"))
|
||||||
|
|
||||||
|
// Ring Light Brightness
|
||||||
|
ringLightBrightnessSlider
|
||||||
|
|
||||||
// Timer Selection
|
// Timer Selection
|
||||||
timerPicker
|
timerPicker
|
||||||
|
|
||||||
@ -204,6 +236,95 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, Design.Spacing.xSmall)
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flash Mode Picker
|
||||||
|
|
||||||
|
private var flashModePicker: some View {
|
||||||
|
SegmentedPicker(
|
||||||
|
title: String(localized: "Flash Mode"),
|
||||||
|
options: CameraFlashMode.allCases.map { ($0.displayName, $0) },
|
||||||
|
selection: $viewModel.flashMode
|
||||||
|
)
|
||||||
|
.accessibilityLabel(String(localized: "Select flash mode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HDR Mode Picker
|
||||||
|
|
||||||
|
private var hdrModePicker: some View {
|
||||||
|
SegmentedPicker(
|
||||||
|
title: String(localized: "HDR Mode"),
|
||||||
|
options: CameraHDRMode.allCases.map { ($0.displayName, $0) },
|
||||||
|
selection: $viewModel.hdrMode
|
||||||
|
)
|
||||||
|
.accessibilityLabel(String(localized: "Select HDR mode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Photo Quality Picker
|
||||||
|
|
||||||
|
private var photoQualityPicker: some View {
|
||||||
|
SegmentedPicker(
|
||||||
|
title: String(localized: "Photo Quality"),
|
||||||
|
options: PhotoQuality.allCases.map { ($0.rawValue.capitalized, $0) },
|
||||||
|
selection: $viewModel.photoQuality
|
||||||
|
)
|
||||||
|
.accessibilityLabel(String(localized: "Select photo quality"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Position Picker
|
||||||
|
|
||||||
|
private var cameraPositionPicker: some View {
|
||||||
|
SegmentedPicker(
|
||||||
|
title: String(localized: "Camera"),
|
||||||
|
options: [
|
||||||
|
(String(localized: "Front"), CameraPosition.front),
|
||||||
|
(String(localized: "Back"), CameraPosition.back)
|
||||||
|
],
|
||||||
|
selection: $viewModel.cameraPosition
|
||||||
|
)
|
||||||
|
.accessibilityLabel(String(localized: "Select camera position"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ring Light Brightness Slider
|
||||||
|
|
||||||
|
private var ringLightBrightnessSlider: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Design.Spacing.small) {
|
||||||
|
HStack {
|
||||||
|
Text(String(localized: "Ring Light Brightness"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.medium, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(Int(viewModel.ringLightOpacity * 100))%")
|
||||||
|
.font(.system(size: Design.BaseFontSize.body, weight: .medium, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: Design.Spacing.medium) {
|
||||||
|
Image(systemName: "sun.min")
|
||||||
|
.font(.system(size: Design.BaseFontSize.small))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value: $viewModel.ringLightOpacity,
|
||||||
|
in: 0.1...1.0,
|
||||||
|
step: 0.05
|
||||||
|
)
|
||||||
|
.tint(Color.Accent.primary)
|
||||||
|
|
||||||
|
Image(systemName: "sun.max.fill")
|
||||||
|
.font(.system(size: Design.BaseFontSize.large))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(String(localized: "Adjusts the brightness of the ring light"))
|
||||||
|
.font(.system(size: Design.BaseFontSize.caption))
|
||||||
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
||||||
|
}
|
||||||
|
.padding(.vertical, Design.Spacing.xSmall)
|
||||||
|
.accessibilityLabel(String(localized: "Ring light brightness"))
|
||||||
|
.accessibilityValue("\(Int(viewModel.ringLightOpacity * 100)) percent")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Timer Picker
|
// MARK: - Timer Picker
|
||||||
|
|
||||||
private var timerPicker: some View {
|
private var timerPicker: some View {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Bedrock
|
import Bedrock
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
// MARK: - Timer Options
|
// MARK: - Timer Options
|
||||||
|
|
||||||
@ -199,6 +200,51 @@ final class SettingsViewModel: RingLightConfigurable {
|
|||||||
set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } }
|
set { updateSettings { $0.selectedCaptureModeRaw = newValue.rawValue } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Camera Settings
|
||||||
|
|
||||||
|
var flashMode: CameraFlashMode {
|
||||||
|
get { CameraFlashMode(rawValue: cloudSync.data.flashModeRaw) ?? .off }
|
||||||
|
set { updateSettings { $0.flashModeRaw = newValue.rawValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var isFlashSyncedWithRingLight: Bool {
|
||||||
|
get { cloudSync.data.isFlashSyncedWithRingLight }
|
||||||
|
set { updateSettings { $0.isFlashSyncedWithRingLight = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var hdrMode: CameraHDRMode {
|
||||||
|
get { CameraHDRMode(rawValue: cloudSync.data.hdrModeRaw) ?? .off }
|
||||||
|
set { updateSettings { $0.hdrModeRaw = newValue.rawValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var photoQuality: PhotoQuality {
|
||||||
|
get { PhotoQuality(rawValue: cloudSync.data.photoQualityRaw) ?? .high }
|
||||||
|
set { updateSettings { $0.photoQualityRaw = newValue.rawValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var cameraPosition: CameraPosition {
|
||||||
|
get {
|
||||||
|
if cloudSync.data.cameraPositionRaw == "front" {
|
||||||
|
return .front
|
||||||
|
} else {
|
||||||
|
return .back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
updateSettings { $0.cameraPositionRaw = newValue == .front ? "front" : "back" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isRingLightEnabled: Bool {
|
||||||
|
get { cloudSync.data.isRingLightEnabled }
|
||||||
|
set { updateSettings { $0.isRingLightEnabled = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
|
var ringLightOpacity: Double {
|
||||||
|
get { cloudSync.data.ringLightOpacity }
|
||||||
|
set { updateSettings { $0.ringLightOpacity = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
var selectedLightColor: RingLightColor {
|
var selectedLightColor: RingLightColor {
|
||||||
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
|
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
|
||||||
set {
|
set {
|
||||||
|
|||||||
@ -1,10 +1,26 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"%@" : {
|
||||||
|
"comment" : "A button with an icon and label. The argument is the text to display in the button.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"%lld" : {
|
||||||
|
"comment" : "A text label displaying the currently selected ring light size. The text inside the label is replaced with the actual size value.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"%lld percent" : {
|
||||||
|
"comment" : "The accessibility value of the ring light brightness slider, expressed as a percentage.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld points" : {
|
"%lld points" : {
|
||||||
"comment" : "The value of the ring size slider, displayed in parentheses.",
|
"comment" : "The value of the ring size slider, displayed in parentheses.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"%lld%%" : {
|
||||||
|
"comment" : "A text label displaying the current brightness setting of the ring light, formatted as a percentage. The argument is the current brightness setting of the ring light, as a decimal between 0.0 and 1.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lldpt" : {
|
"%lldpt" : {
|
||||||
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
|
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -17,10 +33,22 @@
|
|||||||
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
|
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"10%" : {
|
||||||
|
"comment" : "A label displayed alongside the left edge of the opacity slider.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"10s" : {
|
"10s" : {
|
||||||
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
|
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"100%" : {
|
||||||
|
"comment" : "A label displayed alongside the right edge of the opacity slider.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Adjusts the brightness of the ring light" : {
|
||||||
|
"comment" : "A description of the ring light brightness slider.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Adjusts the size of the light ring around the camera preview" : {
|
"Adjusts the size of the light ring around the camera preview" : {
|
||||||
"comment" : "A description of the ring size slider in the settings view.",
|
"comment" : "A description of the ring size slider in the settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -49,6 +77,10 @@
|
|||||||
"comment" : "A toggle option in the Settings view that allows the user to enable or disable automatic saving of captured photos and videos to the user's Photo Library.",
|
"comment" : "A toggle option in the Settings view that allows the user to enable or disable automatic saving of captured photos and videos to the user's Photo Library.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Back" : {
|
||||||
|
"comment" : "Option in the camera position picker for using the back camera.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Best Value • Save 33%" : {
|
"Best Value • Save 33%" : {
|
||||||
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -56,6 +88,16 @@
|
|||||||
"Boomerang" : {
|
"Boomerang" : {
|
||||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Camera" : {
|
||||||
|
"comment" : "Options for the camera position picker.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Camera controls" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Camera Controls" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
"comment" : "The text for a button that dismisses the current view.",
|
"comment" : "The text for a button that dismisses the current view.",
|
||||||
@ -68,6 +110,13 @@
|
|||||||
"Captured video" : {
|
"Captured video" : {
|
||||||
"comment" : "A label describing a captured video.",
|
"comment" : "A label describing a captured video.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Center Stage" : {
|
||||||
|
"comment" : "A label for the \"Center Stage\" button in the zoom control view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Center Stage active" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Close preview" : {
|
"Close preview" : {
|
||||||
"comment" : "A button label that closes the preview screen.",
|
"comment" : "A button label that closes the preview screen.",
|
||||||
@ -101,6 +150,26 @@
|
|||||||
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
|
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Double tap to capture a photo" : {
|
||||||
|
"comment" : "An accessibility hint for the capture button, instructing the user to double-tap it to capture a photo.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Enables or disables the ring light overlay" : {
|
||||||
|
"comment" : "A toggle that enables or disables the ring light overlay.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Flash Mode" : {
|
||||||
|
"comment" : "Title of a segmented picker that allows the user to select the flash mode of the camera.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Flash Sync" : {
|
||||||
|
"comment" : "Title of a toggle that synchronizes the flash color with the ring light color.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Front" : {
|
||||||
|
"comment" : "Option in the camera position picker for using the front camera.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Front Flash" : {
|
"Front Flash" : {
|
||||||
"comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.",
|
"comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -113,6 +182,10 @@
|
|||||||
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
|
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"HDR Mode" : {
|
||||||
|
"comment" : "Title for a picker that allows the user to select the HDR mode of the camera.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Hides preview during capture for a flash effect" : {
|
"Hides preview during capture for a flash effect" : {
|
||||||
"comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.",
|
"comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -140,6 +213,10 @@
|
|||||||
"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 value that describes a control item as \"On\".",
|
||||||
|
"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
|
||||||
@ -150,6 +227,14 @@
|
|||||||
},
|
},
|
||||||
"Photo" : {
|
"Photo" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Photo Quality" : {
|
||||||
|
"comment" : "Title of a segmented picker that allows the user to select the photo quality.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Photo review" : {
|
||||||
|
"comment" : "The title of the view that lets users review and save or share a photo.",
|
||||||
|
"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.",
|
||||||
@ -175,6 +260,29 @@
|
|||||||
"comment" : "Title for a button that allows the user to retake a captured photo or video.",
|
"comment" : "Title for a button that allows the user to retake a captured photo or video.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Ring Light" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Ring light brightness" : {
|
||||||
|
"comment" : "An accessibility label for the ring light brightness setting in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ring Light Brightness" : {
|
||||||
|
"comment" : "The title of the overlay that appears when the user taps the ring light button.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ring Light Color" : {
|
||||||
|
"comment" : "The title of the color picker overlay.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ring Light Enabled" : {
|
||||||
|
"comment" : "Title of a toggle that enables or disables the ring light overlay.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Ring Light Size" : {
|
||||||
|
"comment" : "The title of the slider that allows the user to select the size of their ring light.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Ring size" : {
|
"Ring size" : {
|
||||||
"comment" : "An accessibility label for the ring size slider in the settings view.",
|
"comment" : "An accessibility label for the ring size slider in the settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -191,6 +299,26 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
|
"Saving..." : {
|
||||||
|
"comment" : "A text that appears while a photo is being saved.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Select camera position" : {
|
||||||
|
"comment" : "A label describing the action of selecting a camera position.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Select flash mode" : {
|
||||||
|
"comment" : "An accessibility label for the flash mode picker in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Select HDR mode" : {
|
||||||
|
"comment" : "A label describing the action of selecting an HDR mode in the settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Select photo quality" : {
|
||||||
|
"comment" : "A label describing a segmented picker for selecting photo quality.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Select self-timer duration" : {
|
"Select self-timer duration" : {
|
||||||
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
|
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -207,6 +335,10 @@
|
|||||||
"comment" : "Title for a button that shares the captured media.",
|
"comment" : "Title for a button that shares the captured media.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Show ring light around camera" : {
|
||||||
|
"comment" : "Title of a toggle that enables or disables the ring light overlay.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Shows a grid overlay to help compose your shot" : {
|
"Shows a grid overlay to help compose your shot" : {
|
||||||
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -261,6 +393,20 @@
|
|||||||
},
|
},
|
||||||
"Syncing..." : {
|
"Syncing..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Syncs flash color with ring light color" : {
|
||||||
|
"comment" : "A toggle that synchronizes the flash color with the ring light color.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Take photo" : {
|
||||||
|
"comment" : "An accessibility label for the capture button.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Tap to collapse settings" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Tap to expand camera settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"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.",
|
||||||
@ -282,6 +428,14 @@
|
|||||||
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
"comment" : "A button label that prompts users to upgrade to the premium version of the app.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Use ring light color for flash" : {
|
||||||
|
"comment" : "Text for the \"Flash Sync\" toggle in the Settings view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Use the buttons at the bottom to save or share your photo" : {
|
||||||
|
"comment" : "An accessibility hint for the photo review view, instructing the user on how to interact with the view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Uses the ring light as a flash when taking photos" : {
|
"Uses the ring light as a flash when taking photos" : {
|
||||||
"comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.",
|
"comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@ -309,6 +463,10 @@
|
|||||||
"When enabled, the preview is not mirrored" : {
|
"When enabled, the preview is not mirrored" : {
|
||||||
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
|
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Zoom %@ times" : {
|
||||||
|
"comment" : "A label describing the zoom level of the camera view. The argument is the string “%.1f”.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
44
SelfieCam/Shared/Extensions/Color+Codable.swift
Normal file
44
SelfieCam/Shared/Extensions/Color+Codable.swift
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// Color+Codable.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/3/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Color Codable Extension
|
||||||
|
|
||||||
|
extension Color: Codable {
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case red, green, blue, opacity
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let red = try container.decode(Double.self, forKey: .red)
|
||||||
|
let green = try container.decode(Double.self, forKey: .green)
|
||||||
|
let blue = try container.decode(Double.self, forKey: .blue)
|
||||||
|
let opacity = try container.decode(Double.self, forKey: .opacity)
|
||||||
|
|
||||||
|
self.init(red: red, green: green, blue: blue, opacity: opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
// Convert Color to RGB components
|
||||||
|
let uiColor = UIColor(self)
|
||||||
|
var red: CGFloat = 0
|
||||||
|
var green: CGFloat = 0
|
||||||
|
var blue: CGFloat = 0
|
||||||
|
var alpha: CGFloat = 0
|
||||||
|
|
||||||
|
uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||||
|
|
||||||
|
try container.encode(Double(red), forKey: .red)
|
||||||
|
try container.encode(Double(green), forKey: .green)
|
||||||
|
try container.encode(Double(blue), forKey: .blue)
|
||||||
|
try container.encode(Double(alpha), forKey: .opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
SelfieCam/Shared/Models/CameraFlashMode.swift
Normal file
49
SelfieCam/Shared/Models/CameraFlashMode.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// CameraFlashMode.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/3/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
|
/// Camera flash mode options matching MijickCamera's CameraFlashMode
|
||||||
|
enum CameraFlashMode: String, CaseIterable, Codable {
|
||||||
|
case off
|
||||||
|
case on
|
||||||
|
case auto
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "Off"
|
||||||
|
case .on: return "On"
|
||||||
|
case .auto: return "Auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "bolt.slash"
|
||||||
|
case .on: return "bolt.fill"
|
||||||
|
case .auto: return "bolt.badge.automatic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "Flash disabled"
|
||||||
|
case .on: return "Flash always on"
|
||||||
|
case .auto: return "Flash when needed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to MijickCamera's CameraFlashMode
|
||||||
|
var toMijickFlashMode: MijickCamera.CameraFlashMode {
|
||||||
|
switch self {
|
||||||
|
case .off: return .off
|
||||||
|
case .on: return .on
|
||||||
|
case .auto: return .auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
SelfieCam/Shared/Models/CameraHDRMode.swift
Normal file
50
SelfieCam/Shared/Models/CameraHDRMode.swift
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// CameraHDRMode.swift
|
||||||
|
// SelfieCam
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/4/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MijickCamera
|
||||||
|
|
||||||
|
/// Camera HDR mode options
|
||||||
|
enum CameraHDRMode: String, CaseIterable, Codable {
|
||||||
|
case off
|
||||||
|
case on
|
||||||
|
case auto
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "Off"
|
||||||
|
case .on: return "On"
|
||||||
|
case .auto: return "Auto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .off: return "HDR disabled"
|
||||||
|
case .on: return "HDR always on"
|
||||||
|
case .auto: return "HDR when needed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to MijickCamera.CameraHDRMode
|
||||||
|
var toMijickHDRMode: MijickCamera.CameraHDRMode {
|
||||||
|
switch self {
|
||||||
|
case .off: return .off
|
||||||
|
case .on: return .on
|
||||||
|
case .auto: return .auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize from MijickCamera.CameraHDRMode
|
||||||
|
init(from mijickMode: MijickCamera.CameraHDRMode) {
|
||||||
|
switch mijickMode {
|
||||||
|
case .off: self = .off
|
||||||
|
case .on: self = .on
|
||||||
|
case .auto: self = .auto
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
SelfieCam/Shared/Models/CameraSettings.swift
Normal file
70
SelfieCam/Shared/Models/CameraSettings.swift
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// CameraSettings.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/3/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Persistent camera settings stored in UserDefaults
|
||||||
|
struct CameraSettings: Codable {
|
||||||
|
var photoQuality: PhotoQuality
|
||||||
|
var isRingLightEnabled: Bool
|
||||||
|
var ringLightColor: Color
|
||||||
|
var ringLightSize: CGFloat
|
||||||
|
var ringLightOpacity: Double
|
||||||
|
var flashMode: CameraFlashMode
|
||||||
|
var isFlashSyncedWithRingLight: Bool
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
static let `default` = CameraSettings(
|
||||||
|
photoQuality: .high,
|
||||||
|
isRingLightEnabled: true,
|
||||||
|
ringLightColor: .white,
|
||||||
|
ringLightSize: 25,
|
||||||
|
ringLightOpacity: 1.0,
|
||||||
|
flashMode: .off,
|
||||||
|
isFlashSyncedWithRingLight: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserDefaults keys
|
||||||
|
private enum Keys {
|
||||||
|
static let cameraSettings = "cameraSettings"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
/// Save settings to UserDefaults
|
||||||
|
func save() {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
do {
|
||||||
|
let data = try encoder.encode(self)
|
||||||
|
UserDefaults.standard.set(data, forKey: Keys.cameraSettings)
|
||||||
|
UserDefaults.standard.synchronize()
|
||||||
|
} catch {
|
||||||
|
print("Failed to save camera settings: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load settings from UserDefaults, or return defaults if none exist
|
||||||
|
static func load() -> CameraSettings {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: Keys.cameraSettings) else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
do {
|
||||||
|
return try decoder.decode(CameraSettings.self, from: data)
|
||||||
|
} catch {
|
||||||
|
print("Failed to load camera settings, using defaults: \(error)")
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset settings to defaults and save
|
||||||
|
static func reset() {
|
||||||
|
let settings = CameraSettings.default
|
||||||
|
settings.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
15
SelfieCam/Shared/Models/CapturedPhoto.swift
Normal file
15
SelfieCam/Shared/Models/CapturedPhoto.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// CapturedPhoto.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/3/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Photo capture data
|
||||||
|
struct CapturedPhoto: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let image: UIImage
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
39
SelfieCam/Shared/Models/PhotoQuality.swift
Normal file
39
SelfieCam/Shared/Models/PhotoQuality.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// PhotoQuality.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/3/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Photo quality settings
|
||||||
|
enum PhotoQuality: String, CaseIterable, Codable {
|
||||||
|
case high = "High"
|
||||||
|
case medium = "Medium"
|
||||||
|
case low = "Low"
|
||||||
|
|
||||||
|
var compressionQuality: CGFloat {
|
||||||
|
switch self {
|
||||||
|
case .high: return 0.9 // 90% quality - best for sharing/printing
|
||||||
|
case .medium: return 0.75 // 75% quality - balanced
|
||||||
|
case .low: return 0.5 // 50% quality - small file size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "star.fill"
|
||||||
|
case .medium: return "star.leadinghalf.filled"
|
||||||
|
case .low: return "star"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .high: return "High Quality"
|
||||||
|
case .medium: return "Medium Quality"
|
||||||
|
case .low: return "Low Quality"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
SelfieCam/Shared/Protocols/CameraProtocols.swift
Normal file
119
SelfieCam/Shared/Protocols/CameraProtocols.swift
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
//
|
||||||
|
// CameraProtocols.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/2/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Protocol defining camera session management capabilities
|
||||||
|
protocol CameraSessionManaging: AnyObject {
|
||||||
|
var isSessionRunning: Bool { get }
|
||||||
|
|
||||||
|
func setupCamera() async
|
||||||
|
func startSession() async
|
||||||
|
func stopSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol defining photo capture capabilities
|
||||||
|
protocol PhotoCapturing: AnyObject {
|
||||||
|
var capturedImage: UIImage? { get }
|
||||||
|
|
||||||
|
func takePhoto() async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol defining camera permission handling
|
||||||
|
protocol CameraPermissionHandling: AnyObject {
|
||||||
|
func requestCameraPermission() async -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol defining camera controls (flash, zoom, switch camera)
|
||||||
|
protocol CameraControllingAdvanced: AnyObject {
|
||||||
|
var currentCameraPosition: AVCaptureDevice.Position { get }
|
||||||
|
var isFlashAvailable: Bool { get }
|
||||||
|
var currentFlashMode: AVCaptureDevice.FlashMode { get }
|
||||||
|
var zoomFactor: CGFloat { get }
|
||||||
|
var maxZoomFactor: CGFloat { get }
|
||||||
|
|
||||||
|
func switchCamera() async
|
||||||
|
func setFlashMode(_ mode: AVCaptureDevice.FlashMode)
|
||||||
|
func setZoomFactor(_ factor: CGFloat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol defining Center Stage control capabilities
|
||||||
|
/// Center Stage automatically keeps users centered in the frame using the front camera
|
||||||
|
protocol CenterStageControlling {
|
||||||
|
/// Whether Center Stage is currently available on this device
|
||||||
|
var isCenterStageAvailable: Bool { get }
|
||||||
|
|
||||||
|
/// Whether Center Stage is currently enabled
|
||||||
|
var isCenterStageEnabled: Bool { get }
|
||||||
|
|
||||||
|
/// Enable or disable Center Stage
|
||||||
|
/// - Parameter enabled: Whether to enable Center Stage
|
||||||
|
/// - Throws: If Center Stage cannot be configured
|
||||||
|
func setCenterStageEnabled(_ enabled: Bool) throws
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Center Stage Default Implementation
|
||||||
|
|
||||||
|
extension CenterStageControlling {
|
||||||
|
var isCenterStageAvailable: Bool {
|
||||||
|
AVCaptureDevice.isCenterStageEnabled || canEnableCenterStage
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canEnableCenterStage: Bool {
|
||||||
|
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check if the device supports center stage by checking active format
|
||||||
|
return device.activeFormat.isCenterStageSupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combined protocol for full camera functionality
|
||||||
|
protocol CameraControlling: CameraSessionManaging, PhotoCapturing, CameraPermissionHandling, CameraControllingAdvanced {}
|
||||||
|
|
||||||
|
// MARK: - Default Implementations
|
||||||
|
|
||||||
|
extension CameraSessionManaging {
|
||||||
|
func startSession() async {
|
||||||
|
// Default implementation can be provided if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSession() {
|
||||||
|
// Default implementation can be provided if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PhotoCapturing {
|
||||||
|
func takePhoto() async throws {
|
||||||
|
// Default implementation can be provided if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraPermissionHandling {
|
||||||
|
func requestCameraPermission() async -> Bool {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||||
|
continuation.resume(returning: granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CameraControllingAdvanced {
|
||||||
|
func switchCamera() async {
|
||||||
|
// Default implementation can be provided if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFlashMode(_ mode: AVCaptureDevice.FlashMode) {
|
||||||
|
// Default implementation can be provided if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
func setZoomFactor(_ factor: CGFloat) {
|
||||||
|
// Default implementation can be provided if needed
|
||||||
|
}
|
||||||
|
}
|
||||||
59
SelfieCam/Shared/Services/PhotoLibraryService.swift
Normal file
59
SelfieCam/Shared/Services/PhotoLibraryService.swift
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// PhotoLibraryService.swift
|
||||||
|
// CameraTester
|
||||||
|
//
|
||||||
|
// Created by Matt Bruce on 1/3/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Photos
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Errors that can occur during photo library operations
|
||||||
|
enum PhotoLibraryError: Error {
|
||||||
|
case accessDenied
|
||||||
|
case conversionFailed
|
||||||
|
case saveFailed(String)
|
||||||
|
|
||||||
|
var localizedDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .accessDenied:
|
||||||
|
return "Photos access denied. Please enable in Settings."
|
||||||
|
case .conversionFailed:
|
||||||
|
return "Failed to convert image to JPEG format"
|
||||||
|
case .saveFailed(let message):
|
||||||
|
return "Failed to save photo: \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for handling photo library operations
|
||||||
|
@MainActor
|
||||||
|
class PhotoLibraryService {
|
||||||
|
/// Save a photo to the user's photo library
|
||||||
|
/// - Parameters:
|
||||||
|
/// - image: The UIImage to save
|
||||||
|
/// - quality: The photo quality setting for JPEG compression
|
||||||
|
/// - Returns: Result indicating success or error
|
||||||
|
static func savePhotoToLibrary(_ image: UIImage, quality: PhotoQuality) async -> Result<Void, PhotoLibraryError> {
|
||||||
|
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||||
|
|
||||||
|
guard status == .authorized || status == .limited else {
|
||||||
|
return .failure(.accessDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert image to JPEG data using selected quality
|
||||||
|
guard let imageData = image.jpegData(compressionQuality: quality.compressionQuality) else {
|
||||||
|
return .failure(.conversionFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await PHPhotoLibrary.shared().performChanges {
|
||||||
|
let options = PHAssetResourceCreationOptions()
|
||||||
|
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: options)
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
} catch {
|
||||||
|
return .failure(.saveFailed(error.localizedDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -64,6 +64,27 @@ struct SyncedSettings: PersistableData, Sendable {
|
|||||||
/// Whether captures are auto-saved to Photo Library
|
/// Whether captures are auto-saved to Photo Library
|
||||||
var isAutoSaveEnabled: Bool = true
|
var isAutoSaveEnabled: Bool = true
|
||||||
|
|
||||||
|
/// Flash mode raw value
|
||||||
|
var flashModeRaw: String = "off"
|
||||||
|
|
||||||
|
/// Whether flash is synced with ring light color
|
||||||
|
var isFlashSyncedWithRingLight: Bool = false
|
||||||
|
|
||||||
|
/// HDR mode raw value
|
||||||
|
var hdrModeRaw: String = "off"
|
||||||
|
|
||||||
|
/// Photo quality raw value
|
||||||
|
var photoQualityRaw: String = "high"
|
||||||
|
|
||||||
|
/// Camera position raw value
|
||||||
|
var cameraPositionRaw: String = "front"
|
||||||
|
|
||||||
|
/// Whether ring light is enabled
|
||||||
|
var isRingLightEnabled: Bool = true
|
||||||
|
|
||||||
|
/// Ring light opacity (brightness)
|
||||||
|
var ringLightOpacity: Double = 1.0
|
||||||
|
|
||||||
// MARK: - Computed Properties
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
/// Ring size as CGFloat (convenience accessor)
|
/// Ring size as CGFloat (convenience accessor)
|
||||||
@ -119,6 +140,13 @@ struct SyncedSettings: PersistableData, Sendable {
|
|||||||
case currentZoomFactor
|
case currentZoomFactor
|
||||||
case selectedCaptureModeRaw
|
case selectedCaptureModeRaw
|
||||||
case isAutoSaveEnabled
|
case isAutoSaveEnabled
|
||||||
|
case flashModeRaw
|
||||||
|
case isFlashSyncedWithRingLight
|
||||||
|
case hdrModeRaw
|
||||||
|
case photoQualityRaw
|
||||||
|
case cameraPositionRaw
|
||||||
|
case isRingLightEnabled
|
||||||
|
case ringLightOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,6 +166,13 @@ extension SyncedSettings: Equatable {
|
|||||||
lhs.isGridVisible == rhs.isGridVisible &&
|
lhs.isGridVisible == rhs.isGridVisible &&
|
||||||
lhs.currentZoomFactor == rhs.currentZoomFactor &&
|
lhs.currentZoomFactor == rhs.currentZoomFactor &&
|
||||||
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw &&
|
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw &&
|
||||||
lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled
|
lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled &&
|
||||||
|
lhs.flashModeRaw == rhs.flashModeRaw &&
|
||||||
|
lhs.isFlashSyncedWithRingLight == rhs.isFlashSyncedWithRingLight &&
|
||||||
|
lhs.hdrModeRaw == rhs.hdrModeRaw &&
|
||||||
|
lhs.photoQualityRaw == rhs.photoQualityRaw &&
|
||||||
|
lhs.cameraPositionRaw == rhs.cameraPositionRaw &&
|
||||||
|
lhs.isRingLightEnabled == rhs.isRingLightEnabled &&
|
||||||
|
lhs.ringLightOpacity == rhs.ringLightOpacity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user