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:
parent
74e65829de
commit
ef15a8c21a
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
346
SelfieRingLight/Features/Camera/PostCapturePreviewView.swift
Normal file
346
SelfieRingLight/Features/Camera/PostCapturePreviewView.swift
Normal 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: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user