Changes: 1. Camera preview now fills available space (not forced square) - Maintains proper aspect ratio for captured photos - Controls overlay on top of preview 2. Ring size now limited based on screen dimensions - Maximum is 1/4 of smaller screen dimension - Prevents content from shifting off-screen 3. Removed light intensity slider - Was causing color changes (opacity approach) - Ring light now always at full brightness 4. Removed crown icon from main screen - Pro upgrade moved to Settings > Pro section - Cleaner camera interface 5. Smaller top icons - Grid and settings buttons use .body font - Less visual clutter
374 lines
13 KiB
Swift
374 lines
13 KiB
Swift
import SwiftUI
|
|
import Bedrock
|
|
|
|
struct ContentView: View {
|
|
@State private var viewModel = CameraViewModel()
|
|
@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 {
|
|
viewModel.settings
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
let maxRingSize = calculateMaxRingSize(for: geometry)
|
|
let effectiveRingSize = min(settings.ringSize, maxRingSize)
|
|
|
|
ZStack {
|
|
// MARK: - Ring Light Background
|
|
ringLightBackground
|
|
|
|
// MARK: - Camera Preview (full screen with ring border)
|
|
cameraPreviewArea(ringSize: effectiveRingSize)
|
|
|
|
// MARK: - Grid Overlay
|
|
if settings.isGridVisible && !viewModel.isPreviewHidden {
|
|
GridOverlay(isVisible: true)
|
|
.padding(effectiveRingSize)
|
|
}
|
|
|
|
// MARK: - Controls Overlay (on top of preview)
|
|
controlsOverlay(ringSize: effectiveRingSize)
|
|
|
|
// MARK: - Permission Denied View
|
|
if !viewModel.isCameraAuthorized && viewModel.captureSession != nil {
|
|
permissionDeniedView
|
|
}
|
|
|
|
// MARK: - Toast Notification
|
|
if let message = viewModel.toastMessage {
|
|
toastView(message: message)
|
|
}
|
|
}
|
|
.onChange(of: geometry.size) { _, newSize in
|
|
// Update max ring size when screen size changes
|
|
let newMax = min(newSize.width, newSize.height) / 4
|
|
if settings.ringSize > newMax {
|
|
settings.ringSize = newMax
|
|
}
|
|
}
|
|
}
|
|
.ignoresSafeArea()
|
|
.task {
|
|
await viewModel.setupCamera()
|
|
}
|
|
.onDisappear {
|
|
viewModel.restoreBrightness()
|
|
}
|
|
.sheet(isPresented: $showPaywall) {
|
|
ProPaywallView()
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView(viewModel: viewModel.settings, showPaywall: $showPaywall)
|
|
}
|
|
.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: - Max Ring Size Calculation
|
|
|
|
/// Calculates maximum ring size based on screen dimensions
|
|
/// Ring should not exceed 1/4 of the smaller dimension
|
|
private func calculateMaxRingSize(for geometry: GeometryProxy) -> CGFloat {
|
|
min(geometry.size.width, geometry.size.height) / 4
|
|
}
|
|
|
|
// MARK: - Ring Light Background
|
|
|
|
@ViewBuilder
|
|
private var ringLightBackground: some View {
|
|
let baseColor = premiumManager.isPremiumUnlocked ? settings.lightColor : Color.RingLight.pureWhite
|
|
|
|
baseColor
|
|
.ignoresSafeArea()
|
|
}
|
|
|
|
// MARK: - Camera Preview Area
|
|
|
|
@ViewBuilder
|
|
private func cameraPreviewArea(ringSize: CGFloat) -> some View {
|
|
if viewModel.isCameraAuthorized {
|
|
// Show preview unless front flash is active
|
|
if !viewModel.isPreviewHidden {
|
|
CameraPreview(
|
|
viewModel: viewModel,
|
|
isMirrorFlipped: settings.isMirrorFlipped,
|
|
zoomFactor: settings.currentZoomFactor
|
|
)
|
|
.padding(ringSize)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
|
}
|
|
} else {
|
|
// Show placeholder while requesting permission
|
|
Rectangle()
|
|
.fill(.black)
|
|
.padding(ringSize)
|
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.large))
|
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
|
.overlay {
|
|
if viewModel.captureSession == nil {
|
|
ProgressView()
|
|
.tint(.white)
|
|
.scaleEffect(1.5)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Controls Overlay
|
|
|
|
private func controlsOverlay(ringSize: CGFloat) -> some View {
|
|
VStack {
|
|
// Top bar
|
|
topControlBar
|
|
.padding(.top, ringSize + Design.Spacing.small)
|
|
|
|
Spacer()
|
|
|
|
// Bottom capture controls
|
|
bottomControlBar
|
|
.padding(.bottom, ringSize + Design.Spacing.medium)
|
|
}
|
|
.padding(.horizontal, ringSize + Design.Spacing.small)
|
|
.animation(.easeInOut(duration: Design.Animation.quick), value: ringSize)
|
|
}
|
|
|
|
// MARK: - Top Control Bar
|
|
|
|
private var topControlBar: some View {
|
|
HStack {
|
|
Spacer()
|
|
|
|
// Grid toggle
|
|
Button {
|
|
viewModel.settings.isGridVisible.toggle()
|
|
} label: {
|
|
Image(systemName: "square.grid.3x3")
|
|
.font(.body)
|
|
.foregroundStyle(viewModel.settings.isGridVisible ? .yellow : .white)
|
|
.padding(Design.Spacing.small)
|
|
.background(.ultraThinMaterial, in: .circle)
|
|
}
|
|
.accessibilityLabel(String(localized: "Toggle grid"))
|
|
.accessibilityValue(viewModel.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: - Bottom Control Bar
|
|
|
|
private var bottomControlBar: some View {
|
|
HStack {
|
|
// Switch camera button
|
|
Button {
|
|
viewModel.switchCamera()
|
|
} label: {
|
|
Image(systemName: "camera.rotate.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(.white)
|
|
.padding(Design.Spacing.medium)
|
|
.background(.ultraThinMaterial, in: .circle)
|
|
}
|
|
.accessibilityLabel(String(localized: "Switch camera"))
|
|
|
|
Spacer()
|
|
|
|
// Capture button
|
|
captureButton
|
|
|
|
Spacer()
|
|
|
|
// Capture mode selector
|
|
captureModeMenu
|
|
}
|
|
}
|
|
|
|
// MARK: - Capture Button
|
|
|
|
private var captureButton: some View {
|
|
Button {
|
|
captureAction()
|
|
} label: {
|
|
ZStack {
|
|
Circle()
|
|
.fill(.white)
|
|
.frame(width: Design.Capture.buttonSize, height: Design.Capture.buttonSize)
|
|
|
|
Circle()
|
|
.stroke(.white, lineWidth: Design.LineWidth.thick)
|
|
.frame(width: Design.Capture.buttonSize + Design.Spacing.small, height: Design.Capture.buttonSize + Design.Spacing.small)
|
|
|
|
// Show red stop square when recording
|
|
if viewModel.isRecording {
|
|
RoundedRectangle(cornerRadius: Design.CornerRadius.xSmall)
|
|
.fill(.red)
|
|
.frame(width: Design.Capture.stopSquare, height: Design.Capture.stopSquare)
|
|
}
|
|
}
|
|
}
|
|
.accessibilityLabel(captureButtonLabel)
|
|
.disabled(!viewModel.canCapture)
|
|
}
|
|
|
|
// MARK: - Capture Mode Menu
|
|
|
|
private var captureModeMenu: some View {
|
|
Menu {
|
|
ForEach(CaptureMode.allCases) { mode in
|
|
Button {
|
|
if !mode.isPremium || premiumManager.isPremiumUnlocked {
|
|
viewModel.settings.selectedCaptureMode = mode
|
|
} else {
|
|
showPaywall = true
|
|
}
|
|
} label: {
|
|
HStack {
|
|
Label(mode.displayName, systemImage: mode.systemImage)
|
|
if mode.isPremium && !premiumManager.isPremiumUnlocked {
|
|
Image(systemName: "crown.fill")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: viewModel.settings.selectedCaptureMode.systemImage)
|
|
.font(.title2)
|
|
.foregroundStyle(.white)
|
|
.padding(Design.Spacing.medium)
|
|
.background(.ultraThinMaterial, in: .circle)
|
|
}
|
|
.accessibilityLabel(String(localized: "Capture mode: \(viewModel.settings.selectedCaptureMode.displayName)"))
|
|
}
|
|
|
|
// MARK: - Permission Denied View
|
|
|
|
private var permissionDeniedView: some View {
|
|
VStack(spacing: Design.Spacing.large) {
|
|
Image(systemName: "camera.fill")
|
|
.font(.system(size: Design.BaseFontSize.hero))
|
|
.foregroundStyle(.white.opacity(Design.Opacity.medium))
|
|
|
|
Text("Camera Access Required")
|
|
.font(.title2.bold())
|
|
.foregroundStyle(.white)
|
|
|
|
Text("Please enable camera access in Settings to use SelfieRingLight.")
|
|
.font(.body)
|
|
.foregroundStyle(.white.opacity(Design.Opacity.strong))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, Design.Spacing.xLarge)
|
|
|
|
Button("Open Settings") {
|
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(.black.opacity(Design.Opacity.heavy))
|
|
}
|
|
|
|
// MARK: - Capture Action
|
|
|
|
private func captureAction() {
|
|
switch viewModel.settings.selectedCaptureMode {
|
|
case .photo:
|
|
viewModel.capturePhoto()
|
|
case .video:
|
|
if viewModel.isRecording {
|
|
viewModel.stopRecording()
|
|
} else {
|
|
viewModel.startRecording()
|
|
}
|
|
case .boomerang:
|
|
// TODO: Implement boomerang capture
|
|
viewModel.capturePhoto()
|
|
}
|
|
}
|
|
|
|
private var captureButtonLabel: String {
|
|
switch viewModel.settings.selectedCaptureMode {
|
|
case .photo:
|
|
return String(localized: "Take photo")
|
|
case .video:
|
|
return viewModel.isRecording ? String(localized: "Stop recording") : String(localized: "Start recording")
|
|
case .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)
|
|
.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 {
|
|
ContentView()
|
|
}
|