Compare commits
10 Commits
e628897bff
...
e824119dcb
| Author | SHA1 | Date | |
|---|---|---|---|
| e824119dcb | |||
| e4cb7fc9da | |||
| 9250a44a65 | |||
| 7e5c4a021f | |||
| 22586d6693 | |||
| 7bada93504 | |||
| f38e0bf22c | |||
| 72bac70ea1 | |||
| 442f5d6e1d | |||
| 6e1ce6d262 |
@ -9,8 +9,8 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C852F08306200DC03E1 /* RevenueCat */; };
|
||||
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */ = {isa = PBXBuildFile; productRef = EA766C872F08306200DC03E1 /* RevenueCatUI */; };
|
||||
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F012F08500000DC03E1 /* Bedrock */; };
|
||||
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA766F0F2F08600000DC03E1 /* MijickCamera */; };
|
||||
EA836AB22F0ACBCF00077F87 /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AB12F0ACBCF00077F87 /* Bedrock */; };
|
||||
EA836AB52F0ACBE600077F87 /* MijickCamera in Frameworks */ = {isa = PBXBuildFile; productRef = EA836AB42F0ACBE600077F87 /* MijickCamera */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -61,10 +61,10 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EA836AB52F0ACBE600077F87 /* MijickCamera in Frameworks */,
|
||||
EA836AB22F0ACBCF00077F87 /* Bedrock in Frameworks */,
|
||||
EA766C862F08306200DC03E1 /* RevenueCat in Frameworks */,
|
||||
EA766C882F08306200DC03E1 /* RevenueCatUI in Frameworks */,
|
||||
EA766F022F08500000DC03E1 /* Bedrock in Frameworks */,
|
||||
EA766F102F08600000DC03E1 /* MijickCamera in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@ -137,8 +137,8 @@
|
||||
packageProductDependencies = (
|
||||
EA766C852F08306200DC03E1 /* RevenueCat */,
|
||||
EA766C872F08306200DC03E1 /* RevenueCatUI */,
|
||||
EA766F012F08500000DC03E1 /* Bedrock */,
|
||||
EA766F0F2F08600000DC03E1 /* MijickCamera */,
|
||||
EA836AB12F0ACBCF00077F87 /* Bedrock */,
|
||||
EA836AB42F0ACBE600077F87 /* MijickCamera */,
|
||||
);
|
||||
productName = SelfieRingLight;
|
||||
productReference = EA766C2C2F082A8400DC03E1 /* SelfieRingLight.app */;
|
||||
@ -224,8 +224,8 @@
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */,
|
||||
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */,
|
||||
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */,
|
||||
EA836AB02F0ACBCF00077F87 /* XCRemoteSwiftPackageReference "Bedrock" */,
|
||||
EA836AB32F0ACBE600077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = EA766C2D2F082A8400DC03E1 /* Products */;
|
||||
@ -634,20 +634,20 @@
|
||||
minimumVersion = 5.52.1;
|
||||
};
|
||||
};
|
||||
EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */ = {
|
||||
EA836AB02F0ACBCF00077F87 /* XCRemoteSwiftPackageReference "Bedrock" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "ssh://git@192.168.1.128:220/mbrucedogs/Bedrock.git";
|
||||
repositoryURL = "http://192.168.1.128:3000/mbrucedogs/Bedrock";
|
||||
requirement = {
|
||||
branch = develop;
|
||||
branch = master;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */ = {
|
||||
EA836AB32F0ACBE600077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Mijick/Camera";
|
||||
repositoryURL = "http://192.168.1.128:3000/mbrucedogs/MijickCamera";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 3.0.0;
|
||||
branch = develop;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@ -663,14 +663,14 @@
|
||||
package = EA766C822F08306200DC03E1 /* XCRemoteSwiftPackageReference "purchases-ios-spm" */;
|
||||
productName = RevenueCatUI;
|
||||
};
|
||||
EA766F012F08500000DC03E1 /* Bedrock */ = {
|
||||
EA836AB12F0ACBCF00077F87 /* Bedrock */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EA766F002F08500000DC03E1 /* XCRemoteSwiftPackageReference "Bedrock" */;
|
||||
package = EA836AB02F0ACBCF00077F87 /* XCRemoteSwiftPackageReference "Bedrock" */;
|
||||
productName = Bedrock;
|
||||
};
|
||||
EA766F0F2F08600000DC03E1 /* MijickCamera */ = {
|
||||
EA836AB42F0ACBE600077F87 /* MijickCamera */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = EA766F0E2F08600000DC03E1 /* XCRemoteSwiftPackageReference "Camera" */;
|
||||
package = EA836AB32F0ACBE600077F87 /* XCRemoteSwiftPackageReference "MijickCamera" */;
|
||||
productName = MijickCamera;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
@ -1,287 +1,104 @@
|
||||
import SwiftUI
|
||||
import MijickCamera
|
||||
import Bedrock
|
||||
import Photos
|
||||
import AVFoundation
|
||||
|
||||
/// Main camera view with ring light effect using MijickCamera
|
||||
struct ContentView: View {
|
||||
@State private var settings = SettingsViewModel()
|
||||
@State private var premiumManager = PremiumManager()
|
||||
@State private var showPaywall = false
|
||||
@State private var showSettings = false
|
||||
@State private var toastMessage: String?
|
||||
@State private var showPaywall = false
|
||||
|
||||
// Captured media for post-capture preview
|
||||
// Post-capture state
|
||||
@State private var capturedImage: UIImage?
|
||||
@State private var capturedVideoURL: URL?
|
||||
@State private var showPostCapturePreview = false
|
||||
@State private var showPostCapture = false
|
||||
|
||||
// Center Stage support
|
||||
@State private var isCenterStageAvailable = false
|
||||
@State private var isCenterStageEnabled = false
|
||||
/// 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)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let maxRingSize = calculateMaxRingSize(for: geometry)
|
||||
let effectiveRingSize = min(settings.ringSize, maxRingSize)
|
||||
ZStack {
|
||||
// Ring light background
|
||||
settings.lightColor
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Use MCamera as the base, with ring light as border
|
||||
// MijickCamera with default UI
|
||||
MCamera()
|
||||
.setCameraPosition(.front) // Default to front camera for selfies
|
||||
.onImageCaptured { image, _ in
|
||||
handleImageCaptured(image)
|
||||
capturedImage = image
|
||||
showPostCapture = true
|
||||
}
|
||||
.onVideoCaptured { url, _ in
|
||||
handleVideoCaptured(url)
|
||||
capturedVideoURL = url
|
||||
showPostCapture = true
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
// Ring light border overlay
|
||||
ringLightOverlay(ringSize: effectiveRingSize)
|
||||
}
|
||||
.overlay {
|
||||
// Grid overlay
|
||||
if settings.isGridVisible {
|
||||
GridOverlay(isVisible: true)
|
||||
.padding(effectiveRingSize)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
// Top controls
|
||||
VStack {
|
||||
topControlBar
|
||||
.padding(.top, effectiveRingSize + Design.Spacing.small)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, effectiveRingSize + Design.Spacing.small)
|
||||
}
|
||||
.overlay {
|
||||
// Toast
|
||||
if let message = toastMessage {
|
||||
toastView(message: message)
|
||||
}
|
||||
}
|
||||
.onChange(of: geometry.size) { _, newSize in
|
||||
let newMax = min(newSize.width, newSize.height) / 4
|
||||
if settings.ringSize > newMax {
|
||||
settings.ringSize = newMax
|
||||
.startSession()
|
||||
.padding(.horizontal, effectiveRingSize)
|
||||
.padding(.top, effectiveRingSize)
|
||||
.padding(.bottom, effectiveRingSize)
|
||||
|
||||
// Settings button overlay (top right corner of camera area)
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
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, effectiveRingSize + Design.Spacing.medium)
|
||||
.padding(.top, effectiveRingSize + Design.Spacing.medium)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
checkCenterStageAvailability()
|
||||
.ignoresSafeArea()
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||
}
|
||||
.sheet(isPresented: $showPaywall) {
|
||||
ProPaywallView()
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(viewModel: settings, showPaywall: $showPaywall)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showPostCapturePreview) {
|
||||
postCaptureView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Max Ring Size Calculation
|
||||
|
||||
private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat {
|
||||
min(geometry.size.width, geometry.size.height) / 4
|
||||
}
|
||||
|
||||
// MARK: - Ring Light Overlay
|
||||
|
||||
/// Creates a ring light effect as a border around the camera
|
||||
@ViewBuilder
|
||||
private func ringLightOverlay(ringSize: CGFloat) -> some View {
|
||||
// Use a rectangle with a large inner cutout to create the ring effect
|
||||
GeometryReader { geo in
|
||||
let innerRect = CGRect(
|
||||
x: ringSize,
|
||||
y: ringSize,
|
||||
width: geo.size.width - (ringSize * 2),
|
||||
height: geo.size.height - (ringSize * 2)
|
||||
)
|
||||
|
||||
settings.lightColor
|
||||
.mask(
|
||||
Rectangle()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Design.CornerRadius.large)
|
||||
.frame(width: innerRect.width, height: innerRect.height)
|
||||
.blendMode(.destinationOut)
|
||||
)
|
||||
.compositingGroup()
|
||||
)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
||||
}
|
||||
|
||||
// MARK: - Top Control Bar
|
||||
|
||||
private var topControlBar: some View {
|
||||
HStack {
|
||||
// Center Stage button (only shown when available)
|
||||
if isCenterStageAvailable {
|
||||
Button {
|
||||
toggleCenterStage()
|
||||
} label: {
|
||||
Image(systemName: isCenterStageEnabled ? "person.crop.rectangle.fill" : "person.crop.rectangle")
|
||||
.font(.body)
|
||||
.foregroundStyle(isCenterStageEnabled ? .yellow : .white)
|
||||
.padding(Design.Spacing.small)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.fullScreenCover(isPresented: $showPostCapture) {
|
||||
PostCapturePreviewView(
|
||||
capturedImage: capturedImage,
|
||||
capturedVideoURL: capturedVideoURL,
|
||||
isAutoSaveEnabled: settings.isAutoSaveEnabled,
|
||||
onRetake: {
|
||||
capturedImage = nil
|
||||
capturedVideoURL = nil
|
||||
showPostCapture = false
|
||||
},
|
||||
onSave: {
|
||||
saveCapture()
|
||||
showPostCapture = false
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Center Stage"))
|
||||
.accessibilityValue(isCenterStageEnabled ? "On" : "Off")
|
||||
.accessibilityHint(String(localized: "Keeps you centered in frame"))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Grid toggle
|
||||
Button {
|
||||
settings.isGridVisible.toggle()
|
||||
} label: {
|
||||
Image(systemName: "square.grid.3x3")
|
||||
.font(.body)
|
||||
.foregroundStyle(settings.isGridVisible ? .yellow : .white)
|
||||
.padding(Design.Spacing.small)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Toggle grid"))
|
||||
.accessibilityValue(settings.isGridVisible ? "On" : "Off")
|
||||
|
||||
// Settings button
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(Design.Spacing.small)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Settings"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Center Stage
|
||||
// MARK: - Save Capture
|
||||
|
||||
private func checkCenterStageAvailability() {
|
||||
guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else {
|
||||
isCenterStageAvailable = false
|
||||
return
|
||||
}
|
||||
|
||||
isCenterStageAvailable = device.activeFormat.isCenterStageSupported
|
||||
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
||||
}
|
||||
|
||||
private func toggleCenterStage() {
|
||||
AVCaptureDevice.centerStageControlMode = .app
|
||||
AVCaptureDevice.isCenterStageEnabled.toggle()
|
||||
isCenterStageEnabled = AVCaptureDevice.isCenterStageEnabled
|
||||
}
|
||||
|
||||
// MARK: - Post Capture View
|
||||
|
||||
@ViewBuilder
|
||||
private var postCaptureView: some View {
|
||||
private func saveCapture() {
|
||||
if let image = capturedImage {
|
||||
PostCapturePreviewView(
|
||||
media: .photo(image),
|
||||
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
|
||||
onRetake: { dismissPostCapture() },
|
||||
onSave: { saveImage(image) },
|
||||
onShare: {},
|
||||
onDismiss: { dismissPostCapture() }
|
||||
)
|
||||
} else if let url = capturedVideoURL {
|
||||
PostCapturePreviewView(
|
||||
media: .video(url),
|
||||
isPremiumUnlocked: premiumManager.isPremiumUnlocked,
|
||||
onRetake: { dismissPostCapture() },
|
||||
onSave: { saveVideo(url) },
|
||||
onShare: {},
|
||||
onDismiss: { dismissPostCapture() }
|
||||
)
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.spring(duration: Design.Animation.quick), value: message)
|
||||
}
|
||||
.accessibilityLabel(message)
|
||||
}
|
||||
|
||||
// MARK: - Capture Handlers
|
||||
|
||||
private func handleImageCaptured(_ image: UIImage) {
|
||||
capturedImage = image
|
||||
|
||||
if settings.isAutoSaveEnabled {
|
||||
saveImage(image)
|
||||
showToast(String(localized: "Saved to Photos"))
|
||||
}
|
||||
|
||||
showPostCapturePreview = true
|
||||
}
|
||||
|
||||
private func handleVideoCaptured(_ url: URL) {
|
||||
capturedVideoURL = url
|
||||
|
||||
if settings.isAutoSaveEnabled {
|
||||
saveVideo(url)
|
||||
showToast(String(localized: "Saved to Photos"))
|
||||
}
|
||||
|
||||
showPostCapturePreview = true
|
||||
}
|
||||
|
||||
private func dismissPostCapture() {
|
||||
showPostCapturePreview = false
|
||||
capturedImage = nil
|
||||
capturedVideoURL = nil
|
||||
}
|
||||
|
||||
private func saveImage(_ image: UIImage) {
|
||||
guard let data = image.jpegData(compressionQuality: 0.9) else { return }
|
||||
|
||||
PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveVideo(_ url: URL) {
|
||||
PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: url, options: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
toastMessage = message
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
if toastMessage == message {
|
||||
toastMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@ -2,35 +2,19 @@ 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 capturedImage: UIImage?
|
||||
let capturedVideoURL: URL?
|
||||
let isAutoSaveEnabled: Bool
|
||||
let onRetake: () -> Void
|
||||
let onSave: () -> Void
|
||||
let onShare: () -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@State private var showEditSheet = false
|
||||
@State private var player: AVPlayer?
|
||||
@State private var showShareSheet = false
|
||||
@State private var toastMessage: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -50,18 +34,27 @@ struct PostCapturePreviewView: View {
|
||||
// Bottom toolbar
|
||||
bottomToolbar
|
||||
}
|
||||
|
||||
// Toast notification
|
||||
if let message = toastMessage {
|
||||
toastView(message: message)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
setupVideoPlayerIfNeeded()
|
||||
if isAutoSaveEnabled {
|
||||
autoSave()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
}
|
||||
.sheet(isPresented: $showEditSheet) {
|
||||
PostCaptureEditView(
|
||||
media: media,
|
||||
isPremiumUnlocked: isPremiumUnlocked
|
||||
)
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let image = capturedImage {
|
||||
ShareSheet(items: [image])
|
||||
} else if let url = capturedVideoURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,28 +62,20 @@ struct PostCapturePreviewView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaPreview: some View {
|
||||
switch media {
|
||||
case .photo(let image):
|
||||
if let image = capturedImage {
|
||||
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)
|
||||
}
|
||||
} else if let _ = capturedVideoURL, let player {
|
||||
VideoPlayer(player: player)
|
||||
.onAppear {
|
||||
player.play()
|
||||
}
|
||||
.accessibilityLabel(String(localized: "Captured video"))
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,7 +84,7 @@ struct PostCapturePreviewView: View {
|
||||
private var topBar: some View {
|
||||
HStack {
|
||||
Button {
|
||||
onDismiss()
|
||||
onRetake()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.title2)
|
||||
@ -128,20 +113,25 @@ struct PostCapturePreviewView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Edit button
|
||||
ToolbarButton(
|
||||
title: String(localized: "Edit"),
|
||||
systemImage: "slider.horizontal.3",
|
||||
action: { showEditSheet = true }
|
||||
)
|
||||
|
||||
Spacer()
|
||||
// Save button (if not auto-saved)
|
||||
if !isAutoSaveEnabled {
|
||||
ToolbarButton(
|
||||
title: String(localized: "Save"),
|
||||
systemImage: "square.and.arrow.down",
|
||||
action: {
|
||||
onSave()
|
||||
showToast(String(localized: "Saved to Photos"))
|
||||
}
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Share button
|
||||
ToolbarButton(
|
||||
title: String(localized: "Share"),
|
||||
systemImage: "square.and.arrow.up",
|
||||
action: onShare
|
||||
action: { showShareSheet = true }
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, Design.Spacing.xxLarge)
|
||||
@ -149,26 +139,51 @@ struct PostCapturePreviewView: View {
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
// 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, 100)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.easeInOut, value: toastMessage)
|
||||
}
|
||||
|
||||
// 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()
|
||||
guard let url = capturedVideoURL else { return }
|
||||
player = AVPlayer(url: url)
|
||||
}
|
||||
|
||||
// MARK: - Auto Save
|
||||
|
||||
private func autoSave() {
|
||||
if let image = capturedImage {
|
||||
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
|
||||
showToast(String(localized: "Saved to Photos"))
|
||||
}
|
||||
// Video saving would go here
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
withAnimation {
|
||||
toastMessage = message
|
||||
}
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
withAnimation {
|
||||
toastMessage = nil
|
||||
}
|
||||
self.player = player
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -194,153 +209,24 @@ private struct ToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Post Capture Edit View
|
||||
// MARK: - Share Sheet
|
||||
|
||||
/// Lightweight editor for captured media
|
||||
struct PostCaptureEditView: View {
|
||||
let media: CapturedMedia
|
||||
let isPremiumUnlocked: Bool
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PostCapturePreviewView(
|
||||
media: .photo(UIImage(systemName: "photo")!),
|
||||
isPremiumUnlocked: false,
|
||||
capturedImage: UIImage(systemName: "photo"),
|
||||
capturedVideoURL: nil,
|
||||
isAutoSaveEnabled: false,
|
||||
onRetake: {},
|
||||
onSave: {},
|
||||
onShare: {},
|
||||
onDismiss: {}
|
||||
onSave: {}
|
||||
)
|
||||
}
|
||||
|
||||
@ -372,8 +372,8 @@ struct LicensesView: View {
|
||||
licenseCard(
|
||||
name: "MijickCamera",
|
||||
url: "https://github.com/Mijick/Camera",
|
||||
license: "Apache License 2.0",
|
||||
description: "Camera made simple. The ultimate camera library that significantly reduces implementation time and effort."
|
||||
license: "Apache 2.0 License",
|
||||
description: "Camera framework for SwiftUI. Created by Tomasz Kurylik at Mijick."
|
||||
)
|
||||
|
||||
// RevenueCat
|
||||
|
||||
@ -5,10 +5,6 @@
|
||||
"comment" : "The value of the ring size slider, displayed in parentheses.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%lld%%" : {
|
||||
"comment" : "A text label displaying the current brightness percentage.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%lldpt" : {
|
||||
"comment" : "A label displaying the current ring size, formatted as a number followed by the unit \"pt\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -65,10 +61,6 @@
|
||||
"comment" : "The text for a button that dismisses the current view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Captured boomerang" : {
|
||||
"comment" : "A label describing a captured boomerang.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Captured photo" : {
|
||||
"comment" : "A label describing a captured photo.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -77,10 +69,6 @@
|
||||
"comment" : "A label describing a captured video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Center Stage" : {
|
||||
"comment" : "A button that toggles whether the user is centered in the video feed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Close preview" : {
|
||||
"comment" : "A button label that closes the preview screen.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -113,10 +101,6 @@
|
||||
"comment" : "The text for a button that dismisses a view. In this case, it dismisses the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Edit" : {
|
||||
"comment" : "Label for the button that allows the user to edit their captured photo or video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Front Flash" : {
|
||||
"comment" : "Title of a toggle in the Settings view that controls whether the front flash is enabled.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -137,10 +121,6 @@
|
||||
"comment" : "Name of a ring light color preset.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Keeps you centered in frame" : {
|
||||
"comment" : "A hint that explains the purpose of the \"Center Stage\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Last synced %@" : {
|
||||
|
||||
},
|
||||
@ -160,10 +140,6 @@
|
||||
"comment" : "The accessibility value for the grid toggle when it is off.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"On" : {
|
||||
"comment" : "A label describing a setting that is currently enabled.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open Source Licenses" : {
|
||||
"comment" : "A heading displayed above a list of open source licenses used in the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -199,10 +175,6 @@
|
||||
"comment" : "Title for a button that allows the user to retake a captured photo or video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ring Glow" : {
|
||||
"comment" : "Title of a slider that controls the intensity of the ring glow effect in the captured media.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Ring size" : {
|
||||
"comment" : "An accessibility label for the ring size slider in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -211,6 +183,10 @@
|
||||
"comment" : "The label for the ring size slider in the settings view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Save" : {
|
||||
"comment" : "Title for a button that saves the currently captured photo or video to the user's photo library.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Saved to Photos" : {
|
||||
"comment" : "Text shown as a toast message when a photo is successfully saved to Photos.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -250,9 +226,6 @@
|
||||
"Skin Smoothing" : {
|
||||
"comment" : "A toggle that enables or disables real-time skin smoothing.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Smoothing" : {
|
||||
|
||||
},
|
||||
"Soft Pink" : {
|
||||
"comment" : "Name of a ring light color preset.",
|
||||
@ -293,10 +266,6 @@
|
||||
"comment" : "A description of the third-party libraries used in this app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Toggle grid" : {
|
||||
"comment" : "A button that toggles the visibility of the grid in the camera view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"True Mirror" : {
|
||||
"comment" : "Title of a toggle in the settings view that allows the user to flip the camera preview.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@ -305,10 +274,6 @@
|
||||
"comment" : "Description of a benefit that comes with the Pro subscription, specifically related to the boomerang tool.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unlock filters, AI remove, and more with Pro" : {
|
||||
"comment" : "A teaser text that appears below the capture edit view, promoting a premium feature.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Unlock premium colors, video, and more" : {
|
||||
"comment" : "A description of the benefits of upgrading to the Pro version of the app.",
|
||||
"isCommentAutoGenerated" : true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user