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" : [
|
||||
{
|
||||
"identity" : "bedrock",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git",
|
||||
"location" : "http://192.168.1.128:3000/mbrucedogs/Bedrock",
|
||||
"state" : {
|
||||
"branch" : "develop",
|
||||
"revision" : "9f4046bfd2c23e76c30dfefe0ed164405b1b0ee8"
|
||||
"branch" : "master",
|
||||
"revision" : "8e788ef2121024b25ac6150d857140af6bcd64c2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "camera",
|
||||
"identity" : "mijickcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Mijick/Camera",
|
||||
"location" : "http://192.168.1.128:3000/mbrucedogs/MijickCamera",
|
||||
"state" : {
|
||||
"revision" : "0f02348fcc8fbbc9224c7fbf444f182dc25d0b40",
|
||||
"version" : "3.0.3"
|
||||
"branch" : "develop",
|
||||
"revision" : "55940f0d52a69ab1959ec80118956ddbeca085f9"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@ -2,47 +2,100 @@ import SwiftUI
|
||||
import MijickCamera
|
||||
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 {
|
||||
@State private var settings = SettingsViewModel()
|
||||
@State private var premiumManager = PremiumManager()
|
||||
@State private var showSettings = false
|
||||
@State private var showPaywall = false
|
||||
|
||||
// Post-capture state
|
||||
@State private var capturedImage: UIImage?
|
||||
@State private var capturedVideoURL: URL?
|
||||
@State private var showPostCapture = false
|
||||
@State private var capturedPhoto: CapturedPhoto?
|
||||
@State private var showPhotoReview = false
|
||||
@State private var isSavingPhoto = false
|
||||
@State private var saveError: String?
|
||||
|
||||
/// Ring size clamped to reasonable max
|
||||
private var effectiveRingSize: CGFloat {
|
||||
let maxRing = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) * 0.2
|
||||
return min(settings.ringSize, maxRing)
|
||||
}
|
||||
/// Camera settings in a shared observable to prevent MCamera recreation
|
||||
@State private var cameraSettings: CameraSettingsState?
|
||||
|
||||
/// 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 {
|
||||
ZStack {
|
||||
// Ring light background
|
||||
settings.lightColor
|
||||
.ignoresSafeArea()
|
||||
// Camera view - only recreates when sessionKey changes due to Equatable
|
||||
if !showPhotoReview {
|
||||
CameraContainerView(
|
||||
cameraSettings: cameraSettings ?? CameraSettingsState(settings: settings),
|
||||
sessionKey: cameraSessionKey,
|
||||
onImageCaptured: { image in
|
||||
capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
|
||||
showPhotoReview = true
|
||||
isSavingPhoto = false
|
||||
saveError = nil
|
||||
print("Photo captured successfully")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MijickCamera with default UI
|
||||
MCamera()
|
||||
.setCameraPosition(.front) // Default to front camera for selfies
|
||||
.onImageCaptured { image, _ in
|
||||
capturedImage = image
|
||||
showPostCapture = true
|
||||
}
|
||||
.onVideoCaptured { url, _ in
|
||||
capturedVideoURL = url
|
||||
showPostCapture = true
|
||||
}
|
||||
.startSession()
|
||||
.padding(.horizontal, effectiveRingSize)
|
||||
.padding(.top, effectiveRingSize)
|
||||
.padding(.bottom, effectiveRingSize)
|
||||
// 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 (top right corner of camera area)
|
||||
// Settings button overlay
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
@ -59,45 +112,117 @@ struct ContentView: View {
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
.padding(.horizontal, effectiveRingSize + Design.Spacing.medium)
|
||||
.padding(.top, effectiveRingSize + Design.Spacing.medium)
|
||||
.padding(.horizontal, Design.Spacing.large)
|
||||
.padding(.top, Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
ProPaywallView()
|
||||
}
|
||||
.fullScreenCover(isPresented: $showPostCapture) {
|
||||
PostCapturePreviewView(
|
||||
capturedImage: capturedImage,
|
||||
capturedVideoURL: capturedVideoURL,
|
||||
isAutoSaveEnabled: settings.isAutoSaveEnabled,
|
||||
onRetake: {
|
||||
capturedImage = nil
|
||||
capturedVideoURL = nil
|
||||
showPostCapture = false
|
||||
},
|
||||
onSave: {
|
||||
saveCapture()
|
||||
showPostCapture = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save Capture
|
||||
private func updateCameraSettings() {
|
||||
cameraSettings?.update(from: settings)
|
||||
}
|
||||
|
||||
private func saveCapture() {
|
||||
if let image = capturedImage {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
capturedImage = nil
|
||||
capturedVideoURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
.startSession()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -51,9 +51,9 @@ struct PostCapturePreviewView: View {
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let image = capturedImage {
|
||||
ShareSheet(items: [image])
|
||||
ShareSheet(activityItems: [image])
|
||||
} 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 {
|
||||
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 Bedrock
|
||||
import MijickCamera
|
||||
|
||||
struct SettingsView: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@ -59,6 +60,37 @@ struct SettingsView: View {
|
||||
)
|
||||
.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
|
||||
timerPicker
|
||||
|
||||
@ -204,6 +236,95 @@ struct SettingsView: View {
|
||||
.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
|
||||
|
||||
private var timerPicker: some View {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import Bedrock
|
||||
import MijickCamera
|
||||
|
||||
// MARK: - Timer Options
|
||||
|
||||
@ -199,6 +200,51 @@ final class SettingsViewModel: RingLightConfigurable {
|
||||
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 {
|
||||
get { RingLightColor.fromId(lightColorId, customColor: customColor) }
|
||||
set {
|
||||
|
||||
@ -1,10 +1,26 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"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" : {
|
||||
"comment" : "The value of the ring size slider, displayed in parentheses.",
|
||||
"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" : {
|
||||
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -17,10 +33,22 @@
|
||||
"comment" : "Description of a timer option when the timer is set to 5 seconds.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"10%" : {
|
||||
"comment" : "A label displayed alongside the left edge of the opacity slider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"10s" : {
|
||||
"comment" : "Description of a timer option when the user selects \"10 seconds\".",
|
||||
"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" : {
|
||||
"comment" : "A description of the ring size slider in the settings view.",
|
||||
"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.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Back" : {
|
||||
"comment" : "Option in the camera position picker for using the back camera.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Best Value • Save 33%" : {
|
||||
"comment" : "A promotional text displayed below an annual subscription package, highlighting its value.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -56,6 +88,16 @@
|
||||
"Boomerang" : {
|
||||
"comment" : "Display name for the \"Boomerang\" capture mode.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Camera" : {
|
||||
"comment" : "Options for the camera position picker.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Camera controls" : {
|
||||
|
||||
},
|
||||
"Camera Controls" : {
|
||||
|
||||
},
|
||||
"Cancel" : {
|
||||
"comment" : "The text for a button that dismisses the current view.",
|
||||
@ -68,6 +110,13 @@
|
||||
"Captured video" : {
|
||||
"comment" : "A label describing a captured video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Center Stage" : {
|
||||
"comment" : "A label for the \"Center Stage\" button in the zoom control view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Center Stage active" : {
|
||||
|
||||
},
|
||||
"Close preview" : {
|
||||
"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.",
|
||||
"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" : {
|
||||
"comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -113,6 +182,10 @@
|
||||
"comment" : "Text displayed in a settings toggle for showing a grid overlay to help compose your shot.",
|
||||
"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" : {
|
||||
"comment" : "Subtitle for the \"Front Flash\" toggle in the Settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -140,6 +213,10 @@
|
||||
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"On" : {
|
||||
"comment" : "A value that describes a control item as \"On\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open Source Licenses" : {
|
||||
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -150,6 +227,14 @@
|
||||
},
|
||||
"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" : {
|
||||
"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.",
|
||||
"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" : {
|
||||
"comment" : "An accessibility label for the ring size slider in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -191,6 +299,26 @@
|
||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||
"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" : {
|
||||
"comment" : "A label describing the segmented control for selecting the duration of the self-timer.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -207,6 +335,10 @@
|
||||
"comment" : "Title for a button that shares the captured media.",
|
||||
"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" : {
|
||||
"comment" : "A toggle that enables or disables the rule of thirds grid overlay in the camera view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -261,6 +393,20 @@
|
||||
},
|
||||
"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" : {
|
||||
"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.",
|
||||
"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" : {
|
||||
"comment" : "An accessibility hint for the \"Front Flash\" toggle in the Settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -309,6 +463,10 @@
|
||||
"When enabled, the preview is not mirrored" : {
|
||||
"comment" : "Accessibility hint for the \"True Mirror\" setting in the Settings view.",
|
||||
"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"
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
/// Ring size as CGFloat (convenience accessor)
|
||||
@ -119,6 +140,13 @@ struct SyncedSettings: PersistableData, Sendable {
|
||||
case currentZoomFactor
|
||||
case selectedCaptureModeRaw
|
||||
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.currentZoomFactor == rhs.currentZoomFactor &&
|
||||
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