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
This commit is contained in:
Matt Bruce 2026-01-02 13:05:27 -06:00
parent 74e65829de
commit ef15a8c21a
6 changed files with 537 additions and 8 deletions

View File

@ -23,6 +23,15 @@ class CameraViewModel: NSObject {
/// Whether the preview should be hidden (for front flash effect) /// Whether the preview should be hidden (for front flash effect)
var isPreviewHidden = false 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 let settings = SettingsViewModel() // Shared config
// MARK: - Screen Brightness Handling // MARK: - Screen Brightness Handling
@ -151,29 +160,120 @@ class CameraViewModel: NSObject {
var canCapture: Bool { var canCapture: Bool {
captureSession?.isRunning == true && isPhotoLibraryAuthorized 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 { extension CameraViewModel: AVCapturePhotoCaptureDelegate {
nonisolated func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { 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 { PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil) PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
} }
Task { @MainActor in
UIAccessibility.post(notification: .announcement, argument: String(localized: "Photo captured"))
}
} }
} }
extension CameraViewModel: AVCaptureFileOutputRecordingDelegate { extension CameraViewModel: AVCaptureFileOutputRecordingDelegate {
nonisolated func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { 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 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")) 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 { extension CameraViewModel: AVCaptureVideoDataOutputSampleBufferDelegate {

View File

@ -6,6 +6,7 @@ struct ContentView: View {
@State private var premiumManager = PremiumManager() @State private var premiumManager = PremiumManager()
@State private var showPaywall = false @State private var showPaywall = false
@State private var showSettings = false @State private var showSettings = false
@State private var showShareSheet = false
// Direct reference to shared settings // Direct reference to shared settings
private var settings: SettingsViewModel { private var settings: SettingsViewModel {
@ -40,6 +41,11 @@ struct ContentView: View {
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil { if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
permissionDeniedView permissionDeniedView
} }
// MARK: - Toast Notification
if let message = viewModel.toastMessage {
toastView(message: message)
}
} }
} }
.ignoresSafeArea() .ignoresSafeArea()
@ -55,6 +61,29 @@ struct ContentView: View {
.sheet(isPresented: $showSettings) { .sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel.settings) 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 // MARK: - Ring Light Background
@ -309,6 +338,38 @@ struct ContentView: View {
return String(localized: "Capture boomerang") 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 { #Preview {

View File

@ -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: {}
)
}

View File

@ -58,6 +58,17 @@ struct SettingsView: View {
// Timer Selection // Timer Selection
timerPicker 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 // MARK: - Sync Section
SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud") SettingsSectionHeader(title: "iCloud Sync", systemImage: "icloud")

View File

@ -137,6 +137,12 @@ final class SettingsViewModel: RingLightConfigurable {
set { updateSettings { $0.currentZoomFactor = newValue } } 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 // MARK: - Computed Properties
/// Convenience property for border width (same as ringSize) /// Convenience property for border width (same as ringSize)

View File

@ -59,6 +59,9 @@ struct SyncedSettings: PersistableData, Sendable {
/// Selected capture mode raw value /// Selected capture mode raw value
var selectedCaptureModeRaw: String = "photo" var selectedCaptureModeRaw: String = "photo"
/// Whether captures are auto-saved to Photo Library
var isAutoSaveEnabled: Bool = true
// MARK: - Computed Properties // MARK: - Computed Properties
/// Ring size as CGFloat (convenience accessor) /// Ring size as CGFloat (convenience accessor)
@ -113,6 +116,7 @@ struct SyncedSettings: PersistableData, Sendable {
case isGridVisible case isGridVisible
case currentZoomFactor case currentZoomFactor
case selectedCaptureModeRaw case selectedCaptureModeRaw
case isAutoSaveEnabled
} }
} }
@ -129,6 +133,7 @@ extension SyncedSettings: Equatable {
lhs.selectedTimerRaw == rhs.selectedTimerRaw && lhs.selectedTimerRaw == rhs.selectedTimerRaw &&
lhs.isGridVisible == rhs.isGridVisible && lhs.isGridVisible == rhs.isGridVisible &&
lhs.currentZoomFactor == rhs.currentZoomFactor && lhs.currentZoomFactor == rhs.currentZoomFactor &&
lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw lhs.selectedCaptureModeRaw == rhs.selectedCaptureModeRaw &&
lhs.isAutoSaveEnabled == rhs.isAutoSaveEnabled
} }
} }