From ef15a8c21acb7512fad53b746922c039202bc5d5 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Fri, 2 Jan 2026 13:05:27 -0600 Subject: [PATCH] Add post-capture workflow with preview, edit, and share Features: - Full-screen PostCapturePreviewView after photo/video capture - Auto-save to Photo Library (on by default, configurable in Settings) - Toast notification when saved - Retake button to discard and return to camera - Share button with native iOS Share Sheet - Edit mode with smoothing and glow intensity sliders - Premium tools teaser in edit view - Video/boomerang auto-playback with loop support Settings: - Added 'Auto-Save' toggle in Capture section - Syncs across devices via iCloud Architecture: - CapturedMedia enum for photo/video/boomerang types - ShareSheet UIViewControllerRepresentable wrapper - Toast system in CameraViewModel --- .../Features/Camera/CameraViewModel.swift | 114 +++++- .../Features/Camera/ContentView.swift | 61 +++ .../Camera/PostCapturePreviewView.swift | 346 ++++++++++++++++++ .../Features/Settings/SettingsView.swift | 11 + .../Features/Settings/SettingsViewModel.swift | 6 + .../Shared/Storage/SyncedSettings.swift | 7 +- 6 files changed, 537 insertions(+), 8 deletions(-) create mode 100644 SelfieRingLight/Features/Camera/PostCapturePreviewView.swift diff --git a/SelfieRingLight/Features/Camera/CameraViewModel.swift b/SelfieRingLight/Features/Camera/CameraViewModel.swift index 9ad91c7..2887e32 100644 --- a/SelfieRingLight/Features/Camera/CameraViewModel.swift +++ b/SelfieRingLight/Features/Camera/CameraViewModel.swift @@ -23,6 +23,15 @@ class CameraViewModel: NSObject { /// Whether the preview should be hidden (for front flash effect) var isPreviewHidden = false + /// Captured media for preview (nil when no capture pending) + var capturedMedia: CapturedMedia? + + /// Whether to show the post-capture preview + var showPostCapturePreview = false + + /// Toast message to display + var toastMessage: String? + let settings = SettingsViewModel() // Shared config // MARK: - Screen Brightness Handling @@ -151,29 +160,120 @@ class CameraViewModel: NSObject { var canCapture: Bool { captureSession?.isRunning == true && isPhotoLibraryAuthorized } + + // MARK: - Post-Capture Actions + + /// Dismisses the post-capture preview and returns to camera + func dismissPostCapturePreview() { + showPostCapturePreview = false + capturedMedia = nil + } + + /// Retakes by dismissing preview (deletes unsaved temp if needed) + func retakeCapture() { + // If auto-save was off and there's temp media, it's discarded + dismissPostCapturePreview() + } + + /// Manually saves current capture to Photo Library + func saveCurrentCapture() { + guard let media = capturedMedia else { return } + + switch media { + case .photo(let image): + if let data = image.jpegData(compressionQuality: 0.9) { + savePhotoToLibrary(data: data) + showToast(String(localized: "Saved to Photos")) + } + case .video(let url), .boomerang(let url): + saveVideoToLibrary(url: url) + showToast(String(localized: "Saved to Photos")) + } + } + + /// Gets shareable items for the current capture + func getShareItems() -> [Any] { + guard let media = capturedMedia else { return [] } + + switch media { + case .photo(let image): + return [image] + case .video(let url), .boomerang(let url): + return [url] + } + } + + // MARK: - Toast + + /// Shows a toast message briefly + func showToast(_ message: String) { + toastMessage = message + + Task { + try? await Task.sleep(for: .seconds(2)) + if toastMessage == message { + toastMessage = nil + } + } + } } extension CameraViewModel: AVCapturePhotoCaptureDelegate { nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - guard let data = photo.fileDataRepresentation() else { return } + guard let data = photo.fileDataRepresentation(), + let image = UIImage(data: data) else { return } + + Task { @MainActor in + // Store the captured image for preview + capturedMedia = .photo(image) + + // Auto-save if enabled + if settings.isAutoSaveEnabled { + savePhotoToLibrary(data: data) + showToast(String(localized: "Saved to Photos")) + } + + // Show post-capture preview + showPostCapturePreview = true + + UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured")) + } + } + + /// Saves photo data to Photo Library + private func savePhotoToLibrary(data: Data) { PHPhotoLibrary.shared().performChanges { PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil) } - Task { @MainActor in - UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured")) - } } } extension CameraViewModel: AVCaptureFileOutputRecordingDelegate { nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { - PHPhotoLibrary.shared().performChanges { - PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: outputFileURL, options: nil) - } Task { @MainActor in + // Store the video URL for preview + let isBoomerang = settings.selectedCaptureMode == .boomerang + capturedMedia = isBoomerang ? .boomerang(outputFileURL) : .video(outputFileURL) + + // Auto-save if enabled + if settings.isAutoSaveEnabled { + saveVideoToLibrary(url: outputFileURL) + showToast(String(localized: "Saved to Photos")) + } + + // Show post-capture preview + showPostCapturePreview = true + UIAccessibility.post(notification: .announcement, argument: String(localized: "Video saved")) } } + + /// Saves video to Photo Library + private func saveVideoToLibrary(url: URL) { + PHPhotoLibrary.shared().performChanges { + PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil) + } + } } extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate { diff --git a/SelfieRingLight/Features/Camera/ContentView.swift b/SelfieRingLight/Features/Camera/ContentView.swift index 56f7999..cb4fb64 100644 --- a/SelfieRingLight/Features/Camera/ContentView.swift +++ b/SelfieRingLight/Features/Camera/ContentView.swift @@ -6,6 +6,7 @@ struct ContentView: View { @State private var premiumManager = PremiumManager() @State private var showPaywall = false @State private var showSettings = false + @State private var showShareSheet = false // Direct reference to shared settings private var settings: SettingsViewModel { @@ -40,6 +41,11 @@ struct ContentView: View { if !viewModel.isCameraAuthorized && viewModel.captureSession != nil { permissionDeniedView } + + // MARK: - Toast Notification + if let message = viewModel.toastMessage { + toastView(message: message) + } } } .ignoresSafeArea() @@ -55,6 +61,29 @@ struct ContentView: View { .sheet(isPresented: $showSettings) { SettingsView(viewModel: viewModel.settings) } + .fullScreenCover(isPresented: $viewModel.showPostCapturePreview) { + if let media = viewModel.capturedMedia { + PostCapturePreviewView( + media: media, + isPremiumUnlocked: premiumManager.isPremiumUnlocked, + onRetake: { + viewModel.retakeCapture() + }, + onSave: { + viewModel.saveCurrentCapture() + }, + onShare: { + showShareSheet = true + }, + onDismiss: { + viewModel.dismissPostCapturePreview() + } + ) + .sheet(isPresented: $showShareSheet) { + ShareSheet(items: viewModel.getShareItems()) + } + } + } } // MARK: - Ring Light Background @@ -309,6 +338,38 @@ struct ContentView: View { return String(localized: "Capture boomerang") } } + + // MARK: - Toast View + + private func toastView(message: String) -> some View { + VStack { + Spacer() + + Text(message) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, Design.Spacing.large) + .padding(.vertical, Design.Spacing.medium) + .background(.ultraThinMaterial, in: Capsule()) + .padding(.bottom, Design.Spacing.xxxLarge + settings.ringSize) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(duration: Design.Animation.quick), value: message) + } + .accessibilityLabel(message) + } +} + +// MARK: - Share Sheet + +/// UIKit wrapper for UIActivityViewController +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #Preview { diff --git a/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift b/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift new file mode 100644 index 0000000..217c9db --- /dev/null +++ b/SelfieRingLight/Features/Camera/PostCapturePreviewView.swift @@ -0,0 +1,346 @@ +import SwiftUI +import AVKit +import Bedrock + +// MARK: - Captured Media Type + +/// Represents captured media for preview +enum CapturedMedia: Equatable { + case photo(UIImage) + case video(URL) + case boomerang(URL) + + var isVideo: Bool { + switch self { + case .photo: return false + case .video, .boomerang: return true + } + } +} + +// MARK: - Post Capture Preview View + +/// Full-screen preview shown after photo/video capture +struct PostCapturePreviewView: View { + let media: CapturedMedia + let isPremiumUnlocked: Bool + let onRetake: () -> Void + let onSave: () -> Void + let onShare: () -> Void + let onDismiss: () -> Void + + @State private var showEditSheet = false + @State private var player: AVPlayer? + + var body: some View { + ZStack { + // Dark background + Color.black.ignoresSafeArea() + + // Media preview + mediaPreview + + // Controls overlay + VStack { + // Top bar with close button + topBar + + Spacer() + + // Bottom toolbar + bottomToolbar + } + } + .onAppear { + setupVideoPlayerIfNeeded() + } + .onDisappear { + player?.pause() + } + .sheet(isPresented: $showEditSheet) { + PostCaptureEditView( + media: media, + isPremiumUnlocked: isPremiumUnlocked + ) + } + } + + // MARK: - Media Preview + + @ViewBuilder + private var mediaPreview: some View { + switch media { + case .photo(let image): + Image(uiImage: image) + .resizable() + .scaledToFit() + .accessibilityLabel(String(localized: "Captured photo")) + + case .video(let url), .boomerang(let url): + if let player { + VideoPlayer(player: player) + .onAppear { + player.play() + } + .accessibilityLabel( + media == .boomerang(url) + ? String(localized: "Captured boomerang") + : String(localized: "Captured video") + ) + } else { + ProgressView() + .tint(.white) + } + } + } + + // MARK: - Top Bar + + private var topBar: some View { + HStack { + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.title2) + .foregroundStyle(.white) + .padding(Design.Spacing.medium) + .background(.ultraThinMaterial, in: .circle) + } + .accessibilityLabel(String(localized: "Close preview")) + + Spacer() + } + .padding(.horizontal, Design.Spacing.large) + .padding(.top, Design.Spacing.medium) + } + + // MARK: - Bottom Toolbar + + private var bottomToolbar: some View { + HStack(spacing: Design.Spacing.xLarge) { + // Retake button + ToolbarButton( + title: String(localized: "Retake"), + systemImage: "arrow.counterclockwise", + action: onRetake + ) + + Spacer() + + // Edit button + ToolbarButton( + title: String(localized: "Edit"), + systemImage: "slider.horizontal.3", + action: { showEditSheet = true } + ) + + Spacer() + + // Share button + ToolbarButton( + title: String(localized: "Share"), + systemImage: "square.and.arrow.up", + action: onShare + ) + } + .padding(.horizontal, Design.Spacing.xxLarge) + .padding(.vertical, Design.Spacing.large) + .background(.ultraThinMaterial) + } + + // MARK: - Video Setup + + private func setupVideoPlayerIfNeeded() { + switch media { + case .photo: + break + case .video(let url): + player = AVPlayer(url: url) + case .boomerang(let url): + let player = AVPlayer(url: url) + // Loop boomerang videos + NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: player.currentItem, + queue: .main + ) { _ in + player.seek(to: .zero) + player.play() + } + self.player = player + } + } +} + +// MARK: - Toolbar Button + +private struct ToolbarButton: View { + let title: String + let systemImage: String + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: Design.Spacing.xxSmall) { + Image(systemName: systemImage) + .font(.title2) + Text(title) + .font(.system(size: Design.BaseFontSize.caption)) + } + .foregroundStyle(.white) + } + .accessibilityLabel(title) + } +} + +// MARK: - Post Capture Edit View + +/// Lightweight editor for captured media +struct PostCaptureEditView: View { + let media: CapturedMedia + let isPremiumUnlocked: Bool + + @Environment(\.dismiss) private var dismiss + + @State private var smoothingIntensity: Double = 0.5 + @State private var glowIntensity: Double = 0.3 + + var body: some View { + NavigationStack { + VStack(spacing: Design.Spacing.large) { + // Preview (simplified for now) + mediaPreview + .frame(maxHeight: .infinity) + + // Edit controls + VStack(spacing: Design.Spacing.medium) { + // Smoothing slider + EditSlider( + title: String(localized: "Smoothing"), + value: $smoothingIntensity, + systemImage: "wand.and.stars" + ) + + // Glow effect slider + EditSlider( + title: String(localized: "Ring Glow"), + value: $glowIntensity, + systemImage: "light.max" + ) + + // Premium tools placeholder + if !isPremiumUnlocked { + premiumToolsTeaser + } + } + .padding(.horizontal, Design.Spacing.large) + .padding(.bottom, Design.Spacing.large) + } + .background(Color.Surface.overlay) + .navigationTitle(String(localized: "Edit")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(String(localized: "Cancel")) { + dismiss() + } + .foregroundStyle(.white) + } + + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "Done")) { + // Apply edits and dismiss + dismiss() + } + .foregroundStyle(Color.Accent.primary) + .bold() + } + } + } + } + + @ViewBuilder + private var mediaPreview: some View { + switch media { + case .photo(let image): + Image(uiImage: image) + .resizable() + .scaledToFit() + .clipShape(.rect(cornerRadius: Design.CornerRadius.medium)) + .padding(Design.Spacing.medium) + case .video, .boomerang: + // Video editing would show a frame or thumbnail + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(.gray.opacity(Design.Opacity.medium)) + .overlay { + Image(systemName: "video.fill") + .font(.largeTitle) + .foregroundStyle(.white) + } + .padding(Design.Spacing.medium) + } + } + + private var premiumToolsTeaser: some View { + HStack(spacing: Design.Spacing.small) { + Image(systemName: "crown.fill") + .foregroundStyle(Color.Status.warning) + + Text(String(localized: "Unlock filters, AI remove, and more with Pro")) + .font(.system(size: Design.BaseFontSize.caption)) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.small) + .fill(Color.Accent.primary.opacity(Design.Opacity.subtle)) + ) + } +} + +// MARK: - Edit Slider + +private struct EditSlider: View { + let title: String + @Binding var value: Double + let systemImage: String + + var body: some View { + VStack(alignment: .leading, spacing: Design.Spacing.xSmall) { + HStack { + Image(systemName: systemImage) + .foregroundStyle(.white.opacity(Design.Opacity.strong)) + + Text(title) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + + Spacer() + + Text("\(Int(value * 100))%") + .font(.system(size: Design.BaseFontSize.caption, design: .rounded)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + + Slider(value: $value, in: 0...1, step: 0.05) + .tint(Color.Accent.primary) + } + } +} + +#Preview { + PostCapturePreviewView( + media: .photo(UIImage(systemName: "photo")!), + isPremiumUnlocked: false, + onRetake: {}, + onSave: {}, + onShare: {}, + onDismiss: {} + ) +} diff --git a/SelfieRingLight/Features/Settings/SettingsView.swift b/SelfieRingLight/Features/Settings/SettingsView.swift index 248e21b..7df3a72 100644 --- a/SelfieRingLight/Features/Settings/SettingsView.swift +++ b/SelfieRingLight/Features/Settings/SettingsView.swift @@ -58,6 +58,17 @@ struct SettingsView: View { // Timer Selection timerPicker + // MARK: - Capture Section + + SettingsSectionHeader(title: "Capture", systemImage: "photo.on.rectangle") + + SettingsToggle( + title: String(localized: "Auto-Save"), + subtitle: String(localized: "Automatically save captures to Photo Library"), + isOn: $viewModel.isAutoSaveEnabled + ) + .accessibilityHint(String(localized: "When enabled, photos and videos are saved immediately after capture")) + // MARK: - Sync Section SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud") diff --git a/SelfieRingLight/Features/Settings/SettingsViewModel.swift b/SelfieRingLight/Features/Settings/SettingsViewModel.swift index 6cbabd8..daf02e6 100644 --- a/SelfieRingLight/Features/Settings/SettingsViewModel.swift +++ b/SelfieRingLight/Features/Settings/SettingsViewModel.swift @@ -137,6 +137,12 @@ final class SettingsViewModel: RingLightConfigurable { set { updateSettings { $0.currentZoomFactor = newValue } } } + /// Whether captures are auto-saved to Photo Library + var isAutoSaveEnabled: Bool { + get { cloudSync.data.isAutoSaveEnabled } + set { updateSettings { $0.isAutoSaveEnabled = newValue } } + } + // MARK: - Computed Properties /// Convenience property for border width (same as ringSize) diff --git a/SelfieRingLight/Shared/Storage/SyncedSettings.swift b/SelfieRingLight/Shared/Storage/SyncedSettings.swift index a12f232..9db6158 100644 --- a/SelfieRingLight/Shared/Storage/SyncedSettings.swift +++ b/SelfieRingLight/Shared/Storage/SyncedSettings.swift @@ -59,6 +59,9 @@ struct SyncedSettings: PersistableData, Sendable { /// Selected capture mode raw value var selectedCaptureModeRaw: String = "photo" + /// Whether captures are auto-saved to Photo Library + var isAutoSaveEnabled: Bool = true + // MARK: - Computed Properties /// Ring size as CGFloat (convenience accessor) @@ -113,6 +116,7 @@ struct SyncedSettings: PersistableData, Sendable { case isGridVisible case currentZoomFactor case selectedCaptureModeRaw + case isAutoSaveEnabled } } @@ -129,6 +133,7 @@ extension SyncedSettings: Equatable { lhs.selectedTimerRaw == rhs.selectedTimerRaw && lhs.isGridVisible == rhs.isGridVisible && lhs.currentZoomFactor == rhs.currentZoomFactor && - lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw + lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw && + lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled } }