226 lines
8.6 KiB
Swift
226 lines
8.6 KiB
Swift
//
|
|
// ContentView.swift
|
|
// SelfieCam
|
|
//
|
|
// Main camera view - coordinates camera session, settings, and photo capture flow.
|
|
//
|
|
|
|
import SwiftUI
|
|
import MijickCamera
|
|
import Bedrock
|
|
|
|
struct ContentView: View {
|
|
@State private var settings = SettingsViewModel()
|
|
@State private var premiumManager = PremiumManager()
|
|
@State private var showSettings = false
|
|
@State private var showPaywall = false
|
|
|
|
@State private var capturedPhoto: CapturedPhoto?
|
|
@State private var showPhotoReview = false
|
|
@State private var isSavingPhoto = false
|
|
@State private var saveError: String?
|
|
|
|
/// Settings are managed by SettingsViewModel which is already @Observable
|
|
|
|
/// 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()
|
|
|
|
/// Track camera position (as string) to detect changes when settings sheet closes
|
|
@State private var lastCameraPositionRaw: String = "front"
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Camera view - wrapped in EquatableView to prevent re-evaluation on settings changes
|
|
if !showPhotoReview {
|
|
EquatableView(content: CameraContainerView(
|
|
settings: settings,
|
|
sessionKey: cameraSessionKey,
|
|
cameraPosition: settings.cameraPosition,
|
|
onImageCaptured: { image in
|
|
handlePhotoCaptured(image)
|
|
}
|
|
))
|
|
.ignoresSafeArea() // Only camera ignores safe area to fill screen
|
|
}
|
|
|
|
// Photo review overlay - handles its own safe area
|
|
if showPhotoReview, let photo = capturedPhoto {
|
|
PhotoReviewView(
|
|
photo: photo,
|
|
isSaving: isSavingPhoto,
|
|
saveError: saveError,
|
|
onRetake: {
|
|
resetCameraForNextCapture()
|
|
},
|
|
onSave: {
|
|
savePhotoToLibrary(photo.image)
|
|
}
|
|
)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
// Background matches ring light color so rotation transitions are seamless
|
|
.background(
|
|
(settings.isRingLightEnabled ? settings.lightColor.opacity(settings.ringLightOpacity) : Color.black)
|
|
.ignoresSafeArea()
|
|
)
|
|
// Settings button overlay - only show when NOT in photo review mode
|
|
.overlay(alignment: .topTrailing) {
|
|
if !showPhotoReview {
|
|
Button {
|
|
showSettings = true
|
|
} label: {
|
|
Image(systemName: "gearshape.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(.white)
|
|
.padding(Design.Spacing.medium)
|
|
.background(.ultraThinMaterial, in: Circle())
|
|
.shadow(radius: Design.Shadow.radiusSmall)
|
|
}
|
|
.accessibilityLabel("Settings")
|
|
.padding(.horizontal, Design.Spacing.large)
|
|
.padding(.top, Design.Spacing.small)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: Design.Animation.quick), value: showPhotoReview)
|
|
.onAppear {
|
|
// Initialize tracking of camera position
|
|
lastCameraPositionRaw = settings.cameraPositionRaw
|
|
}
|
|
.onChange(of: settings.isRingLightEnabled) { _, newValue in
|
|
if settings.isFlashSyncedWithRingLight {
|
|
settings.flashMode = newValue ? .on : .off
|
|
}
|
|
}
|
|
.onChange(of: settings.isFlashSyncedWithRingLight) { _, newValue in
|
|
if newValue {
|
|
settings.flashMode = settings.isRingLightEnabled ? .on : .off
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings, onDismiss: {
|
|
// Check if camera position changed while settings was open
|
|
let currentRaw = settings.cameraPositionRaw
|
|
Design.debugLog("Settings dismissed - current raw: '\(currentRaw)', last raw: '\(lastCameraPositionRaw)'")
|
|
if currentRaw != lastCameraPositionRaw {
|
|
Design.debugLog("Camera position changed! Recreating camera session...")
|
|
lastCameraPositionRaw = currentRaw
|
|
// Force camera session recreation with new position
|
|
cameraSessionKey = UUID()
|
|
}
|
|
}) {
|
|
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
|
}
|
|
.sheet(isPresented: $showPaywall) {
|
|
PaywallPresenter()
|
|
// No callback needed - paywall auto-dismisses on success
|
|
// and premium status updates automatically via PremiumManager
|
|
}
|
|
}
|
|
|
|
/// Handles photo capture - either auto-saves or shows review screen
|
|
private func handlePhotoCaptured(_ image: UIImage) {
|
|
if settings.isAutoSaveEnabled {
|
|
// Auto-save enabled: save immediately without showing review screen
|
|
Task {
|
|
// Small delay to ensure shutter sound plays before saving
|
|
try? await Task.sleep(for: .milliseconds(200))
|
|
|
|
let quality = settings.photoQuality
|
|
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
|
|
|
|
switch result {
|
|
case .success:
|
|
Design.debugLog("Photo auto-saved successfully")
|
|
// Don't reset camera session for auto-save to avoid timing issues
|
|
case .failure(let error):
|
|
Design.debugLog("Failed to auto-save photo: \(error)")
|
|
// Don't reset on failure either
|
|
}
|
|
}
|
|
} else {
|
|
// Auto-save disabled: show review screen
|
|
capturedPhoto = CapturedPhoto(image: image, timestamp: Date())
|
|
showPhotoReview = true
|
|
isSavingPhoto = false
|
|
saveError = 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 = settings.photoQuality
|
|
|
|
Task {
|
|
let result = await PhotoLibraryService.savePhotoToLibrary(image, quality: quality)
|
|
|
|
await MainActor.run {
|
|
self.isSavingPhoto = false
|
|
|
|
switch result {
|
|
case .success:
|
|
Design.debugLog("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):
|
|
Design.debugLog("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 settings: SettingsViewModel
|
|
let sessionKey: UUID
|
|
let cameraPosition: CameraPosition
|
|
let onImageCaptured: (UIImage) -> Void
|
|
|
|
// Only compare sessionKey for equality - we want to handle position changes via runtime setCameraPosition
|
|
static func == (lhs: CameraContainerView, rhs: CameraContainerView) -> Bool {
|
|
lhs.sessionKey == rhs.sessionKey
|
|
}
|
|
|
|
var body: some View {
|
|
let _ = Design.debugLog("CameraContainerView body evaluated - sessionKey: \(sessionKey), position: \(cameraPosition)")
|
|
MCamera()
|
|
.setCameraScreen { cameraManager, namespace, closeAction in
|
|
CustomCameraScreen(
|
|
cameraManager: cameraManager,
|
|
namespace: namespace,
|
|
closeMCameraAction: closeAction,
|
|
cameraSettings: settings,
|
|
onPhotoCaptured: onImageCaptured
|
|
)
|
|
}
|
|
.setCapturedMediaScreen(nil)
|
|
.setCameraPosition(cameraPosition)
|
|
.startSession()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ContentView()
|
|
}
|