SelfieRingLight/SelfieRingLight/Features/Camera/ContentView.swift
Matt Bruce bf5853d999 Fix UI issues: full-screen preview, ring size limits, cleaner layout
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
2026-01-02 13:23:17 -06:00

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()
}