diff --git a/.gitignore b/.gitignore index cdc7016..8f7b7f4 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,5 @@ playwright/.cache/ .env .agents .claude + +film-photo-tracker/ diff --git a/Film Tracker.html b/Film Tracker.html new file mode 100644 index 0000000..5ae8298 --- /dev/null +++ b/Film Tracker.html @@ -0,0 +1,194 @@ + + + + + Film Tracker — Redesign + + + + +
+ + + + + + + + + + + + + + + + + + FILM TRACKER + +
+
Unpacking...
+ + + + + + + + + + \ No newline at end of file diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..2afaae1 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,56 @@ +# macOS +.DS_Store + +# Xcode +# +# gitignore.io results for: Xcode +build/ +DerivedData/ +*.xcodeproj +*.xcworkspace +!default.xcworkspace +xcuserdata/ +*.xccheckout +*.xcscmblueprint +*.xctestplan +*.moved-aside +DerivedData/ +*.hmap +*.ipa +*.xcuserstate +*.itunespackage + +# Swift Package Manager +# +# Add this line if you want to avoid committing the test-generated folders +.build/ +Packages/ +Package.resolved +Package.pins +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm/xcode/xcuserdata/ + +# XcodeGen +# +# Project file is generated from project.yml +# *.xcodeproj is already ignored above + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Bundle +vendor/bundle/ +.bundle/ + +# Backup files +*.bak +*.swp +*.swo + +# Crash reports +*.crash + +film-photo-tracker/ diff --git a/ios/FilmTracker/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/FilmTracker/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..900933c --- /dev/null +++ b/ios/FilmTracker/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,98 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "20x20", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "20x20", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "1x" + }, + { + "idiom" : "ipad", + "size" : "76x76", + "scale" : "2x" + }, + { + "idiom" : "ipad", + "size" : "83.5x83.5", + "scale" : "2x" + }, + { + "idiom" : "ios-marketing", + "size" : "1024x1024", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FilmTracker/App/Assets.xcassets/Contents.json b/ios/FilmTracker/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/FilmTracker/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/FilmTracker/App/ContentView.swift b/ios/FilmTracker/App/ContentView.swift new file mode 100644 index 0000000..19a3248 --- /dev/null +++ b/ios/FilmTracker/App/ContentView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct ContentView: View { + @State private var selectedTab: Tab = .rolls + + enum Tab { + case rolls + case equipment + } + + var body: some View { + TabView(selection: $selectedTab) { + NavigationStack { + RollsView() + } + .tabItem { + Label("Rolls", systemImage: "film") + } + .tag(Tab.rolls) + + NavigationStack { + EquipmentView() + } + .tabItem { + Label("Equipment", systemImage: "camera") + } + .tag(Tab.equipment) + } + .tint(Color(hex: Constants.Design.accent)) + } +} + +#Preview { + ContentView() + .modelContainer(for: [FilmRoll.self, Exposure.self, Camera.self, Lens.self], inMemory: true) +} diff --git a/ios/FilmTracker/App/FilmTrackerApp.swift b/ios/FilmTracker/App/FilmTrackerApp.swift new file mode 100644 index 0000000..eafeada --- /dev/null +++ b/ios/FilmTracker/App/FilmTrackerApp.swift @@ -0,0 +1,29 @@ +import SwiftUI +import SwiftData + +@main +struct FilmTrackerApp: App { + var sharedModelContainer: ModelContainer = { + let schema = Schema([ + Camera.self, + Lens.self, + FilmRoll.self, + Exposure.self, + AppSettings.self, + ]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) + + do { + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } catch { + fatalError("Could not create ModelContainer: \(error)") + } + }() + + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(sharedModelContainer) + } +} diff --git a/ios/FilmTracker/Components/AppButton.swift b/ios/FilmTracker/Components/AppButton.swift new file mode 100644 index 0000000..663194e --- /dev/null +++ b/ios/FilmTracker/Components/AppButton.swift @@ -0,0 +1,73 @@ +import SwiftUI + +struct AppButton: View { + enum Variant { + case primary + case secondary + case ghost + case destructive + } + + var title: String + var variant: Variant = .primary + var isDisabled: Bool = false + var action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.appLabel(16)) + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + .background(backgroundView) + .foregroundColor(foregroundColor) + .cornerRadius(Constants.Design.radiusMD) + .opacity(isDisabled ? 0.5 : 1.0) + } + .disabled(isDisabled) + .buttonStyle(ScaleButtonStyle()) + } + + @ViewBuilder + private var backgroundView: some View { + switch variant { + case .primary: + Color.accent + case .secondary: + Color.surface2 + case .ghost: + Color.clear + case .destructive: + Color.appRed + } + } + + private var foregroundColor: Color { + switch variant { + case .primary: + .black + case .secondary, .ghost: + .appText + case .destructive: + .white + } + } +} + +struct ScaleButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.96 : 1.0) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +#Preview { + VStack { + AppButton(title: "Primary Button") {} + AppButton(title: "Secondary Button", variant: .secondary) {} + AppButton(title: "Ghost Button", variant: .ghost) {} + } + .padding() + .background(Color.appBg) +} diff --git a/ios/FilmTracker/Components/AppCard.swift b/ios/FilmTracker/Components/AppCard.swift new file mode 100644 index 0000000..be31f24 --- /dev/null +++ b/ios/FilmTracker/Components/AppCard.swift @@ -0,0 +1,43 @@ +import SwiftUI + +struct AppCard: View { + var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ZStack { + Color.surface1 + + // Subtle grain placeholder + Color.black.opacity(0.05) + .blendMode(.overlay) + + content + } + .cornerRadius(Constants.Design.radiusLG) + .overlay( + RoundedRectangle(cornerRadius: Constants.Design.radiusLG) + .stroke(Color.white.opacity(0.1), lineWidth: 0.5) + ) + } +} + +#Preview { + AppCard { + VStack(alignment: .leading) { + Text("Kodak Portra 400") + .font(.appHeadline()) + .foregroundColor(.appText) + Text("36 exposures remaining") + .font(.appBody(14)) + .foregroundColor(.muted) + } + .padding() + } + .frame(height: 100) + .padding() + .background(Color.appBg) +} diff --git a/ios/FilmTracker/Components/AppChip.swift b/ios/FilmTracker/Components/AppChip.swift new file mode 100644 index 0000000..ad2c2fe --- /dev/null +++ b/ios/FilmTracker/Components/AppChip.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct AppChip: View { + enum Variant { + case standard + case accentGlow + case ghost + } + + var title: String + var variant: Variant = .standard + var isLarge: Bool = false + var isMono: Bool = false + var isSelected: Bool = false + var action: (() -> Void)? = nil + + var body: some View { + Button { + action?() + } label: { + Text(title) + .font(isMono ? .appMono(isLarge ? 16 : 14) : .appLabel(isLarge ? 14 : 12)) + .padding(.horizontal, isLarge ? 16 : 10) + .padding(.vertical, isLarge ? 8 : 4) + .background(backgroundView) + .foregroundColor(foregroundColor) + .cornerRadius(Constants.Design.radiusPill) + .overlay( + RoundedRectangle(cornerRadius: Constants.Design.radiusPill) + .stroke(borderColor, lineWidth: 1) + ) + } + .disabled(action == nil) + } + + @ViewBuilder + private var backgroundView: some View { + if isSelected { + Color.accent.opacity(0.15) + } else { + switch variant { + case .standard: + Color.surface2 + case .accentGlow: + Color.accent.opacity(0.15) + case .ghost: + Color.clear + } + } + } + + private var foregroundColor: Color { + if isSelected { + return .accent + } + switch variant { + case .standard: + return .appText + case .accentGlow: + return .accent + case .ghost: + return .muted + } + } + + private var borderColor: Color { + if isSelected { + return .accent.opacity(0.3) + } + switch variant { + case .standard: + return .clear + case .accentGlow: + return .accent.opacity(0.3) + case .ghost: + return .dim + } + } +} + +#Preview { + HStack { + AppChip(title: "ISO 400") + AppChip(title: "f/8", variant: .accentGlow) + AppChip(title: "1/125", variant: .ghost) + AppChip(title: "36 EXP", isLarge: true, isMono: true) + } + .padding() + .background(Color.appBg) +} diff --git a/ios/FilmTracker/Components/BottomSheet.swift b/ios/FilmTracker/Components/BottomSheet.swift new file mode 100644 index 0000000..895365c --- /dev/null +++ b/ios/FilmTracker/Components/BottomSheet.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct BottomSheet: View { + @Environment(\.dismiss) private var dismiss + @Binding var isPresented: Bool + var title: String? + var content: Content + + init(isPresented: Binding, title: String? = nil, @ViewBuilder content: () -> Content) { + self._isPresented = isPresented + self.title = title + self.content = content() + } + + var body: some View { + VStack(spacing: 0) { + // Grabber + Capsule() + .fill(Color.dim) + .frame(width: 36, height: 4) + .padding(.top, 8) + .padding(.bottom, 16) + + if let title = title { + HStack { + Spacer() + Text(title) + .font(.appHeadline(18)) + .foregroundColor(.appText) + Spacer() + } + .padding(.bottom, 20) + .overlay(alignment: .topLeading) { + Button { + isPresented = false + dismiss() + } label: { + Text("Cancel") + .font(.appBody(14)) + .foregroundColor(.accent) + } + .padding(.leading, 0) + .padding(.top, -10) + } + } + + content + .padding(.bottom, 34) // Safe area bottom + } + .padding(.horizontal, 20) + .background(Color.surface1) + .cornerRadius(Constants.Design.radiusXL, corners: [.topLeft, .topRight]) + } +} + +// Helper for rounded corners on specific sides +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +#Preview { + ZStack(alignment: .bottom) { + Color.black.ignoresSafeArea() + BottomSheet(isPresented: .constant(true), title: "Add New Roll") { + VStack(spacing: 12) { + AppButton(title: "Confirm") {} + AppButton(title: "Cancel", variant: .secondary) {} + } + } + } +} diff --git a/ios/FilmTracker/Components/ConfirmationSheet.swift b/ios/FilmTracker/Components/ConfirmationSheet.swift new file mode 100644 index 0000000..db2b171 --- /dev/null +++ b/ios/FilmTracker/Components/ConfirmationSheet.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct ConfirmationSheet: View { + var title: String + var message: String + var confirmTitle: String = "Delete" + var isDestructive: Bool = true + var onConfirm: () -> Void + var onCancel: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + Text(title) + .font(.appHeadline(20)) + .foregroundColor(.appText) + + Text(message) + .font(.appBody(15)) + .foregroundColor(.muted) + .multilineTextAlignment(.center) + } + + VStack(spacing: 12) { + AppButton( + title: confirmTitle, + variant: isDestructive ? .destructive : .primary, + action: onConfirm + ) + .accessibilityIdentifier("confirmationConfirmButton") + + AppButton( + title: "Cancel", + variant: .secondary, + action: onCancel + ) + .accessibilityIdentifier("confirmationCancelButton") + } + } + .padding(24) + .background(Color.surface1) + .cornerRadius(Constants.Design.radiusXL, corners: [.topLeft, .topRight]) + } +} + +#Preview { + ZStack(alignment: .bottom) { + Color.black.ignoresSafeArea() + ConfirmationSheet( + title: "Delete Roll?", + message: "This action cannot be undone. All exposures in this roll will be lost.", + onConfirm: {}, + onCancel: {} + ) + } +} diff --git a/ios/FilmTracker/Components/DocumentPicker.swift b/ios/FilmTracker/Components/DocumentPicker.swift new file mode 100644 index 0000000..49064aa --- /dev/null +++ b/ios/FilmTracker/Components/DocumentPicker.swift @@ -0,0 +1,35 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct DocumentPicker: UIViewControllerRepresentable { + var onPick: ([URL]) -> Void + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.json, .folder]) + picker.allowsMultipleSelection = false + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UIDocumentPickerDelegate { + var parent: DocumentPicker + + init(_ parent: DocumentPicker) { + self.parent = parent + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + parent.onPick(urls) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + // Cancelled + } + } +} diff --git a/ios/FilmTracker/Components/EmptyStateView.swift b/ios/FilmTracker/Components/EmptyStateView.swift new file mode 100644 index 0000000..9ee46bd --- /dev/null +++ b/ios/FilmTracker/Components/EmptyStateView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct EmptyStateView: View { + var iconName: String + var title: String + var bodyText: String + var actionTitle: String? + var action: (() -> Void)? + + var body: some View { + VStack(spacing: 20) { + // Icon container with dashed border + ZStack { + RoundedRectangle(cornerRadius: Constants.Design.radiusMD) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [6])) + .foregroundColor(.dim) + .frame(width: 88, height: 88) + + Image(systemName: iconName) + .font(.system(size: 32)) + .foregroundColor(.muted) + } + + VStack(spacing: 8) { + Text(title) + .font(.appHeadline(20)) + .foregroundColor(.appText) + + Text(bodyText) + .font(.appBody(15)) + .foregroundColor(.muted) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + if let actionTitle = actionTitle, let action = action { + Button { + action() + } label: { + Text(actionTitle) + .font(.appLabel(16)) + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + .background(Color.accent) + .foregroundColor(.black) + .cornerRadius(Constants.Design.radiusMD) + } + .frame(width: 200) + .padding(.top, 10) + } + } + .padding() + } +} + +#Preview { + EmptyStateView( + iconName: "film", + title: "No active rolls", + bodyText: "Start your first film roll to begin tracking your exposures.", + actionTitle: "Start New Roll", + action: {} + ) + .background(Color.appBg) +} diff --git a/ios/FilmTracker/Components/EntityRow.swift b/ios/FilmTracker/Components/EntityRow.swift new file mode 100644 index 0000000..148e28b --- /dev/null +++ b/ios/FilmTracker/Components/EntityRow.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct EntityRow: View { + var title: String + var subtitle: String + var iconName: String + var menuActions: AnyView? + + var body: some View { + HStack(spacing: 16) { + // Icon circle + ZStack { + Circle() + .fill(Color.accent) + .frame(width: 40, height: 40) + + Image(systemName: iconName) + .font(.system(size: 18)) + .foregroundColor(.black) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.appHeadline(16)) + .foregroundColor(.appText) + .accessibilityIdentifier("entityRowTitle") + + Text(subtitle) + .font(.appMono(12)) + .foregroundColor(.muted) + } + + Spacer() + + if let menuActions = menuActions { + Menu { + menuActions + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 20)) + .foregroundColor(.muted) + .padding(8) + } + .accessibilityIdentifier("entityRowMoreButton") + } + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(Color.surface1) + .cornerRadius(Constants.Design.radiusMD) + .overlay( + RoundedRectangle(cornerRadius: Constants.Design.radiusMD) + .stroke(Color.white.opacity(0.05), lineWidth: 0.5) + ) + } +} + +#Preview { + VStack(spacing: 12) { + EntityRow( + title: "Nikon FE", + subtitle: "35mm · SLR", + iconName: "camera", + menuActions: AnyView( + Group { + Button("Edit") {} + Button("Delete", role: .destructive) {} + } + ) + ) + + EntityRow( + title: "Nikkor 50mm f/1.4", + subtitle: "Prime · f/1.4", + iconName: "lens" + ) + } + .padding() + .background(Color.appBg) +} diff --git a/ios/FilmTracker/Components/ViewfinderOverlays.swift b/ios/FilmTracker/Components/ViewfinderOverlays.swift new file mode 100644 index 0000000..2aac729 --- /dev/null +++ b/ios/FilmTracker/Components/ViewfinderOverlays.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct GrainOverlay: View { + var body: some View { + Rectangle() + .fill(.black.opacity(0.08)) // Placeholder for real grain + .blendMode(.multiply) + .allowsHitTesting(false) + } +} + +struct VignetteOverlay: View { + var body: some View { + RadialGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.6)]), + center: .center, + startRadius: 100, + endRadius: 400 + ) + .allowsHitTesting(false) + } +} diff --git a/ios/FilmTracker/DesignSystem/Colors.swift b/ios/FilmTracker/DesignSystem/Colors.swift new file mode 100644 index 0000000..0d24fb0 --- /dev/null +++ b/ios/FilmTracker/DesignSystem/Colors.swift @@ -0,0 +1,65 @@ +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } + + static let appBg = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.bg)) : UIColor(Color(hex: "#f5f5f7")) + }) + + static let surface0 = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.surface0)) : UIColor(Color(hex: "#ffffff")) + }) + + static let surface1 = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.surface1)) : UIColor(Color(hex: "#f2f2f7")) + }) + + static let surface2 = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.surface2)) : UIColor(Color(hex: "#e5e5ea")) + }) + + static let surface3 = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.surface3)) : UIColor(Color(hex: "#d1d1d6")) + }) + + static let accent = Color(hex: Constants.Design.accent) + + static let appText = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.text)) : UIColor(Color(hex: "#1c1c1e")) + }) + + static let muted = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.muted)) : UIColor(Color(hex: "#8e8e93")) + }) + + static let dim = Color(UIColor { trait in + trait.userInterfaceStyle == .dark ? UIColor(Color(hex: Constants.Design.dim)) : UIColor(Color(hex: "#c7c7cc")) + }) + + static let appRed = Color(hex: Constants.Design.red) + static let appGreen = Color(hex: Constants.Design.green) +} diff --git a/ios/FilmTracker/DesignSystem/Constants.swift b/ios/FilmTracker/DesignSystem/Constants.swift new file mode 100644 index 0000000..a71bbd9 --- /dev/null +++ b/ios/FilmTracker/DesignSystem/Constants.swift @@ -0,0 +1,42 @@ +import Foundation + +enum Constants { + static let apertures = [ + "f/1.4", "f/2", "f/2.8", "f/3.5", "f/4", "f/4.5", "f/5.6", + "f/8", "f/11", "f/16", "f/22" + ] + + static let shutterSpeeds = [ + "1/4000", "1/2000", "1/1000", "1/500", "1/250", "1/125", + "1/60", "1/30", "1/15", "1/8", "1/4", "1/2", "1\"", "2\"", "4\"", "8\"", "BULB" + ] + + static let eiValues = [ + 25, 32, 40, 50, 64, 80, 100, 125, 160, 200, + 250, 320, 400, 500, 640, 800, 1000, 1250, 1600, + 2000, 2500, 3200, 4000, 5000, 6400 + ] + + static let focalPresets = [18, 24, 28, 35, 50, 85, 100, 135, 200] + + enum Design { + static let bg = "#0a0a0b" + static let surface0 = "#111113" + static let surface1 = "#17171a" + static let surface2 = "#1f1f23" + static let surface3 = "#2a2a30" + static let accent = "#f4a261" + static let text = "#f5f5f7" + static let muted = "#9a9aa3" + static let dim = "#5e5e68" + static let red = "#ff453a" + static let green = "#30d158" + + static let radiusXS: CGFloat = 4 + static let radiusSM: CGFloat = 6 + static let radiusMD: CGFloat = 10 + static let radiusLG: CGFloat = 16 + static let radiusXL: CGFloat = 22 + static let radiusPill: CGFloat = 999 + } +} diff --git a/ios/FilmTracker/DesignSystem/Typography.swift b/ios/FilmTracker/DesignSystem/Typography.swift new file mode 100644 index 0000000..f3e3d51 --- /dev/null +++ b/ios/FilmTracker/DesignSystem/Typography.swift @@ -0,0 +1,19 @@ +import SwiftUI + +extension Font { + static func appHeadline(_ size: CGFloat = 20) -> Font { + .system(size: size, weight: .bold, design: .default) + } + + static func appBody(_ size: CGFloat = 16) -> Font { + .system(size: size, weight: .regular, design: .default) + } + + static func appMono(_ size: CGFloat = 14) -> Font { + .system(size: size, weight: .medium, design: .monospaced) + } + + static func appLabel(_ size: CGFloat = 12) -> Font { + .system(size: size, weight: .semibold, design: .default) + } +} diff --git a/ios/FilmTracker/Models/AppSettings.swift b/ios/FilmTracker/Models/AppSettings.swift new file mode 100644 index 0000000..82d7881 --- /dev/null +++ b/ios/FilmTracker/Models/AppSettings.swift @@ -0,0 +1,19 @@ +import Foundation +import SwiftData + +@Model +final class AppSettings { + @Attribute(.unique) var id: String + var gridEnabled: Bool + var locationEnabled: Bool + var hapticsEnabled: Bool + var version: String + + init(id: String = "singleton", gridEnabled: Bool = true, locationEnabled: Bool = true, hapticsEnabled: Bool = true, version: String = "2.0.0") { + self.id = id + self.gridEnabled = gridEnabled + self.locationEnabled = locationEnabled + self.hapticsEnabled = hapticsEnabled + self.version = version + } +} diff --git a/ios/FilmTracker/Models/Camera.swift b/ios/FilmTracker/Models/Camera.swift new file mode 100644 index 0000000..c073e57 --- /dev/null +++ b/ios/FilmTracker/Models/Camera.swift @@ -0,0 +1,21 @@ +import Foundation +import SwiftData + +@Model +final class Camera { + @Attribute(.unique) var id: String + var make: String + var model: String + var name: String + var lensIDs: [String] = [] + var createdAt: Date + + init(id: String = UUID().uuidString, make: String, model: String, name: String? = nil, lensIDs: [String] = [], createdAt: Date = Date()) { + self.id = id + self.make = make + self.model = model + self.name = name ?? "\(make) \(model)" + self.lensIDs = lensIDs + self.createdAt = createdAt + } +} diff --git a/ios/FilmTracker/Models/Exposure.swift b/ios/FilmTracker/Models/Exposure.swift new file mode 100644 index 0000000..f00e541 --- /dev/null +++ b/ios/FilmTracker/Models/Exposure.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftData +import CoreLocation + +@Model +final class Exposure { + @Attribute(.unique) var id: String + var filmRollId: String + var exposureNumber: Int + var aperture: String + var shutterSpeed: String + var additionalInfo: String? + + @Attribute(.externalStorage) + var imageData: Data? + + var latitude: Double? + var longitude: Double? + var capturedAt: Date + var ei: Int? + var lensId: String? + var focalLength: Int? + + var location: CLLocationCoordinate2D? { + guard let latitude, let longitude else { return nil } + return CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } + + init( + id: String = UUID().uuidString, + filmRollId: String, + exposureNumber: Int, + aperture: String, + shutterSpeed: String, + additionalInfo: String? = nil, + imageData: Data? = nil, + latitude: Double? = nil, + longitude: Double? = nil, + capturedAt: Date = Date(), + ei: Int? = nil, + lensId: String? = nil, + focalLength: Int? = nil + ) { + self.id = id + self.filmRollId = filmRollId + self.exposureNumber = exposureNumber + self.aperture = aperture + self.shutterSpeed = shutterSpeed + self.additionalInfo = additionalInfo + self.imageData = imageData + self.latitude = latitude + self.longitude = longitude + self.capturedAt = capturedAt + self.ei = ei + self.lensId = lensId + self.focalLength = focalLength + } +} diff --git a/ios/FilmTracker/Models/FilmRoll.swift b/ios/FilmTracker/Models/FilmRoll.swift new file mode 100644 index 0000000..c9dbf28 --- /dev/null +++ b/ios/FilmTracker/Models/FilmRoll.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftData + +@Model +final class FilmRoll { + @Attribute(.unique) var id: String + var name: String + var iso: Int + var ei: Int? + var totalExposures: Int + var cameraId: String? + var currentLensId: String? + var createdAt: Date + var tag: String? + + init(id: String = UUID().uuidString, name: String, iso: Int, ei: Int? = nil, totalExposures: Int, cameraId: String? = nil, currentLensId: String? = nil, createdAt: Date = Date(), tag: String? = nil) { + self.id = id + self.name = name + self.iso = iso + self.ei = ei + self.totalExposures = totalExposures + self.cameraId = cameraId + self.currentLensId = currentLensId + self.createdAt = createdAt + self.tag = tag + } +} diff --git a/ios/FilmTracker/Models/Lens.swift b/ios/FilmTracker/Models/Lens.swift new file mode 100644 index 0000000..74ac568 --- /dev/null +++ b/ios/FilmTracker/Models/Lens.swift @@ -0,0 +1,27 @@ +import Foundation +import SwiftData + +@Model +final class Lens { + @Attribute(.unique) var id: String + var name: String + var maxAperture: String + var focalLength: Int? + var focalLengthMin: Int? + var focalLengthMax: Int? + var createdAt: Date + + var isZoom: Bool { + focalLengthMin != nil && focalLengthMax != nil + } + + init(id: String = UUID().uuidString, name: String, maxAperture: String, focalLength: Int? = nil, focalLengthMin: Int? = nil, focalLengthMax: Int? = nil, createdAt: Date = Date()) { + self.id = id + self.name = name + self.maxAperture = maxAperture + self.focalLength = focalLength + self.focalLengthMin = focalLengthMin + self.focalLengthMax = focalLengthMax + self.createdAt = createdAt + } +} diff --git a/ios/FilmTracker/Screens/Capture/CameraPreview.swift b/ios/FilmTracker/Screens/Capture/CameraPreview.swift new file mode 100644 index 0000000..e05f683 --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/CameraPreview.swift @@ -0,0 +1,25 @@ +import SwiftUI +import AVFoundation + +struct CameraPreview: UIViewRepresentable { + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.videoPreviewLayer.session = session + view.videoPreviewLayer.videoGravity = .resizeAspectFill + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) {} + + class PreviewView: UIView { + override class var layerClass: AnyClass { + return AVCaptureVideoPreviewLayer.self + } + + var videoPreviewLayer: AVCaptureVideoPreviewLayer { + return layer as! AVCaptureVideoPreviewLayer + } + } +} diff --git a/ios/FilmTracker/Screens/Capture/CaptureView.swift b/ios/FilmTracker/Screens/Capture/CaptureView.swift new file mode 100644 index 0000000..6f1e305 --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/CaptureView.swift @@ -0,0 +1,453 @@ +import SwiftUI +import SwiftData + +struct CaptureView: View { + enum ActivePicker { + case none + case aperture + case shutterSpeed + case ei + case focalLength + } + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: CaptureViewModel + @State private var showNoteSheet = false + @State private var showLensPicker = false + @State private var activePicker: ActivePicker = .none + @State private var cameraAuthorized: Bool = true + + @Binding var path: NavigationPath + + init(roll: FilmRoll, modelContext: ModelContext, path: Binding) { + _viewModel = State(initialValue: CaptureViewModel(roll: roll, modelContext: modelContext)) + _path = path + } + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + if cameraAuthorized { + // Viewfinder + CameraPreview(session: viewModel.cameraService.session) + .ignoresSafeArea() + .overlay { + if viewModel.showGrid { + GridView() + } + if viewModel.showFrameLines { + FrameLinesView() + } + } + .overlay { + if viewModel.currentFocalLength < 24 { + letterboxBars + } + } + .overlay { + VignetteOverlay() + GrainOverlay() + } + .onTapGesture { + withAnimation(.spring(response: 0.3)) { + activePicker = .none + } + } + + // UI Overlays + VStack { + topBar + + HStack { + LightMeterView(ev: viewModel.currentEV) + Spacer() + } + .padding(.top, 20) + + Spacer() + + if activePicker == .none { + FocalLengthOverlay(currentFocalLength: viewModel.currentFocalLength) + .padding(.bottom, 20) + } + + // Picker Overlay + if activePicker != .none { + pickerView + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(1) + } + + // Settings Chips Row + settingsChipsRow + + // Bottom Controls + bottomControls + } + .padding(.horizontal) + + // Frame Counter (floating below top bar) + VStack { + frameCounterPill + Spacer() + } + .padding(.top, 70) + + // Capture Flash + if viewModel.isCapturing { + Color.white + .ignoresSafeArea() + .transition(.opacity) + } + } else { + EmptyStateView( + iconName: "camera.fill", + title: "Camera Access Denied", + bodyText: "Please enable camera access in Settings to use the capture feature.", + actionTitle: "Open Settings" + ) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + } + } + .navigationBarHidden(true) + .preferredColorScheme(.dark) + .sheet(isPresented: $showNoteSheet) { + NoteSheet(note: $viewModel.pendingNote) + .presentationDetents([.height(300)]) + .presentationDragIndicator(.hidden) + } + .sheet(isPresented: $showLensPicker) { + LensPickerSheet(selectedLens: $viewModel.currentLens) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + } + .onAppear { + Task { + cameraAuthorized = await viewModel.cameraService.checkPermission() + if cameraAuthorized { + viewModel.cameraService.setupSession() + viewModel.cameraService.start() + } + } + viewModel.locationService.requestPermission() + viewModel.locationService.startUpdating() + } + .onDisappear { + viewModel.cameraService.stop() + viewModel.locationService.stopUpdating() + } + } + + private var letterboxBars: some View { + VStack(spacing: 0) { + Color.black.opacity(0.4) + .frame(height: 120) + Spacer() + Color.black.opacity(0.4) + .frame(height: 120) + } + .ignoresSafeArea() + } + + private var topBar: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.title2) + .foregroundColor(.white) + .padding(12) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + + // Lens Label Pill + Button { + showLensPicker = true + } label: { + HStack(spacing: 6) { + Image(systemName: "camera.filters") + Text(viewModel.currentLens?.name ?? "No lens") + .lineLimit(1) + } + .font(.custom("InterTight-Medium", size: 12)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + } + + Spacer() + + HStack(spacing: 12) { + NavigationLink(value: GalleryDestination(roll: viewModel.roll)) { + Image(systemName: "photo.on.rectangle") + .foregroundColor(.white) + .padding(10) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + .accessibilityIdentifier("galleryButton") + + Button { + path = NavigationPath() + } label: { + Image(systemName: "house") + .foregroundColor(.white) + .padding(10) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + + VStack(spacing: 8) { + toggleButton(icon: "grid", active: $viewModel.showGrid) + toggleButton(icon: "viewfinder", active: $viewModel.showFrameLines) + } + } + } + .padding(.top, 10) + } + + private func toggleButton(icon: String, active: Binding) -> some View { + Button { + active.wrappedValue.toggle() + } label: { + Image(systemName: icon) + .foregroundColor(active.wrappedValue ? Color(hex: Constants.Design.accent) : .white) + .padding(10) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + } + + private var frameCounterPill: some View { + HStack(spacing: 8) { + Text(viewModel.roll.name) + .font(.custom("InterTight-Medium", size: 12)) + .lineLimit(1) + + Text(viewModel.exposureProgress) + .font(.custom("JetBrainsMono-Bold", size: 14)) + .foregroundColor(Color(hex: Constants.Design.accent)) + + if viewModel.isRollFull { + Text("END") + .font(.custom("JetBrainsMono-Bold", size: 12)) + .foregroundColor(Color(hex: Constants.Design.red)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + } + + @ViewBuilder + private var pickerView: some View { + VStack(spacing: 0) { + switch activePicker { + case .aperture: + RadialDialPicker(options: viewModel.filteredApertures, selection: $viewModel.currentAperture) + case .shutterSpeed: + RadialDialPicker(options: Constants.shutterSpeeds, selection: $viewModel.currentShutterSpeed) + case .ei: + RadialDialPicker(options: Constants.eiValues.map { "\($0)" }, selection: Binding( + get: { "\(viewModel.currentEI)" }, + set: { viewModel.currentEI = Int($0) ?? viewModel.currentEI } + )) + case .focalLength: + RadialDialPicker(options: viewModel.focalLengthOptions, selection: Binding( + get: { "\(viewModel.currentFocalLength)mm" }, + set: { viewModel.currentFocalLength = Int($0.replacingOccurrences(of: "mm", with: "")) ?? viewModel.currentFocalLength } + )) + case .none: + EmptyView() + } + + // Dismiss button for picker + Button { + withAnimation(.spring(response: 0.3)) { + activePicker = .none + } + } label: { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 44)) + .foregroundColor(Color(hex: Constants.Design.accent)) + } + .accessibilityIdentifier("dismissPickerButton") + .accessibilityLabel("Confirm Selection") + .padding(.bottom, 20) + .frame(maxWidth: .infinity) + } + .background(Color(hex: Constants.Design.surface1)) + .cornerRadius(22) + .padding(.bottom, 10) + .contentShape(Rectangle()) + .onTapGesture { } // Catch-all to prevent fall-through + } + + private var settingsChipsRow: some View { + HStack(spacing: 12) { + CaptureChip(label: "APER", value: viewModel.currentAperture, isActive: activePicker == .aperture) { + togglePicker(.aperture) + } + CaptureChip(label: "SHUT", value: viewModel.currentShutterSpeed, isActive: activePicker == .shutterSpeed) { + togglePicker(.shutterSpeed) + } + CaptureChip(label: "EI", value: "\(viewModel.currentEI)", isActive: activePicker == .ei) { + togglePicker(.ei) + } + CaptureChip(label: "FOCL", value: "\(viewModel.currentFocalLength)mm", isActive: activePicker == .focalLength) { + togglePicker(.focalLength) + } + } + .padding(.bottom, 20) + } + + private func togglePicker(_ picker: ActivePicker) { + withAnimation(.spring(response: 0.3)) { + if activePicker == picker { + activePicker = .none + } else { + activePicker = picker + } + } + } + + private var bottomControls: some View { + HStack { + // Note Button + Button { + showNoteSheet = true + } label: { + Image(systemName: "note.text") + .font(.title3) + .foregroundColor(viewModel.pendingNote != nil ? Color(hex: Constants.Design.accent) : .white) + .frame(width: 50, height: 50) + .background(.ultraThinMaterial) + .clipShape(Circle()) + } + + Spacer() + + // Shutter Button + Button { + viewModel.capture() + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + } label: { + ZStack { + Circle() + .strokeBorder(.white, lineWidth: 4) + .frame(width: 78, height: 78) + Circle() + .fill(.white) + .frame(width: 64, height: 64) + } + } + .accessibilityIdentifier("shutterButton") + .disabled(viewModel.isRollFull || viewModel.isCapturing) + + Spacer() + + // Last Shot Peek + NavigationLink(value: GalleryDestination(roll: viewModel.roll)) { + Group { + if let imageData = viewModel.lastExposureThumbnail, let uiImage = UIImage(data: imageData) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "photo.on.rectangle") + .foregroundColor(.white) + } + } + .frame(width: 50, height: 50) + .background(.ultraThinMaterial) + .clipShape(Circle()) + .overlay(Circle().stroke(.white.opacity(0.2), lineWidth: 1)) + } + .accessibilityIdentifier("lastShotPeek") + } + .padding(.bottom, 30) + } +} + +struct GridView: View { + var body: some View { + ZStack { + HStack { + Spacer() + Divider().background(.white.opacity(0.2)) + Spacer() + Divider().background(.white.opacity(0.2)) + Spacer() + } + VStack { + Spacer() + Divider().background(.white.opacity(0.2)) + Spacer() + Divider().background(.white.opacity(0.2)) + Spacer() + } + } + } +} + +struct FrameLinesView: View { + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 5])) + .foregroundColor(Color(hex: Constants.Design.accent).opacity(0.5)) + .padding(40) + + // Crosshair + Path { path in + path.move(to: CGPoint(x: -10, y: 0)) + path.addLine(to: CGPoint(x: 10, y: 0)) + path.move(to: CGPoint(x: 0, y: -10)) + path.addLine(to: CGPoint(x: 0, y: 10)) + } + .stroke(Color(hex: Constants.Design.accent).opacity(0.5), lineWidth: 1) + } + } +} + +struct CaptureChip: View { + let label: String + let value: String + let isActive: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.custom("InterTight-Medium", size: 8)) + .foregroundColor(isActive ? Color(hex: Constants.Design.accent).opacity(0.8) : .white.opacity(0.6)) + Text(value) + .font(.custom("JetBrainsMono-Bold", size: 14)) + .foregroundColor(isActive ? .black : Color(hex: Constants.Design.accent)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isActive ? Color(hex: Constants.Design.accent) : Color.white.opacity(0.1)) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isActive ? Color(hex: Constants.Design.accent) : .white.opacity(0.1), lineWidth: 1) + ) + .shadow(color: isActive ? Color(hex: Constants.Design.accent).opacity(0.3) : .clear, radius: 10) + } + .accessibilityIdentifier(label) + } +} diff --git a/ios/FilmTracker/Screens/Capture/FocalLengthOverlay.swift b/ios/FilmTracker/Screens/Capture/FocalLengthOverlay.swift new file mode 100644 index 0000000..a0eb60f --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/FocalLengthOverlay.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct FocalLengthOverlay: View { + let currentFocalLength: Int + + private let focalPoints = [15, 24, 35, 50, 85, 135, 200] + + var body: some View { + VStack(spacing: 8) { + ZStack(alignment: .bottom) { + // Ruler line + Rectangle() + .fill(.white.opacity(0.2)) + .frame(height: 1) + + // Tick marks + HStack(alignment: .bottom, spacing: 0) { + ForEach(focalPoints, id: \.self) { point in + VStack(spacing: 4) { + Text("\(point)") + .font(.custom("JetBrainsMono-Medium", size: 8)) + .foregroundColor(currentFocalLength == point ? Color(hex: Constants.Design.accent) : .white.opacity(0.4)) + + Rectangle() + .fill(currentFocalLength == point ? Color(hex: Constants.Design.accent) : .white.opacity(0.3)) + .frame(width: 1, height: currentFocalLength == point ? 12 : 6) + } + if point != focalPoints.last { Spacer() } + } + } + + // Active thumb + GeometryReader { geo in + let x = xOffset(for: currentFocalLength, in: geo.size.width) + Circle() + .fill(Color(hex: Constants.Design.accent)) + .frame(width: 8, height: 8) + .shadow(color: Color(hex: Constants.Design.accent).opacity(0.5), radius: 4) + .offset(x: x - 4, y: geo.size.height - 4) + } + .frame(height: 12) + } + .frame(height: 30) + + Text("\(currentFocalLength)mm") + .font(.custom("JetBrainsMono-Bold", size: 12)) + .foregroundColor(Color(hex: Constants.Design.accent)) + } + .padding(.horizontal, 40) + } + + private func xOffset(for focal: Int, in width: CGFloat) -> CGFloat { + guard let first = focalPoints.first, let last = focalPoints.last else { return 0 } + // Non-linear mapping could be better, but let's do simple linear for now + let range = CGFloat(last - first) + let progress = CGFloat(focal - first) / range + return progress * width + } +} diff --git a/ios/FilmTracker/Screens/Capture/LensPickerSheet.swift b/ios/FilmTracker/Screens/Capture/LensPickerSheet.swift new file mode 100644 index 0000000..71a6173 --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/LensPickerSheet.swift @@ -0,0 +1,76 @@ +import SwiftUI +import SwiftData + +struct LensPickerSheet: View { + @Binding var selectedLens: Lens? + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Query(sort: \Lens.name) private var lenses: [Lens] + + var body: some View { + VStack(spacing: 0) { + GrabberBar() + + Text("SELECT LENS") + .font(.custom("InterTight-Bold", size: 14)) + .foregroundColor(.white.opacity(0.6)) + .padding(.vertical, 20) + + ScrollView { + VStack(spacing: 0) { + // No Lens option + lensRow(lens: nil) + + ForEach(lenses) { lens in + Divider().background(.white.opacity(0.1)) + lensRow(lens: lens) + } + } + .background(Color(hex: Constants.Design.surface2)) + .cornerRadius(12) + .padding(.horizontal) + } + .frame(maxHeight: 400) + + Spacer().frame(height: 30) + } + .background(Color(hex: Constants.Design.surface1)) + } + + private func lensRow(lens: Lens?) -> some View { + Button { + selectedLens = lens + dismiss() + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(lens?.name ?? "No lens") + .font(.custom("InterTight-Medium", size: 16)) + .foregroundColor(.white) + + if let l = lens { + Text(lensMetadata(l)) + .font(.custom("JetBrainsMono-Medium", size: 12)) + .foregroundColor(.white.opacity(0.5)) + } + } + + Spacer() + + if selectedLens?.id == lens?.id { + Image(systemName: "checkmark") + .foregroundColor(Color(hex: Constants.Design.accent)) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 16) + .contentShape(Rectangle()) + } + } + + private func lensMetadata(_ lens: Lens) -> String { + let focal = lens.isZoom ? "\(lens.focalLengthMin!)-\(lens.focalLengthMax!)mm" : "\(lens.focalLength!)mm" + return "\(focal) · \(lens.maxAperture)" + } +} diff --git a/ios/FilmTracker/Screens/Capture/LightMeterView.swift b/ios/FilmTracker/Screens/Capture/LightMeterView.swift new file mode 100644 index 0000000..eb8843f --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/LightMeterView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct LightMeterView: View { + let ev: Double + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("LIGHT METER") + .font(.custom("InterTight-Bold", size: 8)) + .foregroundColor(.white.opacity(0.6)) + + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(String(format: "%.1f", ev)) + .font(.custom("JetBrainsMono-Bold", size: 18)) + .foregroundColor(isCorrectExposure ? Color(hex: Constants.Design.green) : Color(hex: Constants.Design.accent)) + Text("EV") + .font(.custom("JetBrainsMono-Medium", size: 10)) + .foregroundColor(.white.opacity(0.4)) + } + + // Scale Bar + ZStack(alignment: .leading) { + Rectangle() + .fill(.white.opacity(0.1)) + .frame(height: 2) + + // Ticks + HStack(spacing: 0) { + ForEach(-3...3, id: \.self) { i in + Rectangle() + .fill(i == 0 ? .white : .white.opacity(0.3)) + .frame(width: 1, height: i == 0 ? 8 : 4) + if i < 3 { Spacer() } + } + } + .frame(height: 8) + + // Indicator + Rectangle() + .fill(isCorrectExposure ? Color(hex: Constants.Design.green) : Color(hex: Constants.Design.accent)) + .frame(width: 2, height: 12) + .offset(x: indicatorOffset) + } + .frame(width: 120) + } + .padding(12) + .background(.ultraThinMaterial) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(.white.opacity(0.1), lineWidth: 1) + ) + } + + private var isCorrectExposure: Bool { + // Simplified: correct exposure is arbitrary for simulation + // Let's say between 10 and 15 EV is "green" for daylight + ev > 10 && ev < 15 + } + + private var indicatorOffset: CGFloat { + // Map EV to scale -3...+3 + // Center (0) is at 60px. Range is 120px. + // Let's center around 12.5 EV for simulation. + let centeredEV = ev - 12.5 + let clampedEV = max(-3, min(3, centeredEV)) + return CGFloat(clampedEV + 3) * (120.0 / 6.0) + } +} diff --git a/ios/FilmTracker/Screens/Capture/NoteSheet.swift b/ios/FilmTracker/Screens/Capture/NoteSheet.swift new file mode 100644 index 0000000..e1f644b --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/NoteSheet.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct NoteSheet: View { + @Binding var note: String? + @Environment(\.dismiss) private var dismiss + + @State private var localNote: String = "" + + var body: some View { + VStack(spacing: 20) { + GrabberBar() + + VStack(alignment: .leading, spacing: 8) { + Text("ADD NOTE") + .font(.custom("InterTight-Bold", size: 14)) + .foregroundColor(.white.opacity(0.6)) + + TextEditor(text: $localNote) + .font(.custom("InterTight-Medium", size: 16)) + .frame(height: 120) + .padding(12) + .background(Color(hex: Constants.Design.surface2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(.white.opacity(0.1), lineWidth: 1) + ) + + Text("Saved with next exposure") + .font(.custom("InterTight-Medium", size: 12)) + .foregroundColor(Color(hex: Constants.Design.accent).opacity(0.8)) + } + .padding(.horizontal) + + AppButton(title: "Save Note", variant: .primary) { + note = localNote.isEmpty ? nil : localNote + dismiss() + } + .padding(.horizontal) + .padding(.bottom, 30) + } + .background(Color(hex: Constants.Design.surface1)) + .onAppear { + localNote = note ?? "" + } + } +} + +struct GrabberBar: View { + var body: some View { + Capsule() + .fill(.white.opacity(0.2)) + .frame(width: 36, height: 4) + .padding(.top, 12) + } +} diff --git a/ios/FilmTracker/Screens/Capture/Pickers/RadialDialPicker.swift b/ios/FilmTracker/Screens/Capture/Pickers/RadialDialPicker.swift new file mode 100644 index 0000000..7f953f2 --- /dev/null +++ b/ios/FilmTracker/Screens/Capture/Pickers/RadialDialPicker.swift @@ -0,0 +1,127 @@ +import SwiftUI + +struct RadialDialPicker: View { + let options: [String] + @Binding var selection: String + + @State private var offset: CGFloat = 0 + @State private var lastOffset: CGFloat = 0 + + private let radius: CGFloat = 180 + private let arcAngle: Double = 130 // Total arc angle + + var body: some View { + VStack(spacing: 20) { + // Pointer + Image(systemName: "triangle.fill") + .font(.system(size: 12)) + .foregroundColor(Color(hex: Constants.Design.accent)) + .rotationEffect(.degrees(180)) + + ZStack { + ForEach(0.. Double { + let baseAngle = Double(index) * (arcAngle / Double(options.count - 1)) + let rotation = Double(offset / 4) // Adjust sensitivity + return baseAngle + rotation - (arcAngle / 2) + } + + private func opacityForIndex(_ index: Int) -> Double { + let angle = angleForIndex(index) + let absAngle = abs(angle) + if absAngle > 60 { return 0 } + return 1.0 - (absAngle / 60.0) + } + + private func updateSelection() { + let step = 40.0 // Pixels per option + let index = Int(round(-offset / step)) + let clampedIndex = max(0, min(options.count - 1, index)) + if options[clampedIndex] != selection { + selection = options[clampedIndex] + UISelectionFeedbackGenerator().selectionChanged() + } + } + + private func snapToNearest() { + let step = 40.0 + let index = Int(round(-offset / step)) + let clampedIndex = max(0, min(options.count - 1, index)) + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + offset = -CGFloat(clampedIndex) * step + } + } +} diff --git a/ios/FilmTracker/Screens/Details/DetailsView.swift b/ios/FilmTracker/Screens/Details/DetailsView.swift new file mode 100644 index 0000000..27fecb7 --- /dev/null +++ b/ios/FilmTracker/Screens/Details/DetailsView.swift @@ -0,0 +1,290 @@ +import SwiftUI +import SwiftData +import PhotosUI + +struct DetailsView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: ExposureViewModel + @State private var showingImageSource = false + @State private var showingPhotosPicker = false + @State private var selectedItem: PhotosPickerItem? + @State private var showingDeleteConfirmation = false + + init(exposure: Exposure, modelContext: ModelContext) { + _viewModel = State(initialValue: ExposureViewModel(exposure: exposure, modelContext: modelContext)) + } + + var body: some View { + ZStack { + Color.appBg.ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + heroImage + + if viewModel.isEditing { + editingForm + } else { + readoutGrid + + metadataCard + + notesSection + } + + Spacer(minLength: 40) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + HStack(spacing: 16) { + Button { + if viewModel.isEditing { + viewModel.save() + } else { + viewModel.isEditing = true + } + } label: { + Text(viewModel.isEditing ? "Save" : "Edit") + .font(.custom("InterTight-Bold", size: 16)) + .foregroundColor(.accent) + } + + if viewModel.isEditing { + Button("Cancel") { + viewModel.cancel() + } + .font(.custom("InterTight-Medium", size: 16)) + .foregroundColor(.appText) + } else { + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + Image(systemName: "trash") + .foregroundColor(.appRed) + } + } + } + } + } + .confirmationDialog("Delete Exposure?", isPresented: $showingDeleteConfirmation, titleVisibility: .visible) { + Button("Delete", role: .destructive) { + viewModel.delete() + dismiss() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently remove this exposure.") + } + .onChange(of: selectedItem) { _, newItem in + if let newItem { + Task { + if let data = try? await newItem.loadTransferable(type: Data.self) { + viewModel.updateImage(data) + } + } + } + } + } + + private var heroImage: some View { + ZStack(alignment: .top) { + // Main Image + Group { + if let data = viewModel.exposure.imageData, let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Rectangle() + .fill(LinearGradient(colors: [Color(hex: Constants.Design.surface2), Color(hex: Constants.Design.surface1)], startPoint: .topLeading, endPoint: .bottomTrailing)) + } + } + .frame(maxWidth: .infinity) + .aspectRatio(4/3, contentMode: .fit) + .clipped() + .overlay { + if viewModel.isEditing { + Color.black.opacity(0.4) + Button { + showingPhotosPicker = true + } label: { + VStack(spacing: 8) { + Image(systemName: "camera.fill") + .font(.largeTitle) + Text("Replace Image") + .font(.custom("InterTight-Bold", size: 16)) + } + .foregroundColor(.white) + } + } + } + + // Perforation Overlays + perforationOverlay(isTop: true) + perforationOverlay(isTop: false) + } + .photosPicker(isPresented: $showingPhotosPicker, selection: $selectedItem, matching: .images) + } + + @ViewBuilder + private var editingForm: some View { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 12) { + Text("EXPOSURE SETTINGS") + .font(.appMono(10)) + .foregroundColor(.muted) + + VStack(spacing: 0) { + pickerRow(label: "Aperture", options: Constants.apertures, selection: $viewModel.aperture) + Divider().background(Color.appText.opacity(0.1)) + pickerRow(label: "Shutter", options: Constants.shutterSpeeds, selection: $viewModel.shutterSpeed) + Divider().background(Color.appText.opacity(0.1)) + pickerRow(label: "EI", options: Constants.eiValues.map { "\($0)" }, selection: Binding( + get: { "\(viewModel.ei)" }, + set: { viewModel.ei = Int($0) ?? 400 } + )) + Divider().background(Color.appText.opacity(0.1)) + pickerRow(label: "Focal", options: Constants.focalPresets.map { "\($0)mm" }, selection: Binding( + get: { viewModel.focalLength != nil ? "\(viewModel.focalLength!)mm" : "—" }, + set: { viewModel.focalLength = Int($0.replacingOccurrences(of: "mm", with: "")) } + )) + } + .background(Color.surface1) + .cornerRadius(12) + } + + notesSection + } + .padding(.horizontal, 20) + } + + private func pickerRow(label: String, options: [String], selection: Binding) -> some View { + HStack { + Text(label) + .font(.appBody(16)) + .foregroundColor(.appText) + Spacer() + Picker(label, selection: selection) { + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .pickerStyle(.menu) + .tint(.accent) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + private func perforationOverlay(isTop: Bool) -> some View { + VStack { + if !isTop { Spacer() } + + HStack { + if isTop { + Text(viewModel.rollName) + Spacer() + Text("\(viewModel.exposure.exposureNumber)A → \(viewModel.exposure.exposureNumber)") + } else { + Text("ISO \(viewModel.exposure.ei ?? 0)") + Spacer() + Text(viewModel.exposure.capturedAt.formatted(date: .numeric, time: .omitted)) + } + } + .font(.custom("JetBrainsMono-Bold", size: 10)) + .foregroundColor(.white.opacity(0.8)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black.opacity(0.4)) + + if isTop { Spacer() } + } + .frame(height: isTop ? 40 : nil) + } + + private var readoutGrid: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + ReadoutTile(label: "Aperture", value: viewModel.aperture, isDimmed: false) + ReadoutTile(label: "Shutter", value: viewModel.shutterSpeed, isDimmed: false) + ReadoutTile(label: "EI", value: "\(viewModel.ei)", isDimmed: false) + ReadoutTile(label: "Focal", value: viewModel.focalLength != nil ? "\(viewModel.focalLength!)mm" : "—", isDimmed: viewModel.focalLength == nil) + } + .padding(.horizontal, 20) + } + + private var metadataCard: some View { + VStack(spacing: 0) { + metadataRow(icon: "camera", label: "Camera", value: viewModel.cameraName) + Divider().background(Color.appText.opacity(0.05)) + metadataRow(icon: "camera.filters", label: "Lens", value: viewModel.lensName) + Divider().background(Color.appText.opacity(0.05)) + metadataRow(icon: "clock", label: "Captured", value: viewModel.exposure.capturedAt.formatted(date: .abbreviated, time: .shortened)) + Divider().background(Color.appText.opacity(0.05)) + metadataRow(icon: "location", label: "Location", value: locationString) + } + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: Constants.Design.radiusLG)) + .padding(.horizontal, 20) + } + + private func metadataRow(icon: String, label: String, value: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon) + .foregroundColor(.muted) + .frame(width: 20) + + Text(label) + .font(.custom("InterTight-Medium", size: 14)) + .foregroundColor(.muted) + + Spacer() + + Text(value) + .font(.custom("JetBrainsMono-Bold", size: 14)) + .foregroundColor(.appText) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + + private var notesSection: some View { + VStack(alignment: .leading, spacing: 12) { + Text("NOTES") + .font(.custom("InterTight-Bold", size: 12)) + .foregroundColor(.muted) + .padding(.leading, 4) + + if viewModel.isEditing { + TextEditor(text: $viewModel.additionalInfo) + .font(.custom("InterTight-Regular", size: 14)) + .foregroundColor(.appText) + .frame(minHeight: 120) + .padding(12) + .background(Color.surface2) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + Text(viewModel.additionalInfo.isEmpty ? "No notes added." : viewModel.additionalInfo) + .font(.custom("InterTight-Regular", size: 14)) + .foregroundColor(viewModel.additionalInfo.isEmpty ? .dim : .appText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(.horizontal, 20) + } + + private var locationString: String { + if let location = viewModel.exposure.location { + return "\(String(format: "%.4f", location.latitude)), \(String(format: "%.4f", location.longitude))" + } else { + return "—" + } + } +} diff --git a/ios/FilmTracker/Screens/Details/ReadoutTile.swift b/ios/FilmTracker/Screens/Details/ReadoutTile.swift new file mode 100644 index 0000000..21f3a87 --- /dev/null +++ b/ios/FilmTracker/Screens/Details/ReadoutTile.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct ReadoutTile: View { + let label: String + let value: String + let isDimmed: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label.uppercased()) + .font(.custom("InterTight-Medium", size: 10)) + .foregroundColor(.muted) + + Text(value) + .font(.custom("JetBrainsMono-Bold", size: 20)) + .foregroundColor(isDimmed ? .dim : .accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: Constants.Design.radiusMD)) + .overlay( + RoundedRectangle(cornerRadius: Constants.Design.radiusMD) + .stroke(Color.appText.opacity(0.05), lineWidth: 1) + ) + } +} diff --git a/ios/FilmTracker/Screens/Equipment/CameraFormSheet.swift b/ios/FilmTracker/Screens/Equipment/CameraFormSheet.swift new file mode 100644 index 0000000..8ba605d --- /dev/null +++ b/ios/FilmTracker/Screens/Equipment/CameraFormSheet.swift @@ -0,0 +1,82 @@ +import SwiftUI +import SwiftData + +struct CameraFormSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + var camera: Camera? // nil for create, non-nil for edit + + @State private var make: String = "" + @State private var model: String = "" + + var isReady: Bool { + !make.trimmingCharacters(in: .whitespaces).isEmpty && + !model.trimmingCharacters(in: .whitespaces).isEmpty + } + + init(camera: Camera? = nil) { + self.camera = camera + _make = State(initialValue: camera?.make ?? "") + _model = State(initialValue: camera?.model ?? "") + } + + var body: some View { + BottomSheet(isPresented: .constant(true), title: camera == nil ? "New camera" : "Edit camera") { + VStack(spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("MAKE") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("Nikon", text: $make) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("cameraMakeInput") + } + + VStack(alignment: .leading, spacing: 8) { + Text("MODEL") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("FE", text: $model) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("cameraModelInput") + } + + AppButton(title: camera == nil ? "Add camera" : "Save changes", isDisabled: !isReady) { + save() + } + .padding(.top, 8) + .accessibilityIdentifier("confirmCameraFormButton") + } + } + .presentationDetents([.medium, .large]) + .presentationBackground(.clear) + } + + private func save() { + if let camera = camera { + camera.make = make + camera.model = model + camera.name = "\(make) \(model)" + } else { + let newCamera = Camera(make: make, model: model) + modelContext.insert(newCamera) + } + dismiss() + } +} + +#Preview { + CameraFormSheet() + .modelContainer(for: Camera.self, inMemory: true) +} diff --git a/ios/FilmTracker/Screens/Equipment/CameraListView.swift b/ios/FilmTracker/Screens/Equipment/CameraListView.swift new file mode 100644 index 0000000..573bd9a --- /dev/null +++ b/ios/FilmTracker/Screens/Equipment/CameraListView.swift @@ -0,0 +1,79 @@ +import SwiftUI +import SwiftData + +struct CameraListView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \Camera.createdAt, order: .reverse) private var cameras: [Camera] + + @State private var cameraToEdit: Camera? + @State private var cameraToDelete: Camera? + + let onAdd: () -> Void + + var body: some View { + Group { + if cameras.isEmpty { + EmptyStateView( + iconName: "camera", + title: "No cameras", + bodyText: "Add your camera bodies to track which one shot each roll.", + actionTitle: "Add Camera" + ) { + onAdd() + } + .padding(.top, 40) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(cameras) { camera in + EntityRow( + title: camera.name, + subtitle: "\(camera.make.uppercased())", + iconName: "camera", + menuActions: AnyView( + Group { + Button("Edit") { + cameraToEdit = camera + } + Button("Delete", role: .destructive) { + cameraToDelete = camera + } + } + ) + ) + } + } + .padding(20) + } + } + } + .sheet(item: $cameraToEdit) { camera in + CameraFormSheet(camera: camera) + } + .sheet(item: $cameraToDelete) { camera in + ConfirmationSheet( + title: "Delete Camera?", + message: "Are you sure you want to delete \(camera.name)? This action cannot be undone.", + onConfirm: { + modelContext.delete(camera) + cameraToDelete = nil + }, + onCancel: { + cameraToDelete = nil + } + ) + .presentationDetents([.height(250)]) + .presentationBackground(.clear) + } + } +} + +extension Camera: Identifiable {} // SwiftData @Model is already Identifiable but let's be explicit if needed + +#Preview { + ZStack { + Color.appBg.ignoresSafeArea() + CameraListView(onAdd: {}) + .modelContainer(for: Camera.self, inMemory: true) + } +} diff --git a/ios/FilmTracker/Screens/Equipment/EquipmentView.swift b/ios/FilmTracker/Screens/Equipment/EquipmentView.swift new file mode 100644 index 0000000..ac98bd0 --- /dev/null +++ b/ios/FilmTracker/Screens/Equipment/EquipmentView.swift @@ -0,0 +1,109 @@ +import SwiftUI +import SwiftData + +struct EquipmentView: View { + @Environment(\.modelContext) private var modelContext + @State private var selectedSegment = 0 + @State private var showingAddSheet = false + + var body: some View { + ZStack(alignment: .bottomTrailing) { + Color.appBg.ignoresSafeArea() + + VStack(spacing: 0) { + headerView + + segmentPicker + + if selectedSegment == 0 { + CameraListView(onAdd: { showingAddSheet = true }) + } else { + LensListView(onAdd: { showingAddSheet = true }) + } + + Spacer() + } + + // FAB + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.black) + .frame(width: 60, height: 60) + .background(Color.accent) + .clipShape(Circle()) + .shadow(color: Color.accent.opacity(0.3), radius: 10, x: 0, y: 5) + } + .padding(24) + .accessibilityIdentifier("addEquipmentFAB") + } + .navigationTitle("Equipment") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(destination: SettingsView()) { + Image(systemName: "gearshape") + .foregroundColor(.appText) + } + } + } + .sheet(isPresented: $showingAddSheet) { + if selectedSegment == 0 { + CameraFormSheet() + } else { + LensFormSheet() + } + } + } + + private var headerView: some View { + VStack(alignment: .leading, spacing: 4) { + Text("GEAR") + .font(.appMono(10)) + .foregroundColor(.muted) + Text("Equipment") + .font(.appHeadline(28)) + .foregroundColor(.appText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 10) + } + + private var segmentPicker: some View { + HStack(spacing: 0) { + segmentButton(title: "Cameras", index: 0) + segmentButton(title: "Lenses", index: 1) + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 10) + } + + private func segmentButton(title: String, index: Int) -> some View { + Button { + selectedSegment = index + } label: { + VStack(spacing: 8) { + Text(title) + .font(.appLabel(16)) + .foregroundColor(selectedSegment == index ? .appText : .muted) + + Rectangle() + .fill(selectedSegment == index ? Color.accent : Color.clear) + .frame(height: 2) + } + } + .frame(maxWidth: .infinity) + .accessibilityIdentifier("segmentButton_\(index)") + } +} + +#Preview { + NavigationStack { + EquipmentView() + .modelContainer(for: [Camera.self, Lens.self], inMemory: true) + } +} diff --git a/ios/FilmTracker/Screens/Equipment/LensFormSheet.swift b/ios/FilmTracker/Screens/Equipment/LensFormSheet.swift new file mode 100644 index 0000000..dc5fe43 --- /dev/null +++ b/ios/FilmTracker/Screens/Equipment/LensFormSheet.swift @@ -0,0 +1,188 @@ +import SwiftUI +import SwiftData + +struct LensFormSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + var lens: Lens? // nil for create, non-nil for edit + + @State private var name: String = "" + @State private var maxAperture: String = "f/2" + @State private var isZoom: Bool = false + @State private var focalLength: String = "" + @State private var focalLengthMin: String = "" + @State private var focalLengthMax: String = "" + + var isReady: Bool { + guard !name.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + if isZoom { + guard let min = Int(focalLengthMin), let max = Int(focalLengthMax) else { return false } + return min < max + } else { + return Int(focalLength) != nil + } + } + + init(lens: Lens? = nil) { + self.lens = lens + _name = State(initialValue: lens?.name ?? "") + _maxAperture = State(initialValue: lens?.maxAperture ?? "f/2") + _isZoom = State(initialValue: lens?.isZoom ?? false) + _focalLength = State(initialValue: lens?.focalLength.map(String.init) ?? "") + _focalLengthMin = State(initialValue: lens?.focalLengthMin.map(String.init) ?? "") + _focalLengthMax = State(initialValue: lens?.focalLengthMax.map(String.init) ?? "") + } + + var body: some View { + BottomSheet(isPresented: .constant(true), title: lens == nil ? "New lens" : "Edit lens") { + ScrollView { + VStack(spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("LENS NAME") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("Summicron 50mm", text: $name) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("lensNameInput") + } + + VStack(alignment: .leading, spacing: 8) { + Text("MAX APERTURE") + .font(.appMono(10)) + .foregroundColor(.muted) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Constants.apertures, id: \.self) { aperture in + AppChip( + title: aperture, + isSelected: maxAperture == aperture + ) { + maxAperture = aperture + } + } + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("TYPE") + .font(.appMono(10)) + .foregroundColor(.muted) + + HStack(spacing: 0) { + Button { isZoom = false } label: { + Text("Prime") + .font(.appLabel(14)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(!isZoom ? Color.surface3 : Color.surface2) + .foregroundColor(!isZoom ? .appText : .muted) + } + .accessibilityIdentifier("lensTypePrime") + + Button { isZoom = true } label: { + Text("Zoom") + .font(.appLabel(14)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(isZoom ? Color.surface3 : Color.surface2) + .foregroundColor(isZoom ? .appText : .muted) + } + .accessibilityIdentifier("lensTypeZoom") + } + .cornerRadius(Constants.Design.radiusSM) + } + + if !isZoom { + VStack(alignment: .leading, spacing: 8) { + Text("FOCAL LENGTH (mm)") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("50", text: $focalLength) + .keyboardType(.numberPad) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("lensFocalInput") + } + } else { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("MIN (mm)") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("24", text: $focalLengthMin) + .keyboardType(.numberPad) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("lensFocalMinInput") + } + + VStack(alignment: .leading, spacing: 8) { + Text("MAX (mm)") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("70", text: $focalLengthMax) + .keyboardType(.numberPad) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("lensFocalMaxInput") + } + } + } + + AppButton(title: lens == nil ? "Add lens" : "Save changes", isDisabled: !isReady) { + save() + } + .padding(.top, 8) + .accessibilityIdentifier("confirmLensFormButton") + } + } + } + .presentationDetents([.medium, .large]) + .presentationBackground(.clear) + } + + private func save() { + if let lens = lens { + lens.name = name + lens.maxAperture = maxAperture + lens.focalLength = isZoom ? nil : Int(focalLength) + lens.focalLengthMin = isZoom ? Int(focalLengthMin) : nil + lens.focalLengthMax = isZoom ? Int(focalLengthMax) : nil + } else { + let newLens = Lens( + name: name, + maxAperture: maxAperture, + focalLength: isZoom ? nil : Int(focalLength), + focalLengthMin: isZoom ? Int(focalLengthMin) : nil, + focalLengthMax: isZoom ? Int(focalLengthMax) : nil + ) + modelContext.insert(newLens) + } + dismiss() + } +} + +#Preview { + LensFormSheet() + .modelContainer(for: Lens.self, inMemory: true) +} diff --git a/ios/FilmTracker/Screens/Equipment/LensListView.swift b/ios/FilmTracker/Screens/Equipment/LensListView.swift new file mode 100644 index 0000000..473f411 --- /dev/null +++ b/ios/FilmTracker/Screens/Equipment/LensListView.swift @@ -0,0 +1,86 @@ +import SwiftUI +import SwiftData + +struct LensListView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \Lens.createdAt, order: .reverse) private var lenses: [Lens] + + @State private var lensToEdit: Lens? + @State private var lensToDelete: Lens? + + let onAdd: () -> Void + + var body: some View { + Group { + if lenses.isEmpty { + EmptyStateView( + iconName: "circle.circle", + title: "No lenses", + bodyText: "Add lenses to track focal length and maximum aperture per shot.", + actionTitle: "Add Lens" + ) { + onAdd() + } + .padding(.top, 40) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(lenses) { lens in + EntityRow( + title: lens.name, + subtitle: formatSubtitle(lens), + iconName: "circle.circle", + menuActions: AnyView( + Group { + Button("Edit") { + lensToEdit = lens + } + Button("Delete", role: .destructive) { + lensToDelete = lens + } + } + ) + ) + } + } + .padding(20) + } + } + } + .sheet(item: $lensToEdit) { lens in + LensFormSheet(lens: lens) + } + .sheet(item: $lensToDelete) { lens in + ConfirmationSheet( + title: "Delete Lens?", + message: "Are you sure you want to delete \(lens.name)? This action cannot be undone.", + onConfirm: { + modelContext.delete(lens) + lensToDelete = nil + }, + onCancel: { + lensToDelete = nil + } + ) + .presentationDetents([.height(250)]) + .presentationBackground(.clear) + } + } + + private func formatSubtitle(_ lens: Lens) -> String { + let focal = lens.isZoom + ? "\(lens.focalLengthMin ?? 0)-\(lens.focalLengthMax ?? 0)mm" + : "\(lens.focalLength ?? 0)mm" + return "\(focal) · \(lens.maxAperture)" + } +} + +extension Lens: Identifiable {} + +#Preview { + ZStack { + Color.appBg.ignoresSafeArea() + LensListView(onAdd: {}) + .modelContainer(for: Lens.self, inMemory: true) + } +} diff --git a/ios/FilmTracker/Screens/Gallery/ExportSheet.swift b/ios/FilmTracker/Screens/Gallery/ExportSheet.swift new file mode 100644 index 0000000..2495ff9 --- /dev/null +++ b/ios/FilmTracker/Screens/Gallery/ExportSheet.swift @@ -0,0 +1,150 @@ +import SwiftUI +import SwiftData + +struct ExportSheet: View { + let roll: FilmRoll + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var selectedFormat: ExportFormat = .jsonOnly + @State private var isExporting = false + @State private var exportURL: URL? + @State private var showingShareSheet = false + + @Query private var allExposures: [Exposure] + + var exposures: [Exposure] { + allExposures.filter { $0.filmRollId == roll.id }.sorted { $0.exposureNumber < $1.exposureNumber } + } + + var body: some View { + VStack(spacing: 24) { + // Header + VStack(spacing: 8) { + Text("EXPORT ROLL") + .font(.appMono(12)) + .foregroundColor(.muted) + Text(roll.name) + .font(.appHeadline(20)) + .foregroundColor(.appText) + } + .padding(.top, 8) + + // Format Selection + VStack(alignment: .leading, spacing: 16) { + Text("SELECT FORMAT") + .font(.appMono(10)) + .foregroundColor(.muted) + + VStack(spacing: 12) { + formatOption( + format: .jsonOnly, + title: "JSON Metadata Only", + subtitle: "Compact, compatible with PWA", + icon: "doc.text" + ) + + formatOption( + format: .jsonWithImages, + title: "JSON with Images", + subtitle: "All-in-one file, base64 encoded", + icon: "doc.richtext" + ) + + formatOption( + format: .archive, + title: "Multi-file Archive", + subtitle: "metadata.json + separate JPEGs", + icon: "archivebox" + ) + } + } + + Spacer() + + // Export Button + AppButton( + title: isExporting ? "Exporting..." : "Export Roll", + isDisabled: isExporting + ) { + performExport() + } + } + .padding(24) + .background(Color.surface1) + .cornerRadius(Constants.Design.radiusXL, corners: [.topLeft, .topRight]) + .sheet(isPresented: $showingShareSheet, onDismiss: { dismiss() }) { + if let url = exportURL { + ShareSheet(activityItems: [url]) + } + } + } + + private func formatOption(format: ExportFormat, title: String, subtitle: String, icon: String) -> some View { + Button { + selectedFormat = format + } label: { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(selectedFormat == format ? .black : .accent) + .frame(width: 44, height: 44) + .background(selectedFormat == format ? Color.accent : Color.surface2) + .clipShape(RoundedRectangle(cornerRadius: Constants.Design.radiusSM)) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.appHeadline(16)) + .foregroundColor(selectedFormat == format ? .accent : .appText) + Text(subtitle) + .font(.appBody(12)) + .foregroundColor(.muted) + } + + Spacer() + + if selectedFormat == format { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.accent) + } + } + .padding(12) + .background(selectedFormat == format ? Color.accent.opacity(0.1) : Color.surface2.opacity(0.5)) + .cornerRadius(Constants.Design.radiusMD) + .overlay( + RoundedRectangle(cornerRadius: Constants.Design.radiusMD) + .stroke(selectedFormat == format ? Color.accent : Color.clear, lineWidth: 1) + ) + } + } + + private func performExport() { + isExporting = true + + Task { + do { + let url = try await ExportService.shared.exportRoll(roll, exposures: exposures, format: selectedFormat) + await MainActor.run { + self.exportURL = url + self.isExporting = false + self.showingShareSheet = true + } + } catch { + print("Export failed: \(error)") + await MainActor.run { + self.isExporting = false + } + } + } + } +} + +struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/ios/FilmTracker/Screens/Gallery/ExposureGalleryComponents.swift b/ios/FilmTracker/Screens/Gallery/ExposureGalleryComponents.swift new file mode 100644 index 0000000..d429ccf --- /dev/null +++ b/ios/FilmTracker/Screens/Gallery/ExposureGalleryComponents.swift @@ -0,0 +1,169 @@ +import SwiftUI +import SwiftData + +struct ExposureStripCard: View { + let exposure: Exposure + let onCopyPrevious: () -> Void + let onDelete: () -> Void + let isFirst: Bool + + var body: some View { + HStack(spacing: 12) { + // Thumbnail + ZStack(alignment: .topLeading) { + if let data = exposure.imageData, let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 92, height: 92) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(LinearGradient(colors: [.surface2, .surface1], startPoint: .topLeading, endPoint: .bottomTrailing)) + .frame(width: 92, height: 92) + + Image(systemName: "photo") + .foregroundColor(.dim) + .frame(width: 92, height: 92) + } + + // Exposure Number Overlay + Text("\(exposure.exposureNumber)") + .font(.custom("JetBrainsMono-Bold", size: 12)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.black.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(4) + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(exposure.capturedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.custom("JetBrainsMono-Regular", size: 10)) + .foregroundColor(.muted) + + Spacer() + + if !isFirst { + Button(action: onCopyPrevious) { + Text("COPY PREV") + .font(.custom("JetBrainsMono-Bold", size: 10)) + .foregroundColor(.accent) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accent.opacity(0.1)) + .clipShape(Capsule()) + } + } + } + + // EXIF Chips + HStack(spacing: 8) { + ExifChip(text: exposure.aperture) + ExifChip(text: exposure.shutterSpeed) + if let focal = exposure.focalLength { + ExifChip(text: "\(focal)mm") + } + } + + if let note = exposure.additionalInfo, !note.isEmpty { + Text(note) + .font(.custom("InterTight-Regular", size: 12)) + .foregroundColor(.appText) + .lineLimit(1) + } + } + + Spacer() + + // Delete button (visible on swipe usually, but adding a menu or button) + Menu { + Button(role: .destructive, action: onDelete) { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis") + .foregroundColor(.dim) + .padding(8) + } + } + .padding(12) + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.appText.opacity(0.05), lineWidth: 1) + ) + } +} + +struct ExifChip: View { + let text: String + + var body: some View { + Text(text) + .font(.custom("JetBrainsMono-Bold", size: 10)) + .foregroundColor(.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.surface2) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} + +struct ExposureGridCell: View { + let exposure: Exposure + + var body: some View { + ZStack(alignment: .bottomLeading) { + if let data = exposure.imageData, let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(minWidth: 0, maxWidth: .infinity) + .aspectRatio(1, contentMode: .fill) + .clipped() + } else { + Rectangle() + .fill(LinearGradient(colors: [.surface2, .surface1], startPoint: .topLeading, endPoint: .bottomTrailing)) + .aspectRatio(1, contentMode: .fill) + } + + // Exposure Number (top-left) + VStack { + HStack { + Text("\(exposure.exposureNumber)") + .font(.custom("JetBrainsMono-Bold", size: 12)) + .foregroundColor(.white) + .shadow(color: .black, radius: 2) + .padding(4) + Spacer() + + if exposure.location != nil { + Image(systemName: "location.fill") + .font(.system(size: 10)) + .foregroundColor(.white) + .shadow(color: .black, radius: 2) + .padding(4) + } + } + Spacer() + } + + // Aperture + Shutter (bottom-left) + HStack(spacing: 4) { + Text(exposure.aperture) + Text("·") + Text(exposure.shutterSpeed) + } + .font(.custom("JetBrainsMono-Bold", size: 10)) + .foregroundColor(.white) + .shadow(color: .black, radius: 2) + .padding(4) + } + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } +} diff --git a/ios/FilmTracker/Screens/Gallery/GalleryView.swift b/ios/FilmTracker/Screens/Gallery/GalleryView.swift new file mode 100644 index 0000000..33d4133 --- /dev/null +++ b/ios/FilmTracker/Screens/Gallery/GalleryView.swift @@ -0,0 +1,253 @@ +import SwiftUI +import SwiftData +import PhotosUI + +struct GalleryView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @State private var viewModel: GalleryViewModel + @State private var showingExportSheet = false + @State private var exposureToDelete: Exposure? + @State private var showingDeleteConfirmation = false + + @Binding var path: NavigationPath + + init(roll: FilmRoll, modelContext: ModelContext, path: Binding) { + _viewModel = State(initialValue: GalleryViewModel(roll: roll, modelContext: modelContext)) + _path = path + } + + var body: some View { + ZStack { + Color.appBg.ignoresSafeArea() + + VStack(spacing: 0) { + header + + quickActions + + if viewModel.exposures.isEmpty { + EmptyStateView( + iconName: "photo.on.rectangle", + title: "No exposures yet", + bodyText: "Start shooting or import photos from your library.", + actionTitle: "Add from gallery", + action: { + // Trigger PHPicker implicitly via state change if needed, + // but usually it's a PhotosPicker button. + } + ) + .padding(.top, 60) + } else { + ScrollView { + if viewModel.isGridView { + gridView + } else { + stripView + } + + filmLeader + } + } + + Spacer(minLength: 0) + } + } + .navigationBarHidden(true) + .sheet(isPresented: $showingExportSheet) { + ExportSheet(roll: viewModel.roll) + .presentationDetents([.height(300)]) + } + .confirmationDialog("Delete Exposure?", isPresented: $showingDeleteConfirmation, titleVisibility: .visible) { + Button("Delete", role: .destructive) { + if let exposure = exposureToDelete { + viewModel.deleteExposure(exposure) + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently remove this exposure.") + } + } + + private var header: some View { + VStack(spacing: 4) { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.title3) + .foregroundColor(.appText) + .padding(10) + .background(Color.surface1) + .clipShape(Circle()) + } + + Spacer() + + VStack(spacing: 2) { + Text("CONTACT SHEET") + .font(.custom("InterTight-Bold", size: 14)) + .foregroundColor(.muted) + Text("\(viewModel.rollProgress) · \(viewModel.rollMetadata)") + .font(.custom("JetBrainsMono-Bold", size: 10)) + .foregroundColor(.accent) + } + + Spacer() + + HStack(spacing: 12) { + Button { + showingExportSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + .font(.title3) + .foregroundColor(.accent) + } + .accessibilityIdentifier("exportButton") + + Button { + path = NavigationPath() + } label: { + Image(systemName: "house") + .font(.title3) + .foregroundColor(.appText) + .padding(10) + .background(Color.surface1) + .clipShape(Circle()) + } + } + } + .padding(.horizontal) + .padding(.top, 10) + + // Strip/Grid Toggle + HStack { + Spacer() + HStack(spacing: 0) { + Button { + withAnimation { viewModel.isGridView = false } + } label: { + Image(systemName: "rectangle.grid.1x2.fill") + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(!viewModel.isGridView ? Color.surface3 : Color.clear) + } + + Button { + withAnimation { viewModel.isGridView = true } + } label: { + Image(systemName: "square.grid.3x3.fill") + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(viewModel.isGridView ? Color.surface3 : Color.clear) + } + } + .foregroundColor(.appText) + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.appText.opacity(0.1), lineWidth: 1)) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + .background(Color.appBg) + } + + private var quickActions: some View { + HStack(spacing: 12) { + Button { + dismiss() // This assumes we came from CaptureView + } label: { + HStack { + Image(systemName: "camera.fill") + Text("Resume shooting") + } + .font(.custom("InterTight-Bold", size: 14)) + .foregroundColor(.black) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.accent) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + PhotosPicker( + selection: $viewModel.selectedPhotosPickerItems, + matching: .images, + photoLibrary: .shared() + ) { + HStack { + Image(systemName: "plus.circle.fill") + Text("Add from gallery") + } + .font(.custom("InterTight-Bold", size: 14)) + .foregroundColor(.appText) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.surface1) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .accessibilityIdentifier("addFromGalleryButton") + } + .padding(.horizontal) + .padding(.vertical, 12) + } + + private var stripView: some View { + LazyVStack(spacing: 12) { + ForEach(Array(viewModel.exposures.enumerated()), id: \.element.id) { index, exposure in + NavigationLink(value: exposure) { + ExposureStripCard( + exposure: exposure, + onCopyPrevious: { + viewModel.copyPrevious(to: exposure) + }, + onDelete: { + exposureToDelete = exposure + showingDeleteConfirmation = true + }, + isFirst: index == 0 + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal) + } + + private var gridView: some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) { + ForEach(viewModel.exposures) { exposure in + NavigationLink(value: exposure) { + ExposureGridCell(exposure: exposure) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 4) + } + + private var filmLeader: some View { + VStack(spacing: 8) { + let remaining = viewModel.roll.totalExposures - viewModel.exposures.count + if remaining > 0 { + Text("\(remaining) EXPOSURES REMAINING") + .font(.custom("JetBrainsMono-Bold", size: 12)) + .foregroundColor(Color(hex: Constants.Design.dim)) + .padding(.vertical, 40) + .frame(maxWidth: .infinity) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [5, 5])) + .foregroundColor(Color(hex: Constants.Design.dim).opacity(0.3)) + ) + .padding(.horizontal) + } + } + .padding(.top, 20) + .padding(.bottom, 40) + } +} + diff --git a/ios/FilmTracker/Screens/Rolls/FABMenu.swift b/ios/FilmTracker/Screens/Rolls/FABMenu.swift new file mode 100644 index 0000000..2f18807 --- /dev/null +++ b/ios/FilmTracker/Screens/Rolls/FABMenu.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct FABMenu: View { + @Environment(\.dismiss) private var dismiss + var onNewRoll: () -> Void + var onImport: () -> Void + var onResumeLast: () -> Void + + var body: some View { + VStack(spacing: 24) { + VStack(spacing: 8) { + Text("ACTIONS") + .font(.appMono(10)) + .foregroundColor(.muted) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 12) { + FABMenuItem(icon: "camera", title: "New roll", action: { dismiss(); onNewRoll() }) + .accessibilityIdentifier("fabNewRoll") + FABMenuItem(icon: "arrow.down.doc", title: "Import", action: { dismiss(); onImport() }) + .accessibilityIdentifier("fabImport") + FABMenuItem(icon: "clock", title: "Resume last", action: { dismiss(); onResumeLast() }) + .accessibilityIdentifier("fabResumeLast") + } + } + + AppButton(title: "Cancel", variant: .secondary) { + dismiss() + } + } + .padding(24) + .background(Color.surface1) + .cornerRadius(Constants.Design.radiusXL, corners: [.topLeft, .topRight]) + } +} + +struct FABMenuItem: View { + let icon: String + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 20)) + .foregroundColor(.accent) + .frame(width: 24) + + Text(title) + .font(.appHeadline(18)) + .foregroundColor(.appText) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.dim) + } + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + } + } +} diff --git a/ios/FilmTracker/Screens/Rolls/RollCard.swift b/ios/FilmTracker/Screens/Rolls/RollCard.swift new file mode 100644 index 0000000..dc692ef --- /dev/null +++ b/ios/FilmTracker/Screens/Rolls/RollCard.swift @@ -0,0 +1,161 @@ +import SwiftUI +import SwiftData + +struct RollCard: View { + let roll: FilmRoll + let onEdit: () -> Void + let onDelete: () -> Void + + @Query private var exposures: [Exposure] + @Query private var cameras: [Camera] + @Query private var lenses: [Lens] + + init(roll: FilmRoll, onEdit: @escaping () -> Void, onDelete: @escaping () -> Void) { + self.roll = roll + self.onEdit = onEdit + self.onDelete = onDelete + + let rollId = roll.id + _exposures = Query(filter: #Predicate { $0.filmRollId == rollId }, sort: \.exposureNumber, order: .reverse) + } + + private var lastExposureImage: UIImage? { + if let data = exposures.first?.imageData { + return UIImage(data: data) + } + return nil + } + + private var cameraName: String? { + cameras.first(where: { $0.id == roll.cameraId })?.name + } + + private var lensName: String? { + lenses.first(where: { $0.id == roll.currentLensId })?.name + } + + private var progress: Double { + guard roll.totalExposures > 0 else { return 0 } + return Double(exposures.count) / Double(roll.totalExposures) + } + + var body: some View { + AppCard { + HStack(spacing: 16) { + // Left: Thumbnail + ZStack(alignment: .topLeading) { + if let image = lastExposureImage { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 96, height: 96) + .clipped() + } else { + Rectangle() + .fill(Color.surface2) + .frame(width: 96, height: 96) + .overlay( + Image(systemName: "film") + .font(.system(size: 32)) + .foregroundColor(.dim) + ) + } + + Text("\(exposures.count)") + .font(.appMono(12)) + .fontWeight(.bold) + .padding(4) + .background(Color.black.opacity(0.6)) + .foregroundColor(.accent) + .cornerRadius(4) + .padding(4) + } + .frame(width: 96, height: 96) + .cornerRadius(Constants.Design.radiusMD) + + // Right: Details + VStack(alignment: .leading, spacing: 6) { + HStack { + if let tag = roll.tag { + AppChip(title: tag.uppercased(), variant: .accentGlow) + } + + Text(roll.name) + .font(.appHeadline(18)) + .foregroundColor(.appText) + .lineLimit(1) + + Spacer() + + Menu { + Button(action: onEdit) { + Label("Edit", systemImage: "pencil") + } + .accessibilityIdentifier("rollCardEditButton") + + Button(role: .destructive, action: onDelete) { + Label("Delete", systemImage: "trash") + } + .accessibilityIdentifier("rollCardDeleteButton") + } label: { + Image(systemName: "ellipsis") + .padding(8) + .foregroundColor(.muted) + .contentShape(Rectangle()) + .accessibilityIdentifier("rollCardMoreMenu") + } + } + + // Metadata line + Text(metadataString) + .font(.appMono(11)) + .foregroundColor(.muted) + .lineLimit(1) + + Spacer() + + // Progress + VStack(alignment: .leading, spacing: 4) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Rectangle() + .fill(Color.surface3) + .frame(height: 2) + + Rectangle() + .fill(Color.accent) + .frame(width: geo.size.width * min(progress, 1.0), height: 2) + } + } + .frame(height: 2) + + HStack { + Text("\(exposures.count)/\(roll.totalExposures)") + .font(.appMono(11)) + Spacer() + Text("\(Int(progress * 100))%") + .font(.appMono(11)) + } + .foregroundColor(.muted) + } + } + } + .padding(12) + } + .padding(.horizontal) + } + + private var metadataString: String { + var components: [String] = ["ISO \(roll.iso)"] + if let ei = roll.ei { + components.append("EI \(ei)") + } + if let camera = cameraName { + components.append(camera) + } + if let lens = lensName { + components.append(lens) + } + return components.joined(separator: " · ") + } +} diff --git a/ios/FilmTracker/Screens/Rolls/RollFormSheet.swift b/ios/FilmTracker/Screens/Rolls/RollFormSheet.swift new file mode 100644 index 0000000..a450886 --- /dev/null +++ b/ios/FilmTracker/Screens/Rolls/RollFormSheet.swift @@ -0,0 +1,271 @@ +import SwiftUI +import SwiftData + +struct RollFormSheet: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + var roll: FilmRoll? // Nil for create + var onSave: ((FilmRoll) -> Void)? = nil + + @Query private var cameras: [Camera] + @Query private var lenses: [Lens] + + @State private var name: String = "" + @State private var iso: Int = 400 + @State private var ei: Int? = nil + @State private var totalExposures: Int = 36 + @State private var cameraId: String? = nil + @State private var currentLensId: String? = nil + @State private var tag: String = "" + + @State private var customExposures: String = "" + @State private var isCustomExposures: Bool = false + + let filmPresets = [ + (name: "Kodak Portra 400", iso: 400), + (name: "Kodak Portra 160", iso: 160), + (name: "Fuji Superia 400", iso: 400), + (name: "Ilford HP5 Plus", iso: 400), + (name: "Kodak Tri-X 400", iso: 400), + (name: "Cinestill 800T", iso: 800) + ] + + let exposureOptions = [12, 24, 36] + + var isReady: Bool { + !name.trimmingCharacters(in: .whitespaces).isEmpty + } + + init(roll: FilmRoll? = nil, onSave: ((FilmRoll) -> Void)? = nil) { + self.roll = roll + self.onSave = onSave + if let roll = roll { + _name = State(initialValue: roll.name) + _iso = State(initialValue: roll.iso) + _ei = State(initialValue: roll.ei) + _totalExposures = State(initialValue: roll.totalExposures) + _cameraId = State(initialValue: roll.cameraId) + _currentLensId = State(initialValue: roll.currentLensId) + _tag = State(initialValue: roll.tag ?? "") + + if ![12, 24, 36].contains(roll.totalExposures) { + _isCustomExposures = State(initialValue: true) + _customExposures = State(initialValue: "\(roll.totalExposures)") + } + } + } + + var body: some View { + BottomSheet(isPresented: .constant(true), title: roll == nil ? "New film roll" : "Edit roll") { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // Presets + if roll == nil { + VStack(alignment: .leading, spacing: 8) { + Text("PRESETS") + .font(.appMono(10)) + .foregroundColor(.muted) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(filmPresets, id: \.name) { preset in + AppChip(title: preset.name, isSelected: name == preset.name) { + name = preset.name + iso = preset.iso + } + .accessibilityIdentifier("preset_\(preset.name.replacingOccurrences(of: " ", with: "_"))") + } + } + } + } + } + + // Name + VStack(alignment: .leading, spacing: 8) { + Text("ROLL NAME") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("e.g. Vacations 2024", text: $name) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("rollNameInput") + } + + // Tag (Optional) + VStack(alignment: .leading, spacing: 8) { + Text("TAG (OPTIONAL)") + .font(.appMono(10)) + .foregroundColor(.muted) + + TextField("e.g. PORTRA", text: $tag) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .tint(.accent) + .accessibilityIdentifier("rollTagInput") + } + + // ISO & Exposures + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 8) { + Text("ISO") + .font(.appMono(10)) + .foregroundColor(.muted) + + Menu { + ForEach(Constants.eiValues, id: \.self) { value in + Button("\(value)") { iso = value } + } + } label: { + HStack { + Text("\(iso)") + .font(.appMono()) + Spacer() + Image(systemName: "chevron.down") + .font(.system(size: 12)) + } + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + } + .accessibilityIdentifier("isoMenu") + } + + VStack(alignment: .leading, spacing: 8) { + Text("EXPOSURES") + .font(.appMono(10)) + .foregroundColor(.muted) + + HStack(spacing: 4) { + ForEach(exposureOptions, id: \.self) { opt in + AppChip(title: "\(opt)", isSelected: !isCustomExposures && totalExposures == opt) { + totalExposures = opt + isCustomExposures = false + } + .accessibilityIdentifier("exp_\(opt)") + } + AppChip(title: "Custom", isSelected: isCustomExposures) { + isCustomExposures = true + } + .accessibilityIdentifier("exp_custom") + } + } + } + + if isCustomExposures { + TextField("Count", text: $customExposures) + .keyboardType(.numberPad) + .padding() + .background(Color.surface2) + .cornerRadius(Constants.Design.radiusMD) + .foregroundColor(.appText) + .accessibilityIdentifier("customExposuresInput") + } + + // EI + VStack(alignment: .leading, spacing: 8) { + Text("EI (EXPOSURE INDEX)") + .font(.appMono(10)) + .foregroundColor(.muted) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + AppChip(title: "None", isSelected: ei == nil) { + ei = nil + } + .accessibilityIdentifier("ei_none") + ForEach(Constants.eiValues, id: \.self) { value in + AppChip(title: "\(value)", isSelected: ei == value) { + ei = value + } + .accessibilityIdentifier("ei_\(value)") + } + } + } + } + + // Camera + VStack(alignment: .leading, spacing: 8) { + Text("CAMERA") + .font(.appMono(10)) + .foregroundColor(.muted) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + AppChip(title: "None", isSelected: cameraId == nil) { + cameraId = nil + } + .accessibilityIdentifier("camera_none") + ForEach(cameras) { camera in + AppChip(title: camera.name, isSelected: cameraId == camera.id) { + cameraId = camera.id + } + .accessibilityIdentifier("camera_\(camera.id)") + } + } + } + } + + // Lens + VStack(alignment: .leading, spacing: 8) { + Text("LENS") + .font(.appMono(10)) + .foregroundColor(.muted) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + AppChip(title: "None", isSelected: currentLensId == nil) { + currentLensId = nil + } + .accessibilityIdentifier("lens_none") + ForEach(lenses) { lens in + AppChip(title: lens.name, isSelected: currentLensId == lens.id) { + currentLensId = lens.id + } + .accessibilityIdentifier("lens_\(lens.id)") + } + } + } + } + + AppButton(title: roll == nil ? "Start shooting" : "Save changes", isDisabled: !isReady) { + save() + } + .padding(.top, 8) + .accessibilityIdentifier("confirmRollFormButton") + } + .padding(.horizontal, 4) + } + } + .presentationDetents([.large]) + .presentationBackground(.clear) + } + + private func save() { + let finalExposures = isCustomExposures ? (Int(customExposures) ?? totalExposures) : totalExposures + let cleanedTag = tag.trimmingCharacters(in: .whitespaces).isEmpty ? nil : tag + + if let roll = roll { + roll.name = name + roll.iso = iso + roll.ei = ei + roll.totalExposures = finalExposures + roll.cameraId = cameraId + roll.currentLensId = currentLensId + roll.tag = cleanedTag + onSave?(roll) + } else { + let newRoll = FilmRoll(name: name, iso: iso, ei: ei, totalExposures: finalExposures, cameraId: cameraId, currentLensId: currentLensId, tag: cleanedTag) + modelContext.insert(newRoll) + onSave?(newRoll) + } + dismiss() + } +} diff --git a/ios/FilmTracker/Screens/Rolls/RollsView.swift b/ios/FilmTracker/Screens/Rolls/RollsView.swift new file mode 100644 index 0000000..4a707d7 --- /dev/null +++ b/ios/FilmTracker/Screens/Rolls/RollsView.swift @@ -0,0 +1,258 @@ +import SwiftUI +import SwiftData + +enum RollFilter: String, CaseIterable { + case all = "All" + case active = "Active" + case complete = "Complete" +} + +struct FilterPillsView: View { + @Binding var selectedFilter: RollFilter + let counts: [RollFilter: Int] + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(RollFilter.allCases, id: \.self) { filter in + Button { + selectedFilter = filter + } label: { + HStack(spacing: 6) { + Text(filter.rawValue) + Text("\(counts[filter] ?? 0)") + .font(.appMono(10)) + .opacity(0.6) + } + .font(.appHeadline(14)) + .foregroundColor(selectedFilter == filter ? .black : .appText) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(selectedFilter == filter ? Color.accent : Color.surface1) + .cornerRadius(Constants.Design.radiusPill) + .overlay( + Capsule() + .stroke(Color.appText.opacity(0.1), lineWidth: 1) + ) + } + .accessibilityIdentifier("filter_\(filter.rawValue.lowercased())") + } + } + .padding(.horizontal) + } + } +} + +struct GalleryDestination: Hashable { + let roll: FilmRoll +} + +struct RollsView: View { + @Environment(\.modelContext) private var modelContext + @Query(sort: \FilmRoll.createdAt, order: .reverse) private var allRolls: [FilmRoll] + @Query private var allExposures: [Exposure] + + @State private var selectedFilter: RollFilter = .all + @State private var showingForm = false + @State private var showingFABMenu = false + @State private var rollToEdit: FilmRoll? + @State private var rollToDelete: FilmRoll? + @State private var showingDeleteConfirmation = false + @State private var navigationPath = NavigationPath() + + // Import handling + @State private var showingDocumentPicker = false + @State private var documentPickerMode: UIDocumentPickerMode = .import + @State private var importErrorMessage: String? + @State private var showingImportError = false + + var filteredRolls: [FilmRoll] { + allRolls.filter { roll in + let rollId = roll.id + let exposureCount = allExposures.filter { $0.filmRollId == rollId }.count + switch selectedFilter { + case .all: return true + case .active: return exposureCount < roll.totalExposures + case .complete: return exposureCount >= roll.totalExposures + } + } + } + + var filterCounts: [RollFilter: Int] { + var counts: [RollFilter: Int] = [:] + counts[.all] = allRolls.count + let activeCount = allRolls.filter { roll in + let rollId = roll.id + return allExposures.filter { $0.filmRollId == rollId }.count < roll.totalExposures + }.count + counts[.active] = activeCount + counts[.complete] = allRolls.count - activeCount + return counts + } + + var body: some View { + NavigationStack(path: $navigationPath) { + ZStack(alignment: .bottomTrailing) { + Color.appBg.ignoresSafeArea() + + VStack(spacing: 0) { + // Filter Header + FilterPillsView(selectedFilter: $selectedFilter, counts: filterCounts) + .padding(.vertical, 12) + + if filteredRolls.isEmpty { + EmptyStateView( + iconName: "film", + title: "No rolls found", + bodyText: selectedFilter == .all ? "Start your photography journey by adding your first film roll." : "No rolls match the selected filter.", + actionTitle: "Add film roll", + action: { + showingForm = true + } + ) + .padding(.top, 40) + } else { + ScrollView { + LazyVStack(spacing: 12) { + ForEach(filteredRolls) { roll in + NavigationLink(value: roll) { + RollCard( + roll: roll, + onEdit: { rollToEdit = roll }, + onDelete: { rollToDelete = roll; showingDeleteConfirmation = true } + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier(roll.name) + } + } + .padding(.vertical, 12) + .padding(.bottom, 80) // Space for FAB + } + } + + Spacer(minLength: 0) + } + + // FAB + Button { + showingFABMenu = true + } label: { + Image(systemName: "plus") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.black) + .frame(width: 60, height: 60) + .background(Color.accent) + .clipShape(Circle()) + .shadow(color: Color.accent.opacity(0.3), radius: 10, x: 0, y: 5) + } + .padding(24) + .accessibilityIdentifier("addRollFAB") + } + .navigationDestination(for: FilmRoll.self) { roll in + let rollId = roll.id + let exposureCount = allExposures.filter { $0.filmRollId == rollId }.count + if exposureCount >= roll.totalExposures { + GalleryView(roll: roll, modelContext: modelContext, path: $navigationPath) + } else { + CaptureView(roll: roll, modelContext: modelContext, path: $navigationPath) + } + } + .navigationDestination(for: GalleryDestination.self) { destination in + GalleryView(roll: destination.roll, modelContext: modelContext, path: $navigationPath) + } + .navigationDestination(for: Exposure.self) { exposure in + DetailsView(exposure: exposure, modelContext: modelContext) + } + .navigationTitle("Rolls") + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + showingDocumentPicker = true + } label: { + Image(systemName: "square.and.arrow.down") + .foregroundColor(.accent) + } + } + + ToolbarItem(placement: .topBarTrailing) { + NavigationLink(destination: SettingsView()) { + Image(systemName: "gearshape") + .foregroundColor(.appText) + } + } + } + .sheet(isPresented: $showingForm) { + RollFormSheet(onSave: { newRoll in + navigationPath.append(newRoll) + }) + } + .sheet(item: $rollToEdit) { roll in + RollFormSheet(roll: roll) + } + .sheet(isPresented: $showingFABMenu) { + FABMenu( + onNewRoll: { showingForm = true }, + onImport: { showingDocumentPicker = true }, + onResumeLast: { + if let lastRoll = allRolls.first { + navigationPath.append(lastRoll) + } + } + ) + .presentationDetents([.height(340)]) + .presentationBackground(.clear) + } + .sheet(isPresented: $showingDocumentPicker) { + DocumentPicker { urls in + guard let url = urls.first else { return } + Task { + do { + if url.hasDirectoryPath { + try await ImportService.shared.importFromFolder(url: url, modelContext: modelContext) + } else { + try await ImportService.shared.importFromJSON(url: url, modelContext: modelContext) + } + } catch { + importErrorMessage = error.localizedDescription + showingImportError = true + } + } + } + } + .alert("Import Failed", isPresented: $showingImportError) { + Button("OK", role: .cancel) { } + } message: { + Text(importErrorMessage ?? "An unknown error occurred during import.") + } + .sheet(isPresented: $showingDeleteConfirmation) { + ConfirmationSheet( + title: "Delete Roll?", + message: "This will permanently delete '\(rollToDelete?.name ?? "")' and all its exposures. This cannot be undone.", + confirmTitle: "Delete", + isDestructive: true, + onConfirm: { + if let roll = rollToDelete { + deleteRoll(roll) + } + showingDeleteConfirmation = false + }, + onCancel: { + showingDeleteConfirmation = false + } + ) + .presentationDetents([.height(300)]) + } + + } + } + + private func deleteRoll(_ roll: FilmRoll) { + let rollId = roll.id + let exposuresToDelete = allExposures.filter { $0.filmRollId == rollId } + for exposure in exposuresToDelete { + modelContext.delete(exposure) + } + modelContext.delete(roll) + } +} diff --git a/ios/FilmTracker/Screens/Settings/SettingsView.swift b/ios/FilmTracker/Screens/Settings/SettingsView.swift new file mode 100644 index 0000000..5c25091 --- /dev/null +++ b/ios/FilmTracker/Screens/Settings/SettingsView.swift @@ -0,0 +1,235 @@ +import SwiftUI +import SwiftData + +struct SettingsView: View { + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + + @Query private var settings: [AppSettings] + @Query private var cameras: [Camera] + @Query private var lenses: [Lens] + @Query private var rolls: [FilmRoll] + @Query private var exposures: [Exposure] + + @State private var showingClearConfirmation = false + + private var currentSettings: AppSettings { + if let first = settings.first { + return first + } else { + let newSettings = AppSettings() + modelContext.insert(newSettings) + return newSettings + } + } + + var body: some View { + ZStack { + Color.appBg.ignoresSafeArea() + + VStack(spacing: 0) { + header + + ScrollView { + VStack(spacing: 24) { + // Capture Section + settingsSection(title: "CAPTURE") { + ToggleRow( + icon: "grid", + title: "Rule-of-thirds grid", + isOn: Binding( + get: { currentSettings.gridEnabled }, + set: { currentSettings.gridEnabled = $0 } + ) + ) + + ToggleRow( + icon: "location", + title: "Location tagging", + isOn: Binding( + get: { currentSettings.locationEnabled }, + set: { currentSettings.locationEnabled = $0 } + ) + ) + + ToggleRow( + icon: "waveform", + title: "Haptic feedback", + isOn: Binding( + get: { currentSettings.hapticsEnabled }, + set: { currentSettings.hapticsEnabled = $0 } + ) + ) + } + + // Sync Section + settingsSection(title: "SYNC") { + HStack { + Image(systemName: "icloud") + .foregroundColor(.dim) + .frame(width: 24) + Text("Google Drive Sync") + .font(.appBody(16)) + .foregroundColor(.muted) + Spacer() + Text("Coming soon") + .font(.appMono(10)) + .foregroundColor(.dim) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.surface2) + .cornerRadius(4) + } + .padding(.vertical, 8) + } + + // Data Section + settingsSection(title: "DATA MANAGEMENT") { + SettingsActionRow(icon: "square.and.arrow.up", title: "Export all rolls") { + // Multi-roll export logic if needed, or just a hint + } + .opacity(0.5) // Placeholder + + SettingsActionRow(icon: "trash", title: "Clear all data", isDestructive: true) { + showingClearConfirmation = true + } + } + + // About Section + settingsSection(title: "ABOUT") { + HStack { + Text("Version") + .font(.appBody(16)) + Spacer() + Text(currentSettings.version) + .font(.appMono(12)) + .foregroundColor(.muted) + } + .padding(.vertical, 8) + + Link(destination: URL(string: "https://github.com/nikitazavartsev/film-meta-tracker")!) { + HStack { + Text("Open Source") + .font(.appBody(16)) + Spacer() + Image(systemName: "arrow.up.right") + .font(.system(size: 12)) + } + .foregroundColor(.accent) + } + .padding(.vertical, 8) + } + } + .padding(20) + } + } + } + .navigationBarHidden(true) + .confirmationDialog("Clear All Data?", isPresented: $showingClearConfirmation, titleVisibility: .visible) { + Button("Delete Everything", role: .destructive) { + clearAllData() + } + Button("Cancel", role: .cancel) { } + } message: { + Text("This will permanently delete all cameras, lenses, film rolls, and exposures. This action cannot be undone.") + } + } + + private var header: some View { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.title3) + .foregroundColor(.appText) + .padding(10) + .background(Color.surface1) + .clipShape(Circle()) + } + + Spacer() + + Text("SETTINGS") + .font(.appHeadline(16)) + .foregroundColor(.appText) + Spacer() + + // Empty spacer to balance header + Color.clear.frame(width: 44, height: 44) + } + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 12) + .background(Color.appBg) + } + + private func settingsSection(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.appMono(10)) + .foregroundColor(.muted) + + VStack(spacing: 0) { + content() + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.surface1) + .cornerRadius(Constants.Design.radiusMD) + } + } + + private func clearAllData() { + for camera in cameras { modelContext.delete(camera) } + for lens in lenses { modelContext.delete(lens) } + for roll in rolls { modelContext.delete(roll) } + for exposure in exposures { modelContext.delete(exposure) } + try? modelContext.save() + } +} + +struct ToggleRow: View { + let icon: String + let title: String + @Binding var isOn: Bool + + var body: some View { + Toggle(isOn: $isOn) { + HStack(spacing: 12) { + Image(systemName: icon) + .foregroundColor(.accent) + .frame(width: 24) + Text(title) + .font(.appBody(16)) + } + } + .tint(.accent) + .padding(.vertical, 10) + } +} + +struct SettingsActionRow: View { + let icon: String + let title: String + var isDestructive: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .foregroundColor(isDestructive ? .red : .accent) + .frame(width: 24) + Text(title) + .font(.appBody(16)) + .foregroundColor(isDestructive ? .red : .appText) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(.dim) + } + .padding(.vertical, 12) + } + } +} diff --git a/ios/FilmTracker/Services/CameraService.swift b/ios/FilmTracker/Services/CameraService.swift new file mode 100644 index 0000000..db53c6f --- /dev/null +++ b/ios/FilmTracker/Services/CameraService.swift @@ -0,0 +1,131 @@ +import AVFoundation +import UIKit +import Observation + +@Observable +final class CameraService: NSObject { + enum CameraError: Error { + case notAuthorized + case sessionSetupFailed + case captureFailed + } + + var session = AVCaptureSession() + private let photoOutput = AVCapturePhotoOutput() + private var isSessionRunning = false + private let sessionQueue = DispatchQueue(label: "com.filmtracker.camera.session") + + var captureCompletion: ((Data?) -> Void)? + + override init() { + super.init() + } + + func checkPermission() async -> Bool { + #if targetEnvironment(simulator) + return true + #endif + let status = AVCaptureDevice.authorizationStatus(for: .video) + switch status { + case .authorized: + return true + case .notDetermined: + return await AVCaptureDevice.requestAccess(for: .video) + default: + return false + } + } + + func setupSession() { + #if targetEnvironment(simulator) + return + #endif + sessionQueue.async { [weak self] in + guard let self = self else { return } + + self.session.beginConfiguration() + + // Input + guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back), + let videoDeviceInput = try? AVCaptureDeviceInput(device: videoDevice), + self.session.canAddInput(videoDeviceInput) else { + print("Could not add video device input to the session") + self.session.commitConfiguration() + return + } + self.session.addInput(videoDeviceInput) + + // Output + if self.session.canAddOutput(self.photoOutput) { + self.session.addOutput(self.photoOutput) + self.photoOutput.isHighResolutionCaptureEnabled = true + } else { + print("Could not add photo output to the session") + self.session.commitConfiguration() + return + } + + self.session.commitConfiguration() + } + } + + func start() { + #if targetEnvironment(simulator) + return + #endif + sessionQueue.async { [weak self] in + guard let self = self else { return } + if !self.session.isRunning { + self.session.startRunning() + } + } + } + + func stop() { + #if targetEnvironment(simulator) + return + #endif + sessionQueue.async { [weak self] in + guard let self = self else { return } + if self.session.isRunning { + self.session.stopRunning() + } + } + } + + func capturePhoto(completion: @escaping (Data?) -> Void) { + #if targetEnvironment(simulator) + completion(UIImage(systemName: "photo")?.jpegData(compressionQuality: 0.75)) + return + #endif + self.captureCompletion = completion + + sessionQueue.async { [weak self] in + guard let self = self else { return } + + let settings = AVCapturePhotoSettings() + if let photoOutputConnection = self.photoOutput.connection(with: .video) { + // Ensure orientation is correct if needed, but for now we keep it simple + } + + self.photoOutput.capturePhoto(with: settings, delegate: self) + } + } +} + +extension CameraService: AVCapturePhotoCaptureDelegate { + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + if let error = error { + print("Error capturing photo: \(error.localizedDescription)") + captureCompletion?(nil) + return + } + + guard let imageData = photo.fileDataRepresentation() else { + captureCompletion?(nil) + return + } + + captureCompletion?(imageData) + } +} diff --git a/ios/FilmTracker/Services/ExportService.swift b/ios/FilmTracker/Services/ExportService.swift new file mode 100644 index 0000000..ad15df7 --- /dev/null +++ b/ios/FilmTracker/Services/ExportService.swift @@ -0,0 +1,151 @@ +import Foundation +import SwiftData +import UIKit + +enum ExportFormat { + case jsonOnly + case jsonWithImages + case archive // Folder with metadata.json and separate .jpg files +} + +struct ExportMetadata: Codable { + let filmRoll: ExportFilmRoll + let exposures: [ExportExposure] + let exportedAt: Date + let version: String +} + +struct ExportFilmRoll: Codable { + let id: String + let name: String + let iso: Int + let ei: Int? + let totalExposures: Int + let cameraId: String? + let currentLensId: String? + let createdAt: Date + let tag: String? + + init(roll: FilmRoll) { + self.id = roll.id + self.name = roll.name + self.iso = roll.iso + self.ei = roll.ei + self.totalExposures = roll.totalExposures + self.cameraId = roll.cameraId + self.currentLensId = roll.currentLensId + self.createdAt = roll.createdAt + self.tag = roll.tag + } +} + +struct ExportExposure: Codable { + let id: String + let filmRollId: String + let exposureNumber: Int + let aperture: String + let shutterSpeed: String + let additionalInfo: String? + let imageData: String? // Base64 if jsonWithImages + let location: ExportLocation? + let capturedAt: Date + let ei: Int? + let lensId: String? + let focalLength: Int? + + init(exposure: Exposure, includeImages: Bool = false) { + self.id = exposure.id + self.filmRollId = exposure.filmRollId + self.exposureNumber = exposure.exposureNumber + self.aperture = exposure.aperture + self.shutterSpeed = exposure.shutterSpeed + self.additionalInfo = exposure.additionalInfo + + if includeImages, let data = exposure.imageData { + self.imageData = data.base64EncodedString() + } else { + self.imageData = nil + } + + if let lat = exposure.latitude, let lon = exposure.longitude { + self.location = ExportLocation(latitude: lat, longitude: lon) + } else { + self.location = nil + } + + self.capturedAt = exposure.capturedAt + self.ei = exposure.ei + self.lensId = exposure.lensId + self.focalLength = exposure.focalLength + } +} + +struct ExportLocation: Codable { + let latitude: Double + let longitude: Double +} + +final class ExportService { + static let shared = ExportService() + + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + return encoder + }() + + func exportRoll(_ roll: FilmRoll, exposures: [Exposure], format: ExportFormat) async throws -> URL { + let exportMetadata = ExportMetadata( + filmRoll: ExportFilmRoll(roll: roll), + exposures: exposures.map { ExportExposure(exposure: $0, includeImages: format == .jsonWithImages) }, + exportedAt: Date(), + version: "2.0.0" + ) + + let tempDir = FileManager.default.temporaryDirectory + let baseFileName = roll.name.replacingOccurrences(of: " ", with: "_").lowercased() + + switch format { + case .jsonOnly, .jsonWithImages: + let data = try encoder.encode(exportMetadata) + let fileURL = tempDir.appendingPathComponent("\(baseFileName).json") + try data.write(to: fileURL) + return fileURL + + case .archive: + // For archive, we create a folder, put metadata.json (without images) and all images as .jpg + let folderURL = tempDir.appendingPathComponent("\(baseFileName)_export") + if FileManager.default.fileExists(atPath: folderURL.path) { + try FileManager.default.removeItem(at: folderURL) + } + try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true) + + // Metadata without images + let metadataOnly = ExportMetadata( + filmRoll: ExportFilmRoll(roll: roll), + exposures: exposures.map { ExportExposure(exposure: $0, includeImages: false) }, + exportedAt: Date(), + version: "2.0.0" + ) + let metadataData = try encoder.encode(metadataOnly) + try metadataData.write(to: folderURL.appendingPathComponent("metadata.json")) + + // Images + for exposure in exposures { + if let imageData = exposure.imageData { + let imageFileName = "exposure_\(exposure.exposureNumber)_\(exposure.id).jpg" + try imageData.write(to: folderURL.appendingPathComponent(imageFileName)) + } + } + + // In a real app we might zip it, but UIActivityViewController can share folders or multiple files. + // However, global-plan mentions "zip" for multi-file. + // Let's implement a simple zip if possible, or just return the folder. + // SwiftUI's UIActivityViewController handles folders okay, but zip is safer. + // For now, I'll stick to the folder or try to find a simple way to zip without external libs. + // Actually, returning folder is fine for iOS share sheet. + return folderURL + } + } +} diff --git a/ios/FilmTracker/Services/ImportService.swift b/ios/FilmTracker/Services/ImportService.swift new file mode 100644 index 0000000..e622656 --- /dev/null +++ b/ios/FilmTracker/Services/ImportService.swift @@ -0,0 +1,138 @@ +import Foundation +import SwiftData +import UIKit + +enum ImportErrors: Error { + case invalidJSON + case missingMetadata + case fileAccessError +} + +final class ImportService { + static let shared = ImportService() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + func importFromJSON(url: URL, modelContext: ModelContext) async throws { + // Start accessing security-scoped resource if needed (from UIDocumentPicker) + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + let data = try Data(contentsOf: url) + let metadata = try decoder.decode(ExportMetadata.self, from: data) + + try await processImport(metadata: metadata, modelContext: modelContext) + } + + func importFromFolder(url: URL, modelContext: ModelContext) async throws { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + + let metadataURL = url.appendingPathComponent("metadata.json") + guard FileManager.default.fileExists(atPath: metadataURL.path) else { + throw ImportErrors.missingMetadata + } + + let data = try Data(contentsOf: metadataURL) + var metadata = try decoder.decode(ExportMetadata.self, from: data) + + // Match images from folder + let updatedExposures = metadata.exposures.map { exp -> ExportExposure in + let imageFileName = "exposure_\(exp.exposureNumber)_\(exp.id).jpg" + let imageURL = url.appendingPathComponent(imageFileName) + + if FileManager.default.fileExists(atPath: imageURL.path), + let imageData = try? Data(contentsOf: imageURL), + let downscaled = ImageUtils.downscale(imageData) { + return ExportExposure( + id: exp.id, + filmRollId: exp.filmRollId, + exposureNumber: exp.exposureNumber, + aperture: exp.aperture, + shutterSpeed: exp.shutterSpeed, + additionalInfo: exp.additionalInfo, + imageData: downscaled.base64EncodedString(), + location: exp.location, + capturedAt: exp.capturedAt, + ei: exp.ei, + lensId: exp.lensId, + focalLength: exp.focalLength + ) + } + return exp + } + + metadata = ExportMetadata( + filmRoll: metadata.filmRoll, + exposures: updatedExposures, + exportedAt: metadata.exportedAt, + version: metadata.version + ) + + try await processImport(metadata: metadata, modelContext: modelContext) + } + + private func processImport(metadata: ExportMetadata, modelContext: ModelContext) async throws { + // Create FilmRoll + let roll = FilmRoll( + id: UUID().uuidString, // New ID for imported roll to avoid collisions + name: "[IMPORTED] \(metadata.filmRoll.name)", + iso: metadata.filmRoll.iso, + ei: metadata.filmRoll.ei, + totalExposures: metadata.filmRoll.totalExposures, + cameraId: metadata.filmRoll.cameraId, + currentLensId: metadata.filmRoll.currentLensId, + createdAt: metadata.filmRoll.createdAt, + tag: "IMPORTED" + ) + + modelContext.insert(roll) + + // Create Exposures + for exp in metadata.exposures { + let imageData = exp.imageData.flatMap { Data(base64Encoded: $0) } + let downscaled = imageData.flatMap { ImageUtils.downscale($0) } + + let exposure = Exposure( + id: UUID().uuidString, // New ID + filmRollId: roll.id, + exposureNumber: exp.exposureNumber, + aperture: exp.aperture, + shutterSpeed: exp.shutterSpeed, + additionalInfo: exp.additionalInfo, + imageData: downscaled, + latitude: exp.location?.latitude, + longitude: exp.location?.longitude, + capturedAt: exp.capturedAt, + ei: exp.ei, + lensId: exp.lensId, + focalLength: exp.focalLength + ) + modelContext.insert(exposure) + } + + try modelContext.save() + } +} + +// Extension to help creating ExportExposure manually in folder import loop +extension ExportExposure { + init(id: String, filmRollId: String, exposureNumber: Int, aperture: String, shutterSpeed: String, additionalInfo: String?, imageData: String?, location: ExportLocation?, capturedAt: Date, ei: Int?, lensId: String?, focalLength: Int?) { + self.id = id + self.filmRollId = filmRollId + self.exposureNumber = exposureNumber + self.aperture = aperture + self.shutterSpeed = shutterSpeed + self.additionalInfo = additionalInfo + self.imageData = imageData + self.location = location + self.capturedAt = capturedAt + self.ei = ei + self.lensId = lensId + self.focalLength = focalLength + } +} diff --git a/ios/FilmTracker/Services/LocationService.swift b/ios/FilmTracker/Services/LocationService.swift new file mode 100644 index 0000000..1d366bf --- /dev/null +++ b/ios/FilmTracker/Services/LocationService.swift @@ -0,0 +1,43 @@ +import Foundation +import CoreLocation +import Observation + +@Observable +final class LocationService: NSObject, CLLocationManagerDelegate { + private let manager = CLLocationManager() + var lastLocation: CLLocation? + var authorizationStatus: CLAuthorizationStatus = .notDetermined + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + authorizationStatus = manager.authorizationStatus + } + + func requestPermission() { + manager.requestWhenInUseAuthorization() + } + + func startUpdating() { + manager.startUpdatingLocation() + } + + func stopUpdating() { + manager.stopUpdatingLocation() + } + + // MARK: - CLLocationManagerDelegate + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + authorizationStatus = manager.authorizationStatus + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + lastLocation = locations.last + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("Location manager failed with error: \(error.localizedDescription)") + } +} diff --git a/ios/FilmTracker/Utils/ImageUtils.swift b/ios/FilmTracker/Utils/ImageUtils.swift new file mode 100644 index 0000000..99134e1 --- /dev/null +++ b/ios/FilmTracker/Utils/ImageUtils.swift @@ -0,0 +1,35 @@ +import UIKit + +enum ImageUtils { + static func downscale(_ image: UIImage) -> Data? { + let maxDimension: CGFloat = 1280 + let size = image.size + + let widthRatio = maxDimension / size.width + let heightRatio = maxDimension / size.height + + var newSize: CGSize + if widthRatio < heightRatio { + newSize = CGSize(width: size.width * widthRatio, height: size.height * widthRatio) + } else { + newSize = CGSize(width: size.width * heightRatio, height: size.height * heightRatio) + } + + if size.width <= maxDimension && size.height <= maxDimension { + return image.jpegData(compressionQuality: 0.75) + } + + let rect = CGRect(origin: .zero, size: newSize) + UIGraphicsBeginImageContextWithOptions(newSize, false, 1.0) + image.draw(in: rect) + let newImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + return newImage?.jpegData(compressionQuality: 0.75) + } + + static func downscale(_ data: Data) -> Data? { + guard let image = UIImage(data: data) else { return nil } + return downscale(image) + } +} diff --git a/ios/FilmTracker/ViewModels/CaptureViewModel.swift b/ios/FilmTracker/ViewModels/CaptureViewModel.swift new file mode 100644 index 0000000..04861dc --- /dev/null +++ b/ios/FilmTracker/ViewModels/CaptureViewModel.swift @@ -0,0 +1,228 @@ +import Foundation +import SwiftData +import Observation +import CoreLocation + +@Observable +final class CaptureViewModel { + var roll: FilmRoll + var modelContext: ModelContext + + var cameraService = CameraService() + var locationService = LocationService() + + var currentAperture: String = "f/8" + var currentShutterSpeed: String = "1/125" + var currentEI: Int = 400 + var currentFocalLength: Int = 50 + var currentLens: Lens? { + didSet { + roll.currentLensId = currentLens?.id + if let lens = currentLens { + if let focal = lens.focalLength { + currentFocalLength = focal + } else if let min = lens.focalLengthMin { + currentFocalLength = Swift.max(min, Swift.min(currentFocalLength, lens.focalLengthMax ?? min)) + } + + // Ensure current aperture is valid for the new lens + if !filteredApertures.contains(currentAperture) { + currentAperture = lens.maxAperture + } + } + } + } + + var filteredApertures: [String] { + guard let lens = currentLens else { return Constants.apertures } + + let lensMax = lens.maxAperture + // Constants.apertures are ordered from f/1.4 to f/22 + // We want to find the index of lensMax and return all values from that index onwards. + if let index = Constants.apertures.firstIndex(of: lensMax) { + return Array(Constants.apertures[index...]) + } + + // If lensMax is not in constants (unlikely if UI prevents it), just return all + return Constants.apertures + } + + var focalLengthOptions: [String] { + if let lens = currentLens { + if lens.isZoom { + let min = lens.focalLengthMin! + let max = lens.focalLengthMax! + // Generate steps of 5mm + var steps: [Int] = [] + var current = min + while current <= max { + steps.append(current) + current += 5 + } + if steps.last != max { steps.append(max) } + return steps.map { "\($0)mm" } + } else { + return ["\(lens.focalLength!)mm"] + } + } + return Constants.focalPresets.map { "\($0)mm" } + } + + var showGrid: Bool = false + var showFrameLines: Bool = false + var pendingNote: String? + + var isCapturing: Bool = false + var lastExposureThumbnail: Data? + + init(roll: FilmRoll, modelContext: ModelContext) { + self.roll = roll + self.modelContext = modelContext + self.currentEI = roll.ei ?? roll.iso + + // Load last exposure settings if available + fetchLastExposure() + + // Initialize lens + if let lensId = roll.currentLensId { + fetchLens(id: lensId) + } + } + + func fetchLastExposure() { + let rollId = roll.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filmRollId == rollId }, + sortBy: [SortDescriptor(\.exposureNumber, order: .reverse)] + ) + + if let lastExposure = try? modelContext.fetch(descriptor).first { + currentAperture = lastExposure.aperture + currentShutterSpeed = lastExposure.shutterSpeed + currentEI = lastExposure.ei ?? roll.ei ?? roll.iso + currentFocalLength = lastExposure.focalLength ?? 50 + lastExposureThumbnail = lastExposure.imageData + } + } + + func fetchLens(id: String) { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + currentLens = try? modelContext.fetch(descriptor).first + if let lens = currentLens { + if let focal = lens.focalLength { + currentFocalLength = focal + } else if let min = lens.focalLengthMin { + currentFocalLength = Swift.max(min, Swift.min(currentFocalLength, lens.focalLengthMax ?? min)) + } + } + } + + func capture() { + guard !isCapturing else { return } + isCapturing = true + + cameraService.capturePhoto { [weak self] rawData in + guard let self = self, let data = rawData else { + self?.isCapturing = false + return + } + + Task { + // Downscale image + guard let downscaledData = ImageUtils.downscale(data) else { + await MainActor.run { self.isCapturing = false } + return + } + + let location = self.locationService.lastLocation + + await MainActor.run { + self.saveExposure(imageData: downscaledData, location: location) + self.isCapturing = false + } + } + } + } + + private func saveExposure(imageData: Data, location: CLLocation?) { + let nextNumber = currentExposureCount() + 1 + + let exposure = Exposure( + filmRollId: roll.id, + exposureNumber: nextNumber, + aperture: currentAperture, + shutterSpeed: currentShutterSpeed, + imageData: imageData, + latitude: location?.coordinate.latitude, + longitude: location?.coordinate.longitude, + ei: currentEI, + lensId: currentLens?.id, + focalLength: currentFocalLength + ) + + if let note = pendingNote { + exposure.additionalInfo = note + pendingNote = nil + } + + modelContext.insert(exposure) + lastExposureThumbnail = imageData + + // Haptic feedback could be triggered here or in the View + } + + func currentExposureCount() -> Int { + let rollId = roll.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filmRollId == rollId } + ) + return (try? modelContext.fetchCount(descriptor)) ?? 0 + } + + var exposureProgress: String { + let count = currentExposureCount() + return "\(count) / \(roll.totalExposures)" + } + + var isRollFull: Bool { + currentExposureCount() >= roll.totalExposures + } + + // MARK: - Light Meter Logic + + var currentEV: Double { + let n = apertureValue(currentAperture) + let t = shutterSpeedValue(currentShutterSpeed) + + // EV = log2(N^2 / t) + // ISO adjustment: EV_S = EV_100 + log2(S / 100) + // But for a simple simulated meter, we just show the EV for the current settings + return log2(pow(n, 2) / t) + } + + private func apertureValue(_ s: String) -> Double { + // "f/8" -> 8.0 + let cleaned = s.replacingOccurrences(of: "f/", with: "") + return Double(cleaned) ?? 1.0 + } + + private func shutterSpeedValue(_ s: String) -> Double { + // "1/125" -> 0.008 + // "1\"" -> 1.0 + // "BULB" -> 1.0 + if s == "BULB" { return 1.0 } + if s.contains("\"") { + let cleaned = s.replacingOccurrences(of: "\"", with: "") + return Double(cleaned) ?? 1.0 + } + if s.contains("/") { + let parts = s.components(separatedBy: "/") + if parts.count == 2, let den = Double(parts[1]) { + return 1.0 / den + } + } + return Double(s) ?? 1.0 + } +} diff --git a/ios/FilmTracker/ViewModels/EquipmentViewModels.swift b/ios/FilmTracker/ViewModels/EquipmentViewModels.swift new file mode 100644 index 0000000..c8b6db2 --- /dev/null +++ b/ios/FilmTracker/ViewModels/EquipmentViewModels.swift @@ -0,0 +1,39 @@ +import SwiftUI +import SwiftData +import Observation + +@Observable +final class CameraViewModel { + var modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func addCamera(make: String, model: String) { + let camera = Camera(make: make, model: model) + modelContext.insert(camera) + } + + func deleteCamera(_ camera: Camera) { + modelContext.delete(camera) + } +} + +@Observable +final class LensViewModel { + var modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func addLens(name: String, maxAperture: String, focalLength: Int? = nil, focalLengthMin: Int? = nil, focalLengthMax: Int? = nil) { + let lens = Lens(name: name, maxAperture: maxAperture, focalLength: focalLength, focalLengthMin: focalLengthMin, focalLengthMax: focalLengthMax) + modelContext.insert(lens) + } + + func deleteLens(_ lens: Lens) { + modelContext.delete(lens) + } +} diff --git a/ios/FilmTracker/ViewModels/ExposureViewModel.swift b/ios/FilmTracker/ViewModels/ExposureViewModel.swift new file mode 100644 index 0000000..2405bcc --- /dev/null +++ b/ios/FilmTracker/ViewModels/ExposureViewModel.swift @@ -0,0 +1,99 @@ +import SwiftUI +import SwiftData + +@Observable +final class ExposureViewModel { + var exposure: Exposure + var modelContext: ModelContext + + var isEditing = false + + // Editable fields + var aperture: String + var shutterSpeed: String + var ei: Int + var focalLength: Int? + var additionalInfo: String + var lensId: String? + + var roll: FilmRoll? + var camera: Camera? + var lens: Lens? + + init(exposure: Exposure, modelContext: ModelContext) { + self.exposure = exposure + self.modelContext = modelContext + + self.aperture = exposure.aperture + self.shutterSpeed = exposure.shutterSpeed + self.ei = exposure.ei ?? 400 + self.focalLength = exposure.focalLength + self.additionalInfo = exposure.additionalInfo ?? "" + self.lensId = exposure.lensId + + fetchDetails() + } + + private func fetchDetails() { + // Fetch Roll + let rollId = exposure.filmRollId + let rollDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == rollId }) + self.roll = try? modelContext.fetch(rollDescriptor).first + + // Fetch Camera from Roll + if let cameraId = roll?.cameraId { + let camDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == cameraId }) + self.camera = try? modelContext.fetch(camDescriptor).first + } + + // Fetch Lens + if let lId = exposure.lensId { + let lensDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == lId }) + self.lens = try? modelContext.fetch(lensDescriptor).first + } + } + + func save() { + exposure.aperture = aperture + exposure.shutterSpeed = shutterSpeed + exposure.ei = ei + exposure.focalLength = focalLength + exposure.additionalInfo = additionalInfo + exposure.lensId = lensId + + try? modelContext.save() + isEditing = false + } + + func cancel() { + aperture = exposure.aperture + shutterSpeed = exposure.shutterSpeed + ei = exposure.ei ?? 400 + focalLength = exposure.focalLength + additionalInfo = exposure.additionalInfo ?? "" + lensId = exposure.lensId + isEditing = false + } + + func delete() { + modelContext.delete(exposure) + } + + func updateImage(_ data: Data) { + guard let downscaledData = ImageUtils.downscale(data) else { return } + exposure.imageData = downscaledData + try? modelContext.save() + } + + var cameraName: String { + camera?.name ?? "No camera" + } + + var lensName: String { + lens?.name ?? "No lens" + } + + var rollName: String { + roll?.name ?? "Unknown Roll" + } +} diff --git a/ios/FilmTracker/ViewModels/GalleryViewModel.swift b/ios/FilmTracker/ViewModels/GalleryViewModel.swift new file mode 100644 index 0000000..8bbafd4 --- /dev/null +++ b/ios/FilmTracker/ViewModels/GalleryViewModel.swift @@ -0,0 +1,99 @@ +import SwiftUI +import SwiftData +import PhotosUI + +@Observable +final class GalleryViewModel { + var roll: FilmRoll + var modelContext: ModelContext + + var exposures: [Exposure] = [] + var isGridView = false + var selectedPhotosPickerItems: [PhotosPickerItem] = [] { + didSet { + if !selectedPhotosPickerItems.isEmpty { + importPhotos() + } + } + } + + init(roll: FilmRoll, modelContext: ModelContext) { + self.roll = roll + self.modelContext = modelContext + fetchExposures() + } + + func fetchExposures() { + let rollId = roll.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.filmRollId == rollId }, + sortBy: [SortDescriptor(\.exposureNumber)] + ) + + do { + exposures = try modelContext.fetch(descriptor) + } catch { + print("Failed to fetch exposures: \(error)") + } + } + + func deleteExposure(_ exposure: Exposure) { + modelContext.delete(exposure) + fetchExposures() + } + + func copyPrevious(to exposure: Exposure) { + guard let index = exposures.firstIndex(where: { $0.id == exposure.id }), index > 0 else { return } + let previous = exposures[index - 1] + + exposure.aperture = previous.aperture + exposure.shutterSpeed = previous.shutterSpeed + exposure.ei = previous.ei + exposure.lensId = previous.lensId + exposure.focalLength = previous.focalLength + + try? modelContext.save() + fetchExposures() + } + + private func importPhotos() { + let items = selectedPhotosPickerItems + selectedPhotosPickerItems = [] + + Task { + for item in items { + if let data = try? await item.loadTransferable(type: Data.self) { + await MainActor.run { + addExposureFromData(data) + } + } + } + } + } + + private func addExposureFromData(_ data: Data) { + guard let downscaledData = ImageUtils.downscale(data) else { return } + + let nextNumber = (exposures.last?.exposureNumber ?? 0) + 1 + + let newExposure = Exposure( + filmRollId: roll.id, + exposureNumber: nextNumber, + aperture: "f/8", // Default values + shutterSpeed: "1/125", + imageData: downscaledData, + capturedAt: Date() + ) + + modelContext.insert(newExposure) + fetchExposures() + } + + var rollProgress: String { + "\(exposures.count)/\(roll.totalExposures)" + } + + var rollMetadata: String { + "ISO \(roll.iso) · EI \(roll.ei ?? roll.iso)" + } +} diff --git a/ios/FilmTracker/ViewModels/RollsViewModel.swift b/ios/FilmTracker/ViewModels/RollsViewModel.swift new file mode 100644 index 0000000..107c170 --- /dev/null +++ b/ios/FilmTracker/ViewModels/RollsViewModel.swift @@ -0,0 +1,39 @@ +import SwiftUI +import SwiftData +import Observation + +@Observable +final class RollsViewModel { + var modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func addRoll(name: String, iso: Int, ei: Int?, totalExposures: Int, cameraId: String?, currentLensId: String?, tag: String? = nil) { + let roll = FilmRoll(name: name, iso: iso, ei: ei, totalExposures: totalExposures, cameraId: cameraId, currentLensId: currentLensId, tag: tag) + modelContext.insert(roll) + } + + func updateRoll(_ roll: FilmRoll, name: String, iso: Int, ei: Int?, totalExposures: Int, cameraId: String?, currentLensId: String?, tag: String?) { + roll.name = name + roll.iso = iso + roll.ei = ei + roll.totalExposures = totalExposures + roll.cameraId = cameraId + roll.currentLensId = currentLensId + roll.tag = tag + } + + func deleteRoll(_ roll: FilmRoll) { + // Also delete related exposures + let rollId = roll.id + let descriptor = FetchDescriptor(predicate: #Predicate { $0.filmRollId == rollId }) + if let exposures = try? modelContext.fetch(descriptor) { + for exposure in exposures { + modelContext.delete(exposure) + } + } + modelContext.delete(roll) + } +} diff --git a/ios/FilmTrackerTests/ImageUtilsTests.swift b/ios/FilmTrackerTests/ImageUtilsTests.swift new file mode 100644 index 0000000..ef6c1d9 --- /dev/null +++ b/ios/FilmTrackerTests/ImageUtilsTests.swift @@ -0,0 +1,42 @@ +import XCTest +import UIKit +@testable import FilmTracker + +final class ImageUtilsTests: XCTestCase { + func testDownscaleLargeImage() { + // Create a 2000x2000 white image + let size = CGSize(width: 2000, height: 2000) + UIGraphicsBeginImageContext(size) + UIColor.white.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + let largeImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + + let data = ImageUtils.downscale(largeImage) + XCTAssertNotNil(data) + + let processedImage = UIImage(data: data!)! + XCTAssertTrue(processedImage.size.width <= 1280) + XCTAssertTrue(processedImage.size.height <= 1280) + + // Size check (should be under 500KB for a plain white image) + XCTAssertTrue(data!.count < 500 * 1024) + } + + func testDownscaleSmallImage() { + // Create a 500x500 white image + let size = CGSize(width: 500, height: 500) + UIGraphicsBeginImageContext(size) + UIColor.white.setFill() + UIRectFill(CGRect(origin: .zero, size: size)) + let smallImage = UIGraphicsGetImageFromCurrentImageContext()! + UIGraphicsEndImageContext() + + let data = ImageUtils.downscale(smallImage) + XCTAssertNotNil(data) + + let processedImage = UIImage(data: data!)! + XCTAssertEqual(processedImage.size.width, 500) + XCTAssertEqual(processedImage.size.height, 500) + } +} diff --git a/ios/FilmTrackerTests/ImportExportTests.swift b/ios/FilmTrackerTests/ImportExportTests.swift new file mode 100644 index 0000000..9cac26c --- /dev/null +++ b/ios/FilmTrackerTests/ImportExportTests.swift @@ -0,0 +1,63 @@ +import XCTest +import SwiftData +@testable import FilmTracker + +@MainActor +final class ImportExportTests: XCTestCase { + var modelContainer: ModelContainer! + var modelContext: ModelContext! + + override func setUpWithError() throws { + let schema = Schema([Camera.self, Lens.self, FilmRoll.self, Exposure.self, AppSettings.self]) + let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + modelContainer = try ModelContainer(for: schema, configurations: [modelConfiguration]) + modelContext = modelContainer.mainContext + } + + func testExportImportRoundTrip() async throws { + // 1. Create seed data + let roll = FilmRoll(name: "Test Roll", iso: 400, totalExposures: 36) + modelContext.insert(roll) + + let exposure = Exposure( + filmRollId: roll.id, + exposureNumber: 1, + aperture: "f/8", + shutterSpeed: "1/250", + imageData: UIImage(systemName: "camera")?.pngData() // Dummy image + ) + modelContext.insert(exposure) + try modelContext.save() + + // 2. Export + let exportURL = try await ExportService.shared.exportRoll(roll, exposures: [exposure], format: .jsonWithImages) + XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path)) + + // 3. Import into a new context (to simulate fresh import) + let importSchema = Schema([Camera.self, Lens.self, FilmRoll.self, Exposure.self, AppSettings.self]) + let importConfig = ModelConfiguration(schema: importSchema, isStoredInMemoryOnly: true) + let importContainer = try ModelContainer(for: importSchema, configurations: [importConfig]) + let importContext = importContainer.mainContext + + try await ImportService.shared.importFromJSON(url: exportURL, modelContext: importContext) + + // 4. Verify + let fetchRolls = FetchDescriptor() + let importedRolls = try importContext.fetch(fetchRolls) + + XCTAssertEqual(importedRolls.count, 1) + XCTAssertTrue(importedRolls[0].name.contains("Test Roll")) + XCTAssertTrue(importedRolls[0].name.contains("[IMPORTED]")) + + let fetchExposures = FetchDescriptor() + let importedExposures = try importContext.fetch(fetchExposures) + + XCTAssertEqual(importedExposures.count, 1) + XCTAssertEqual(importedExposures[0].aperture, "f/8") + XCTAssertEqual(importedExposures[0].shutterSpeed, "1/250") + XCTAssertNotNil(importedExposures[0].imageData) + + // Cleanup + try? FileManager.default.removeItem(at: exportURL) + } +} diff --git a/ios/FilmTrackerTests/ModelTests.swift b/ios/FilmTrackerTests/ModelTests.swift new file mode 100644 index 0000000..fca0283 --- /dev/null +++ b/ios/FilmTrackerTests/ModelTests.swift @@ -0,0 +1,53 @@ +import XCTest +import SwiftData +@testable import FilmTracker + +final class ModelTests: XCTestCase { + var container: ModelContainer! + var context: ModelContext! + + override func setUp() { + super.setUp() + let schema = Schema([ + Camera.self, + Lens.self, + FilmRoll.self, + Exposure.self, + AppSettings.self + ]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + container = try! ModelContainer(for: schema, configurations: [config]) + context = ModelContext(container) + } + + func testCameraCRUD() { + let camera = Camera(make: "Nikon", model: "FE") + context.insert(camera) + + let cameras = try! context.fetch(FetchDescriptor()) + XCTAssertEqual(cameras.count, 1) + XCTAssertEqual(cameras.first?.name, "Nikon FE") + + camera.model = "FE2" + camera.name = "Nikon FE2" + XCTAssertEqual(cameras.first?.name, "Nikon FE2") + + context.delete(camera) + let camerasAfterDelete = try! context.fetch(FetchDescriptor()) + XCTAssertEqual(camerasAfterDelete.count, 0) + } + + func testLensCRUD() { + let lens = Lens(name: "Nikkor 50mm", maxAperture: "f/1.4", focalLength: 50) + context.insert(lens) + + let lenses = try! context.fetch(FetchDescriptor()) + XCTAssertEqual(lenses.count, 1) + XCTAssertEqual(lenses.first?.isZoom, false) + + let zoom = Lens(name: "Nikkor 80-200mm", maxAperture: "f/4.5", focalLengthMin: 80, focalLengthMax: 200) + context.insert(zoom) + XCTAssertEqual(try! context.fetch(FetchDescriptor()).count, 2) + XCTAssertEqual(zoom.isZoom, true) + } +} diff --git a/ios/FilmTrackerUITests/CaptureWorkflowTests.swift b/ios/FilmTrackerUITests/CaptureWorkflowTests.swift new file mode 100644 index 0000000..4b17836 --- /dev/null +++ b/ios/FilmTrackerUITests/CaptureWorkflowTests.swift @@ -0,0 +1,94 @@ +import XCTest + +final class CaptureWorkflowTests: XCTestCase { + let app = XCUIApplication() + let rollName = "Capture Test Roll \(UUID().uuidString.prefix(4))" + + override func setUpWithError() throws { + continueAfterFailure = false + app.launch() + + // Add a camera and lens first for full testing + addCamera(name: "Test Camera") + addLens(name: "Test Lens") + + // Create a roll + createRoll(name: rollName) + } + + func testCaptureScreenLoads() { + // Verify capture screen elements + XCTAssertTrue(app.buttons["grid"].exists) + XCTAssertTrue(app.buttons["viewfinder"].exists) + XCTAssertTrue(app.staticTexts[rollName].exists) + XCTAssertTrue(app.buttons["note.text"].exists) + XCTAssertTrue(app.buttons["photo.on.rectangle"].exists) // last shot peek + } + + func testPickerInteractions() { + // Tap Aperture chip + app.buttons["APER"].tap() + + // Verify radial picker appears + XCTAssertTrue(app.staticTexts["SWIPE TO ROTATE"].exists) + + // Dismiss picker + let dismissButton = app.buttons["dismissPickerButton"] + XCTAssertTrue(dismissButton.waitForExistence(timeout: 5)) + dismissButton.tap() + + // Verify picker closed + XCTAssertFalse(app.staticTexts["SWIPE TO ROTATE"].exists) + } + + func testNoteSheet() { + app.buttons["note.text"].tap() + + let noteEditor = app.textViews.firstMatch + XCTAssertTrue(noteEditor.exists) + + noteEditor.tap() + noteEditor.typeText("Test Note") + + app.buttons["Save Note"].tap() + + // Verify note icon is present + XCTAssertTrue(app.buttons["note.text"].exists) + } + + // Helper methods + private func addCamera(name: String) { + app.tabBars.buttons["Equipment"].tap() + app.buttons["addEquipmentButton"].tap() + let makeField = app.textFields["cameraMakeInput"] + makeField.tap() + makeField.typeText(name) + let modelField = app.textFields["cameraModelInput"] + modelField.tap() + modelField.typeText("TestModel") + app.buttons["confirmCameraFormButton"].tap() + } + + private func addLens(name: String) { + app.tabBars.buttons["Equipment"].tap() + app.buttons["segmentButton_1"].tap() // Lenses + app.buttons["addEquipmentButton"].tap() + let nameField = app.textFields["lensNameInput"] + nameField.tap() + nameField.typeText(name) + let focalField = app.textFields["lensFocalInput"] + focalField.tap() + focalField.typeText("50") + app.buttons["confirmLensFormButton"].tap() + } + + private func createRoll(name: String) { + app.tabBars.buttons["Rolls"].tap() + app.buttons["addRollFAB"].tap() + app.buttons["New roll"].tap() + let nameField = app.textFields["rollNameInput"] + nameField.tap() + nameField.typeText(name) + app.buttons["confirmRollFormButton"].tap() + } +} diff --git a/ios/FilmTrackerUITests/EquipmentManagementTests.swift b/ios/FilmTrackerUITests/EquipmentManagementTests.swift new file mode 100644 index 0000000..1ed3ff4 --- /dev/null +++ b/ios/FilmTrackerUITests/EquipmentManagementTests.swift @@ -0,0 +1,127 @@ +import XCTest + +final class EquipmentManagementTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + app.launch() + + // Navigate to Equipment tab + app.tabBars.buttons["Equipment"].tap() + } + + func testCreateCamera() throws { + // Tap FAB + app.buttons["addEquipmentFAB"].tap() + + // Fill form + let makeField = app.textFields["cameraMakeInput"] + let modelField = app.textFields["cameraModelInput"] + + XCTAssertTrue(makeField.waitForExistence(timeout: 5)) + + makeField.tap() + makeField.typeText("Nikon") + + modelField.tap() + modelField.typeText("D750") + + // Confirm + app.buttons["confirmCameraFormButton"].tap() + + // Verify in list + let cameraTitle = app.staticTexts["Nikon D750"] + XCTAssertTrue(cameraTitle.waitForExistence(timeout: 5)) + } + + func testEditCamera() throws { + // First create a camera + try testCreateCamera() + + // Tap ellipsis (more menu) + let moreButton = app.buttons.matching(identifier: "entityRowMoreButton").firstMatch + XCTAssertTrue(moreButton.waitForExistence(timeout: 5)) + moreButton.tap() + + // Tap Edit + app.buttons["Edit"].tap() + + // Change model + let modelField = app.textFields["cameraModelInput"] + modelField.tap() + modelField.typeText(" FE2") // Append + + // Save + app.buttons["confirmCameraFormButton"].tap() + + // Verify updated title + let updatedTitle = app.staticTexts["Nikon D750 FE2"] + XCTAssertTrue(updatedTitle.waitForExistence(timeout: 5)) + } + + func testCreatePrimeLens() throws { + // Switch to Lenses tab + app.buttons["segmentButton_1"].tap() + + // Tap FAB + app.buttons["addEquipmentFAB"].tap() + + // Fill form + let nameField = app.textFields["lensNameInput"] + XCTAssertTrue(nameField.waitForExistence(timeout: 5)) + + nameField.tap() + nameField.typeText("Summicron 50mm") + app.keyboards.buttons["Return"].tap() + + // Aperture is f/2 by default + + // Type is Prime by default + + let focalField = app.textFields["lensFocalInput"] + focalField.tap() + focalField.typeText("50") + + // Confirm + app.buttons["confirmLensFormButton"].tap() + + // Verify in list + let lensTitle = app.staticTexts["Summicron 50mm"] + XCTAssertTrue(lensTitle.waitForExistence(timeout: 5)) + } + + func testCreateZoomLens() throws { + // Switch to Lenses tab + app.buttons["segmentButton_1"].tap() + + // Tap FAB + app.buttons["addEquipmentFAB"].tap() + + // Fill form + let nameField = app.textFields["lensNameInput"] + XCTAssertTrue(nameField.waitForExistence(timeout: 5)) + + nameField.tap() + nameField.typeText("Nikkor 24-70mm") + app.keyboards.buttons["Return"].tap() + + // Switch to Zoom + app.buttons["lensTypeZoom"].tap() + + let minFocalField = app.textFields["lensFocalMinInput"] + minFocalField.tap() + minFocalField.typeText("24") + + let maxFocalField = app.textFields["lensFocalMaxInput"] + maxFocalField.tap() + maxFocalField.typeText("70") + + // Confirm + app.buttons["confirmLensFormButton"].tap() + + // Verify in list + let lensTitle = app.staticTexts["Nikkor 24-70mm"] + XCTAssertTrue(lensTitle.waitForExistence(timeout: 5)) + } +} diff --git a/ios/FilmTrackerUITests/FilmRollManagementTests.swift b/ios/FilmTrackerUITests/FilmRollManagementTests.swift new file mode 100644 index 0000000..3cf386a --- /dev/null +++ b/ios/FilmTrackerUITests/FilmRollManagementTests.swift @@ -0,0 +1,126 @@ +import XCTest + +final class FilmRollManagementTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + app.launch() + + // Ensure we are on Rolls tab + app.tabBars.buttons["Rolls"].tap() + } + + @discardableResult + func createFilmRoll(suffix: String) throws -> String { + // Tap FAB + let fab = app.buttons["addRollFAB"] + XCTAssertTrue(fab.waitForExistence(timeout: 5)) + fab.tap() + + // Tap New roll in menu + let newRollButton = app.buttons["fabNewRoll"] + XCTAssertTrue(newRollButton.waitForExistence(timeout: 5)) + newRollButton.tap() + + // Select preset + let portraPreset = app.buttons["preset_Kodak_Portra_400"] + XCTAssertTrue(portraPreset.waitForExistence(timeout: 5)) + portraPreset.tap() + + // Append suffix + let nameField = app.textFields["rollNameInput"] + nameField.tap() + nameField.typeText(" \(suffix)") + + let finalName = "Kodak Portra 400 \(suffix)" + + // Confirm + app.buttons["confirmRollFormButton"].tap() + + // Verify we are in CaptureView or see the roll title (frame counter has it) + let rollTitle = app.staticTexts[finalName] + XCTAssertTrue(rollTitle.waitForExistence(timeout: 10)) + + // Navigate back to Rolls list + let backButton = app.buttons["chevron.left"] + if backButton.exists { + backButton.tap() + } + + // Verify in list + XCTAssertTrue(app.staticTexts[finalName].waitForExistence(timeout: 5)) + + return finalName + } + + func testCreateFilmRoll() throws { + try createFilmRoll(suffix: "Create-\(UUID().uuidString.prefix(4))") + } + + func testFilterRolls() throws { + let name = try createFilmRoll(suffix: "Filter-\(UUID().uuidString.prefix(4))") + + // Tap Complete + app.buttons["filter_complete"].tap() + + // Verify empty state (unless we have complete rolls from before, but this roll is active) + // If we have complete rolls from before, "No rolls found" won't be there. + // So we just check that our new roll is NOT there. + XCTAssertFalse(app.staticTexts[name].exists) + } + + func testEditFilmRoll() throws { + let name = try createFilmRoll(suffix: "Edit-\(UUID().uuidString.prefix(4))") + + // Tap more menu for THIS specific roll if possible, or just first match + let moreButton = app.buttons.matching(identifier: "rollCardMoreMenu").firstMatch + XCTAssertTrue(moreButton.waitForExistence(timeout: 5)) + moreButton.tap() + + // Tap Edit + let editMenuButton = app.buttons.matching(identifier: "rollCardEditButton").firstMatch + XCTAssertTrue(editMenuButton.waitForExistence(timeout: 5)) + editMenuButton.tap() + + // Change name + let nameField = app.textFields["rollNameInput"] + nameField.tap() + nameField.typeText(" Updated") + + let updatedName = "\(name) Updated" + + // Save + app.buttons["confirmRollFormButton"].tap() + + // Verify updated title + let updatedTitle = app.staticTexts[updatedName] + XCTAssertTrue(updatedTitle.waitForExistence(timeout: 5)) + } + + func testDeleteFilmRoll() throws { + let suffix = "Del-\(UUID().uuidString.prefix(4))" + let name = try createFilmRoll(suffix: suffix) + + // Tap more menu + let moreButton = app.buttons.matching(identifier: "rollCardMoreMenu").firstMatch + XCTAssertTrue(moreButton.waitForExistence(timeout: 5)) + moreButton.tap() + + // Tap Delete + let deleteMenuButton = app.buttons.matching(identifier: "rollCardDeleteButton").firstMatch + XCTAssertTrue(deleteMenuButton.waitForExistence(timeout: 5)) + deleteMenuButton.tap() + + // Wait for sheet + let confirmButton = app.buttons["confirmationConfirmButton"] + XCTAssertTrue(confirmButton.waitForExistence(timeout: 10)) + confirmButton.tap() + + // Verify removed + let rollTitle = app.staticTexts[name] + let doesNotExistPredicate = NSPredicate(format: "exists == false") + expectation(for: doesNotExistPredicate, evaluatedWith: rollTitle, handler: nil) + waitForExpectations(timeout: 10, handler: nil) + } +} diff --git a/ios/FilmTrackerUITests/GalleryTests.swift b/ios/FilmTrackerUITests/GalleryTests.swift new file mode 100644 index 0000000..a45915e --- /dev/null +++ b/ios/FilmTrackerUITests/GalleryTests.swift @@ -0,0 +1,73 @@ +import XCTest + +final class GalleryTests: XCTestCase { + let app = XCUIApplication() + let testRollName = "Gallery Test Roll \(UUID().uuidString.prefix(4))" + + override func setUpWithError() throws { + continueAfterFailure = false + app.launch() + + // Ensure we are in a clean state or handle existing rolls + // Create a roll for testing + app.buttons["addRollFAB"].tap() + + let newRollButton = app.buttons["New roll"] + XCTAssertTrue(newRollButton.waitForExistence(timeout: 5)) + newRollButton.tap() + + let nameField = app.textFields["rollNameInput"] + XCTAssertTrue(nameField.waitForExistence(timeout: 5)) + nameField.tap() + nameField.typeText(testRollName) + + app.buttons["confirmRollFormButton"].tap() + + // Capture one photo to have something in gallery + let shutter = app.buttons.matching(identifier: "shutterButton").firstMatch + XCTAssertTrue(shutter.waitForExistence(timeout: 5)) + shutter.tap() + + // Wait for capture flash/processing + sleep(1) + } + + func testNavigateToGalleryAndToggleViews() throws { + // From Capture, tap Gallery button + let galleryButton = app.buttons["galleryButton"] + XCTAssertTrue(galleryButton.exists) + galleryButton.tap() + + // Verify we are in Gallery + XCTAssertTrue(app.staticTexts["CONTACT SHEET"].waitForExistence(timeout: 5)) + + // Toggle to Grid + let gridToggle = app.buttons.matching(identifier: "square.grid.3x3.fill").firstMatch + XCTAssertTrue(gridToggle.exists) + gridToggle.tap() + + // Toggle back to Strip + let stripToggle = app.buttons.matching(identifier: "rectangle.grid.1x2.fill").firstMatch + XCTAssertTrue(stripToggle.exists) + stripToggle.tap() + } + + func testTapExposureToDetails() throws { + // Navigate to Gallery + app.buttons["galleryButton"].tap() + + // Tap the first exposure card (it has exposure number "1") + let firstExposure = app.buttons.matching(identifier: "1").firstMatch + // Wait, I might need to check how I set accessibility identifiers for cards. + // In ExposureStripCard, I didn't set an explicit identifier on the NavigationLink. + // But the text "1" should be findable. + + let exposureOne = app.staticTexts["1"].firstMatch + XCTAssertTrue(exposureOne.waitForExistence(timeout: 5)) + exposureOne.tap() + + // Verify Details View + XCTAssertTrue(app.staticTexts["NOTES"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["APERTURE"].exists) + } +} diff --git a/ios/FilmTrackerUITests/RefinementTests.swift b/ios/FilmTrackerUITests/RefinementTests.swift new file mode 100644 index 0000000..06bd29a --- /dev/null +++ b/ios/FilmTrackerUITests/RefinementTests.swift @@ -0,0 +1,56 @@ +import XCTest + +final class RefinementTests: XCTestCase { + let app = XCUIApplication() + + override func setUpWithError() throws { + continueAfterFailure = false + // Reset app data if possible or ensure it's a clean state + // For UI tests, we usually rely on the app's initial state + app.launch() + } + + func testSettingsButtonVisibility() throws { + // In Rolls tab + let rollsSettingsButton = app.navigationBars["Rolls"].buttons["gearshape"] + XCTAssertTrue(rollsSettingsButton.waitForExistence(timeout: 5)) + rollsSettingsButton.tap() + + // Verify Settings screen + XCTAssertTrue(app.staticTexts["SETTINGS"].waitForExistence(timeout: 5)) + app.buttons["chevron.left"].tap() + + // Go to Equipment tab + app.tabBars.buttons["Equipment"].tap() + + // In Equipment tab + let equipSettingsButton = app.navigationBars["Equipment"].buttons["gearshape"] + XCTAssertTrue(equipSettingsButton.waitForExistence(timeout: 5)) + equipSettingsButton.tap() + + // Verify Settings screen + XCTAssertTrue(app.staticTexts["SETTINGS"].waitForExistence(timeout: 5)) + } + + func testEmptyStateButtons() throws { + // Go to Equipment tab + app.tabBars.buttons["Equipment"].tap() + + // 1. Add Camera button in empty state (assuming no cameras) + let addCameraButton = app.buttons["Add Camera"] + if addCameraButton.waitForExistence(timeout: 2) { + addCameraButton.tap() + XCTAssertTrue(app.staticTexts["New camera"].waitForExistence(timeout: 5)) + app.buttons["Cancel"].tap() + } + + // 2. Add Lens button in empty state + app.buttons["segmentButton_1"].tap() // Switch to Lenses + let addLensButton = app.buttons["Add Lens"] + if addLensButton.waitForExistence(timeout: 2) { + addLensButton.tap() + XCTAssertTrue(app.staticTexts["New lens"].waitForExistence(timeout: 5)) + app.buttons["Cancel"].tap() + } + } +} diff --git a/ios/GEMINI.md b/ios/GEMINI.md new file mode 100644 index 0000000..4755027 --- /dev/null +++ b/ios/GEMINI.md @@ -0,0 +1,66 @@ +# iOS Film Photography Tracker + +This project is a native iOS Swift port of a PWA film photography tracker. It provides a specialized UI for tracking film photography metadata (exposures, aperture, shutter speed, ISO/EI, focal length, geolocation, and notes) per film roll, along with camera and lens equipment management. + +## Project Overview + +- **Purpose:** To help film photographers record and manage their metadata on the go with a high-quality, immersive iOS experience. +- **Key Features:** + - **Film Roll Management:** Track progress, film stock, and metadata for multiple rolls. + - **Equipment Management:** Maintain a database of cameras and lenses (supporting both prime and zoom). + - **Immersive Capture UI:** A custom camera viewfinder with manual controls, novel radial dial pickers, and a simulated light meter. + - **Gallery & Contact Sheet:** View exposures in strip or grid formats, with detailed EXIF-style readouts. + - **Import/Export:** Compatibility with the original PWA's JSON format for easy data migration and backup. +- **Reference Implementation:** The `film-photo-tracker/` directory contains a React-based functional prototype used for design and logic reference. + +## Technology Stack + +- **Language:** Swift 5.9+ +- **Framework:** SwiftUI +- **Persistence:** SwiftData (`@Model`, `ModelContainer`) +- **Concurrency:** Swift Concurrency (async/await) +- **Architecture:** MVVM with the `@Observable` macro +- **APIs/Services:** + - `AVFoundation`: Camera session and photo capture. + - `CoreLocation`: Geotagging exposures. + - `PhotosUI`: Importing images from the iOS Photo Library. + - `SwiftData`: Local database management. + +## Building and Running + +### iOS Application (Xcode) +1. Open the project in Xcode (requires macOS with Xcode 15+). +2. Ensure the `FilmTracker` scheme is selected. +3. Build and run on an iOS 17+ Simulator or physical device. +4. **Testing:** Run tests using `Cmd+U` or via the Test Navigator. The project uses XCTest and XCUITest. + +### Reference Prototype (Web) +1. Open `film-photo-tracker/index.html` in a modern web browser to view the functional design reference. + +## Development Conventions + +### Architecture & Style +- **MVVM:** Strictly follow the Model-View-ViewModel pattern. Use the `@Observable` macro for ViewModels. +- **Design System:** Adhere to the design tokens defined in `global-plan.md`. Use the specific hex codes for backgrounds (`#0a0a0b`), surfaces, and the primary accent (`#f4a261`). +- **Typography:** Use **Inter Tight** for UI elements and **SF Mono / JetBrains Mono** for numeric readouts and metadata. +- **Surgical Updates:** When modifying code, maintain the existing architectural patterns and avoid unnecessary refactoring. + +### Image Handling +- **Downscaling:** All images (camera capture, gallery import, etc.) must be run through a shared `ImageUtils.downscale(_:)` helper. +- **Specs:** Max dimension of 1280px (maintaining aspect ratio), JPEG format, 0.75 quality. +- **Storage:** Use `@Attribute(.externalStorage)` in SwiftData models to keep large image blobs outside the main SQLite database. + +### Testing +- **Mandatory Verification:** Every new feature or bug fix must be accompanied by relevant unit or UI tests. +- **Target:** Primary testing target is `FilmTrackerTests`. +- **Environment:** Tests should ideally be runnable on the latest iPhone simulator (e.g., iPhone 16). + +## Directory Structure + +- `ios/FilmTracker/`: Main source code directory (Swift files). +- `ios/film-photo-tracker/`: React-based reference prototype and mockups. +- `ios/global-plan.md`: The master implementation roadmap and design spec. + +## Functional Parity & Reference +This project is a rewrite of the original application, which can be found at `/Users/nikitazavartsev/prog_projects/film-meta-tracker`. That repository should be checked as a primary reference to ensure all necessary functionalities and logic (e.g., specific metadata handling, export formats) are accurately implemented in this iOS version. + diff --git a/ios/Makefile b/ios/Makefile new file mode 100644 index 0000000..0ba1d46 --- /dev/null +++ b/ios/Makefile @@ -0,0 +1,58 @@ +PROJECT_NAME = FilmTracker +BUNDLE_ID = com.filmtracker.FilmTracker +DESTINATION = platform=iOS Simulator,name=iPhone 15 +SIM_NAME = iPhone 15 + +.PHONY: up down clean test build install launch generate + +# Starts the simulator, builds the app, installs it, and launches it. +up: generate build install launch + open -a Simulator + +# Regenerates the Xcode project using xcodegen. +generate: + xcodegen generate + +# Builds the app for the simulator. +build: + xcodebuild -project $(PROJECT_NAME).xcodeproj \ + -scheme $(PROJECT_NAME) \ + -destination '$(DESTINATION)' \ + -configuration Debug \ + -derivedDataPath build \ + build + +# Boots the simulator (if needed) and installs the app. +install: + xcrun simctl boot "$(SIM_NAME)" || true + xcrun simctl install booted build/Build/Products/Debug-iphonesimulator/$(PROJECT_NAME).app + +# Launches the app on the booted simulator. +launch: + xcrun simctl launch booted $(BUNDLE_ID) + +# Uninstalls the app and clears all local data. +reset: + xcrun simctl boot "$(SIM_NAME)" || true + xcrun simctl uninstall booted $(BUNDLE_ID) || true + rm -rf build + $(MAKE) up + +# Shuts down the simulator. +down: + xcrun simctl shutdown booted || true + +# Runs all tests (Unit + UI). +test: + xcodebuild test -project $(PROJECT_NAME).xcodeproj \ + -scheme $(PROJECT_NAME) \ + -destination '$(DESTINATION)' \ + -derivedDataPath build + +# Cleans build artifacts and the generated Xcode project. +clean: + rm -rf build + rm -rf $(PROJECT_NAME).xcodeproj + + +# To resume this session: gemini --resume 9a3368e1-3312-4609-903d-f69bc350ffc5 diff --git a/ios/global-plan.md b/ios/global-plan.md new file mode 100644 index 0000000..6997db4 --- /dev/null +++ b/ios/global-plan.md @@ -0,0 +1,419 @@ +# iOS Film Photography Tracker — Global Implementation Plan + +## Context +Native iOS Swift port of the existing PWA film photography tracker. The PWA tracks film photography metadata (exposures, aperture, shutter speed, ISO/EI, focal length, geolocation, notes) per film roll, with camera/lens equipment management. The iOS version uses the design mockups in `film-photo-tracker/` as reference — significantly better UX than the PWA: dark theme, amber accent `#f4a261`, film aesthetic (grain, perforations, monospace numerics), novel picker UI (radial dial). + +--- + +## Technology Stack +- **Language**: Swift 5.9+, SwiftUI, iOS 17+ minimum +- **Persistence**: SwiftData (`@Model`, `ModelContainer`) +- **Camera**: AVFoundation (`AVCaptureSession`, `AVCapturePhotoOutput`) +- **Photos**: PhotosUI (`PHPickerViewController`) +- **Location**: CoreLocation +- **Testing**: XCTest + XCUITest +- **Architecture**: MVVM with `@Observable` macro + +--- + +## Data Models (SwiftData `@Model`) +Direct mapping from PWA `src/types.ts`: +- **Camera**: id, make, model, name (auto-generated), lensIDs[], createdAt +- **Lens**: id, name, maxAperture (e.g. "f/1.4"), focalLength? (prime) OR focalLengthMin/Max (zoom), createdAt +- **FilmRoll**: id, name, iso, ei?, totalExposures (24/36/custom), cameraId?, currentLensId?, createdAt +- **Exposure**: id, filmRollId, exposureNumber, aperture, shutterSpeed, additionalInfo?, `@Attribute(.externalStorage) imageData: Data?` (JPEG stored outside SQLite), location?, capturedAt, ei?, lensId?, focalLength? +- **AppSettings**: id (singleton), gridEnabled, locationEnabled, hapticsEnabled, version + +### Image handling rule (applies everywhere images enter the app) +All images — camera capture, PHPicker gallery import, Details image replacement, and folder-based file import — are run through a single shared `ImageUtils.downscale(_:)` before saving: +- Max dimension: **1280px** (longer side), maintaining aspect ratio +- Format: **JPEG, quality 0.75** +- This keeps each exposure image ≈ 150–300 KB, so a 36-exposure JSON-with-images export stays well under 10 MB +- `@Attribute(.externalStorage)` keeps image bytes outside the SQLite WAL, preventing large model fetches from stalling the main thread + +### Predefined constants (from PWA `types.ts`): +- **Apertures**: f/1.4, f/2, f/2.8, f/3.5, f/4, f/4.5, f/5.6, f/8, f/11, f/16, f/22 +- **Shutter speeds**: 1/4000 … 8", BULB +- **EI values**: 25 … 6400 (standard increments) + +--- + +## Design System (from `film-photo-tracker/` mockups) +- **Colors**: bg `#0a0a0b`, surface-0 `#111113`, surface-1 `#17171a`, surface-2 `#1f1f23`, surface-3 `#2a2a30`; accent `#f4a261`; text `#f5f5f7`; muted `#9a9aa3`; dim `#5e5e68`; red `#ff453a`; green `#30d158` +- **Typography**: Inter Tight (UI), SF Mono / JetBrains Mono (numeric readouts, frame counters, EXIF) +- **Radius**: xs 4, sm 6, md 10, lg 16, xl 22 (sheet corners), pill 999 +- **Motion**: sheets 0.32s ease-out, pickers 0.28s, buttons scale 0.1s, shutter flash 0.28s + +--- + +## Phase 1: Foundation + +### Step 1.1 — Project Setup, Data Models & Navigation Skeleton +**Deliverable**: App launches, tabs visible, SwiftData CRUD confirmed in unit tests. +- Create Xcode project: SwiftUI App, SwiftData, iOS 17+, `FilmTracker` bundle +- Define all `@Model` types: Camera, Lens, FilmRoll, Exposure, AppSettings +- `ModelContainer` setup in `FilmTrackerApp.swift` with schema migrations stub +- Root: `TabView` with Rolls tab (film roll icon) and Equipment tab (camera icon) +- `NavigationStack` inside each tab for push navigation +- PWA aperture/shutter/EI constant arrays as Swift enums/arrays +- `ImageUtils.swift` (static helpers): + - `downscale(_ image: UIImage) -> Data` — resize to max 1280px, JPEG 0.75 + - `downscale(_ data: Data) -> Data?` — convenience for raw Data input (used by import) + - Called by: `CameraService` (capture), `GalleryViewModel` (PHPicker), `ExposureViewModel` (replace image), `ImportService` (file-based import) + +**Tests**: Unit tests — create/read/delete each model type; `ImageUtils.downscale` produces Data ≤ ~350 KB for a typical photo + +### Step 1.2 — Design System & Core UI Components +**Deliverable**: Component library renders in Xcode Previews; colors match spec. +- `Color+DesignSystem.swift`: all named tokens as `static` Color properties +- `Font+DesignSystem.swift`: Inter Tight + SF Mono registrations, named text styles +- `AppButton`: primary (amber fill), secondary (surface-2), ghost (transparent) variants +- `AppChip`: default / accent-glow / ghost variants; large variant; monospace numerics +- `AppCard`: surface-1 bg, 1px hairline border, lg radius, subtle grain texture overlay +- `BottomSheet`: generic container — grabber bar (36×4px), xl radius top corners, 0.32s slide-up +- `EmptyStateView`: dashed border square icon (88×88), title, body, CTA button +- `ConfirmationSheet`: title, message, destructive confirm + cancel (replaces `window.confirm`) +- `EntityRow`: icon circle (amber bg), title, subtitle, trailing more-menu (`Menu`) + +**Tests**: SwiftUI Preview snapshots (manual); unit tests for color hex values + +--- + +## Phase 2: Equipment Management + +### Step 2.1 — Camera Management +**Deliverable**: Full camera CRUD verified by XCUITests. +- Equipment tab: `Picker` segmented control "Cameras (N)" / "Lenses (N)" +- Camera list: `EntityRow` per camera; context menu — Edit, Delete +- Create/Edit camera sheet (via `BottomSheet`): Make field, Model field; auto-name = "Make Model" +- Delete with `ConfirmationSheet` +- Empty state via `EmptyStateView` ("No cameras", "Add your first camera") +- `CameraViewModel` (`@Observable`): `@Query` cameras, CRUD methods + +**Tests** (XCUITest): +- `testCreateCamera` — add Nikon D750, verify in list +- `testEditCamera` — rename camera, verify updated +- `testDeleteCamera` — delete with confirmation, verify removed +- `testCameraEmptyState` — empty state visible initially +- `testTabNavigation` — switch Cameras/Lenses segments + +### Step 2.2 — Lens Management +**Deliverable**: Lens CRUD with prime/zoom validation verified by XCUITests. +- Lens list in Equipment tab (Lenses segment): `EntityRow` per lens with focal range in subtitle +- Create/Edit lens sheet: Name, Max Aperture (picker), then either: + - Prime: single "Focal Length (mm)" field + - Zoom: "Min Focal Length" + "Max Focal Length" fields (min < max validated) + - Toggle between prime/zoom with visual mode selector +- Validation inline (highlight field red, disable Save until valid) +- Delete with `ConfirmationSheet` +- Empty state via `EmptyStateView` +- `LensViewModel` (`@Observable`) + +**Tests** (XCUITest): +- `testCreatePrimeLens` — 50mm f/1.4 +- `testCreateZoomLens` — 24-70mm f/2.8 +- `testZoomValidation` — min > max shows error +- `testDeleteLens` + +--- + +## Phase 3: Film Roll Management + +### Step 3.1 — Film Roll List & Create +**Deliverable**: Film roll creation and list display verified by XCUITests. +- Rolls tab: vertical `ScrollView` of `RollCard` +- `RollCard` (surface-1, lg radius): + - Left: 96×96 thumbnail (last exposure image or film icon placeholder) + - Exposure number (monospace) top-left over thumbnail + - Right: optional tag pill (accent), roll name (headline), metadata line (mono muted: "ISO 400 · EI 320 · Camera · Lens"), 2px progress bar (amber fill), "23/36 · 64%" counter + - More menu (⋯) — Edit, Delete +- FAB (60×60 amber circle, amber shadow): tap → FAB menu sheet + - FAB menu items: "New roll" (camera icon), "Import" (arrow-down icon), "Resume last" (clock icon) +- Create roll sheet (via `BottomSheet`): + - Film stock presets (horizontal chip scroll: Kodak Portra 400, Fuji Superia, Ilford HP5, etc.) + - Name field (required) + - ISO + Exposures (side by side; exposures: 12/24/36/custom) + - EI chips (None + common values) + - Camera picker (None + available cameras as chips) + - Lens picker (None + available lenses as chips) + - "Start shooting" primary button → navigate to CaptureView +- `FilmRollViewModel` (`@Observable`) + +**Tests** (XCUITest): +- `testCreateFilmRoll` — create "Kodak Portra 400", ISO 400, 36 exp; verify in list +- `testFilmRollProgressShows` — 0/36 visible +- `testNavigateToCaptureScreen` — tap roll → capture screen +- `testSpecialCharactersInRollName` + +### Step 3.2 — Film Roll Edit/Delete & Filters +**Deliverable**: Film roll CRUD and filtering verified by XCUITests. +- Filter pills row below header: "All (N)", "Active (N)", "Complete (N)" + - Active = exposures < totalExposures; Complete = exposures ≥ totalExposures +- Edit roll sheet (same form as create, pre-filled) +- Delete with `ConfirmationSheet` +- Import button in Rolls tab header (stub, activates in Phase 6) +- Settings button in header (stub, activates in Phase 6) +- Tap roll card body → navigate to CaptureView (active) or GalleryView (complete) + +**Tests** (XCUITest): +- `testFilterPillsAllActiveComplete` +- `testEditFilmRoll` +- `testDeleteFilmRollWithConfirmation` + +--- + +## Phase 4: Camera Capture + +### Step 4.1 — Viewfinder & Capture Core +**Deliverable**: Capture screen with live viewfinder, shutter capture, geolocation; XCUITests for UI elements. +- `CameraService` (`@Observable`): `AVCaptureSession`, rear camera default, fallback to front, `AVCapturePhotoOutput` +- `CaptureView`: full-screen immersive layout + - Live preview via `AVCaptureVideoPreviewLayer` wrapped in `UIViewRepresentable` + - Grain texture overlay (tiled noise pattern, 8% opacity) + - Vignette (radial gradient, black 0→0.6 opacity) + - Optional rule-of-thirds grid (3×3 lines, 20% white) + - Optional frame lines (dashed rect + crosshair, amber) +- Frame counter pill (below notch): roll name (truncated), "23 / 36" (amber mono), "END" (red) when full +- Top bar (glass blur): back button (left), gallery button (right) +- Grid / frame-lines toggle buttons (top-right floating column) +- Lens label pill (top-left, floating): lens name or "No lens" → tap opens `LensPickerSheet` +- Shutter button (78px): white border circle → inner circle → press → 0.28s white flash overlay +- Last shot peek button (50px, bottom-right): shows thumbnail of last exposure +- Note editor button (50px, bottom-left): amber if note pending +- Capture flow: `CameraService.capturePhoto()` → `ImageUtils.downscale()` (max 1280px, JPEG 0.75) → get location (non-blocking) → save `Exposure` to SwiftData → increment counter +- `CaptureViewModel` (`@Observable`): manages current settings, exposure count, session +- `LocationService` (`@Observable`): CoreLocation, 10-min cache, high accuracy + +**Tests** (XCUITest): `testCaptureScreenLoads`, `testFrameCounterVisible`, `testShutterButtonVisible`, `testGalleryButtonVisible` (mock camera permission) + +### Step 4.2 — Settings Chips & Picker Drawers +**Deliverable**: All settings configurable via novel picker UI; values persist to exposure capture. +- `SettingsChipsRow`: 4 chips horizontal — Aperture, Shutter Speed, EI, Focal Length + - Each chip: tiny label above, large value (amber mono when active) + - Tap chip → activates picker drawer (deactivates others) + - Active chip: amber glow background, amber text +- **Radial Dial Picker** (default, primary design from mockups): + - Semi-circular arc of values distributed over 130° + - Centered pointer triangle at top + - Drag gesture: ~28px per step rotates arc + - Active value: amber bold; adjacent values fade + - Tick marks radiate outward (longer for active) + - Hint: "Swipe to rotate" with arrow icons; dismiss: checkmark button +- **Wheel Picker** (alternate, togglable): + - Horizontal strip; center line (amber); drag 56px/step; off-center values scale 0.85, fade to 0.25 opacity +- **Grid Sheet Picker** (alternate, for EI values / long lists): + - Grid layout; 52px min cell height; active: amber glow + border + text +- Picker preference stored in `AppSettings` (radial/wheel/grid) +- **Note sheet** (`BottomSheet`): textarea (4 rows), "Saved with next exposure" hint, Save button +- **Lens picker sheet** (`BottomSheet`): lens list + "No lens" row, checkmark on selected +- Aperture options filtered to ≥ lens.maxAperture (can't use wider than lens allows) +- Focal length: prime lens → chip disabled, shows fixed value; zoom → constrained to [min, max], 5mm snapping + +**Tests** (XCUITest): `testOpenAperturePicker`, `testSelectApertureValue`, `testChipUpdates`, `testFullSettingsConfigure`, `testLensFiltersFocalLength` + +### Step 4.3 — Focal Length Overlay & Capture Polish +**Deliverable**: Focal length ruler overlay functional; haptics; camera screen feature-complete. +- `FocalLengthOverlay`: ruler at viewfinder bottom, tick marks at 15, 24, 35, 50, 85, 135, 200mm; amber glowing thumb; current value in mm +- Letterbox bars (top + bottom, 20% height each, dark semi-transparent) when focalLength < 24mm +- `LightMeterView`: floating transparent card, "LIGHT METER" label, EV readout (+0.5 EV), -3…+3 scale bar with ticks, green if ≤ 0.34 stops from 0, else amber (simulated from aperture/shutter — EV = log2(N²/t)) +- Haptic feedback: `UIImpactFeedbackGenerator` on shutter, `UISelectionFeedbackGenerator` on picker steps +- Camera permission denied state: `EmptyStateView` with "Enable Camera Access" → `UIApplication.open(Settings URL)` + +**Tests** (XCUITest): `testFocalLengthOverlayVisible`, `testLetterboxAppearsWideAngle` + +--- + +## Phase 5: Gallery & Exposure Details + +### Step 5.1 — Gallery Screen +**Deliverable**: Gallery with strip/grid views, photo library import, copy-previous; XCUITests. +- `GalleryView`: full-screen, from CaptureView or roll card +- Header: back (to capture), export stub (amber), home buttons; "CONTACT SHEET" label; "23/36 · ISO 400 · EI 320" +- Quick actions row: "Resume shooting" (→ CaptureView), "Add from gallery" (→ PHPicker) +- Strip/Grid toggle (two-button container, top-right of header) +- **Strip view**: `LazyVStack` of `ExposureCard` + - 92×92 image (or gradient placeholder); exposure number overlay (mono) + - Right: date/time (mono muted), "COPY PREV" button (if not first), EXIF chips, note (truncated) + - Tap → DetailsView; swipe-to-delete or trash button +- **Grid view**: `LazyVGrid` 3 columns, square cells + - Gradient image fill; exposure# top-left (white mono shadow); location pin icon if GPS; aperture + shutter bottom-left (white mono) + - Tap → DetailsView +- Animated transition between strip/grid +- Empty state: `EmptyStateView` with "No exposures yet" +- Film leader at strip end: dashed border, "N EXPOSURES REMAINING" (muted) +- **PHPickerViewController integration**: select photos → `ImageUtils.downscale()` (max 1280px, JPEG 0.75) → get location → save as new Exposure, auto-increment number +- **Copy previous**: copies aperture, shutterSpeed, ei, lensId, focalLength from prior exposure +- Delete exposure with `ConfirmationSheet` +- `GalleryViewModel` (`@Observable`): `@Query` exposures filtered by filmRollId, sorted by exposureNumber + +**Tests** (XCUITest): `testGalleryShowsExposures`, `testToggleStripGrid`, `testAddFromPhotoLibrary`, `testCopyPrevious`, `testDeleteExposure`, `testTapToDetails` + +### Step 5.2 — Exposure Details Screen +**Deliverable**: Full details viewing and editing; XCUITests. +- `DetailsView`: full-screen navigation push from GalleryView +- Header: back, Edit toggle button, Delete button (red) +- **Hero image** (4:3, full width): `Image` fill; if nil → seeded gradient + - Film perforation bar top (dark semi-transparent 12px): roll name left, "23A → 23" right (mono) + - Film perforation bar bottom: "ISO 400" left, date "05.14.26" right (mono) +- **2×2 readout tiles** (`ReadoutTile`): Aperture, Shutter, EI, Focal + - surface-1 bg, hairline border, md radius; label small muted, value large amber mono + - Muted/dimmed if EI or Focal not set +- **Metadata card** (surface-1, lg radius): rows with icon + label + value, hairline dividers + - Lens, Camera, Captured (full date/time), Location (lat/long or "—") +- **Notes section**: read mode (surface-1 box, tap to edit), inline edit mode (textarea + Cancel/Save) +- **Edit mode** (header toggle): + - Lens picker, Aperture selector, Shutter selector, EI selector, Focal slider (all enabled) + - Image overlay: Camera button + Gallery button + - Replacing image (camera or PHPicker) → `ImageUtils.downscale()` (max 1280px, JPEG 0.75) before saving, same as initial capture + - Save / Cancel in header +- `ExposureViewModel` (`@Observable`): manages edit state + +**Tests** (XCUITest): `testViewExposureDetails`, `testEditNotes`, `testEditMetadata`, `testReplaceImageFromLibrary`, `testDeleteExposureFromDetails` + +--- + +## Phase 6: Import/Export & Polish + +### Step 6.1 — Export +**Deliverable**: Export produces correct JSON format; XCUITests verify sheet and share trigger. +- Export button in GalleryView header → `ExportSheet` (`BottomSheet`) +- Options (radio-style chips): + 1. **JSON metadata only**: `{ filmRoll, exposures[], exportedAt, version: "2.0.0" }` without images → `UIActivityViewController` + 2. **JSON with images**: same + base64 imageData per exposure; warn if >10MB → `UIActivityViewController` + 3. **Multi-file** (folder): metadata.json + `exposure_N_ID.jpg` files → zip → `UIActivityViewController` +- Format matches PWA `exportImport.ts` exactly (Python `apply_filmroll_metadata.py` compatibility) +- `ExportService`: pure Swift, no UI dependencies + +**Tests** (XCUITest): `testExportSheetOpens`, `testExportJSONOnlyTriggersShare`, `testExportWithImagesTriggersShare` + +### Step 6.2 — Import +**Deliverable**: Import from files produces correct [IMPORTED]-prefixed film roll; XCUITests. +- Import sheet (FAB menu + header button) → options: + 1. **JSON with images**: `UIDocumentPickerViewController` (single JSON file) → parse → extract base64 images → `ImageUtils.downscale()` each → create film roll with `[IMPORTED]` prefix + 2. **Local folder**: `UIDocumentPickerViewController` (multi-file select) → find metadata.json + match image files → `ImageUtils.downscale()` each image file before saving +- Validation: missing fields, bad JSON, source image file > 50MB (reject before decode) +- Error alert for malformed imports +- `ImportService`: pure Swift + +**Tests** (XCUITest): `testImportJSONWithImages` (inject test fixture file), `testImportedRollAppearsWithPrefix`, `testImportPreservesAllMetadata`, `testMultipleImports` + +### Step 6.3 — Settings, App Icon & Final Polish +**Deliverable**: App fully feature-complete; all XCUITests pass on simulator. +- Settings sheet (header button in Rolls tab): + - **Sync**: Google Drive row (disabled, shows "Coming soon"), auto-sync toggle (disabled) + - **Capture**: "Rule-of-thirds grid" toggle, "Location" toggle, "Haptic feedback" toggle + - **Data**: "Export all rolls" (triggers multi-roll export), "Import archive", "Clear all data" (ConfirmationSheet) + - **About**: version string (from Bundle), "Open source" link +- App icon: amber-accented film canister / camera icon (all required sizes via Asset Catalog) +- Launch screen: dark bg `#0a0a0b`, amber film tracker wordmark centered +- Light/dark theme: `@Environment(\.colorScheme)` throughout — light theme uses `#f5f5f7` bg, same accent +- Final XCUITest regression sweep: + - `AppNavigationTests`: tabs, empty states, settings + - `EquipmentManagementTests`: camera + lens CRUD + - `FilmRollManagementTests`: roll CRUD, filters + - `CaptureWorkflowTests`: navigate to capture, configure settings, frame counter + - `GalleryTests`: strip/grid, import, details + - `ImportExportTests`: round-trip JSON + +**Tests** (XCUITest): `testSettingsToggles`, `testClearAllDataConfirmation`, `testFullNavigationFlow` + +--- + +## File Structure +``` +ios/ +├── global-plan.md ← this file +├── FilmTracker.xcodeproj +└── FilmTracker/ + ├── App/ + │ ├── FilmTrackerApp.swift # ModelContainer, app entry + │ └── ContentView.swift # TabView root + ├── Models/ # SwiftData @Model + │ ├── Camera.swift + │ ├── Lens.swift + │ ├── FilmRoll.swift + │ ├── Exposure.swift + │ └── AppSettings.swift + ├── DesignSystem/ + │ ├── Colors.swift + │ ├── Typography.swift + │ └── Constants.swift # Spacing, radius, apertures[], shutterSpeeds[], eiValues[] + ├── Components/ + │ ├── AppButton.swift + │ ├── AppChip.swift + │ ├── AppCard.swift + │ ├── BottomSheet.swift + │ ├── EmptyStateView.swift + │ ├── ConfirmationSheet.swift + │ └── EntityRow.swift + ├── Screens/ + │ ├── Rolls/ + │ │ ├── RollsView.swift + │ │ ├── RollCard.swift + │ │ ├── CreateRollSheet.swift + │ │ ├── FilterPillsView.swift + │ │ └── FABMenu.swift + │ ├── Equipment/ + │ │ ├── EquipmentView.swift + │ │ ├── CameraListView.swift + │ │ ├── LensListView.swift + │ │ ├── CreateCameraSheet.swift + │ │ └── CreateLensSheet.swift + │ ├── Capture/ + │ │ ├── CaptureView.swift + │ │ ├── FocalLengthOverlay.swift + │ │ ├── LightMeterView.swift + │ │ ├── NoteSheet.swift + │ │ ├── LensPickerSheet.swift + │ │ └── Pickers/ + │ │ ├── SettingsChipsRow.swift + │ │ ├── RadialDialPicker.swift + │ │ ├── WheelPickerView.swift + │ │ └── GridSheetPicker.swift + │ ├── Gallery/ + │ │ ├── GalleryView.swift + │ │ ├── ExposureStripCard.swift + │ │ ├── ExposureGridCell.swift + │ │ └── ExportSheet.swift + │ ├── Details/ + │ │ ├── DetailsView.swift + │ │ └── ReadoutTile.swift + │ └── Settings/ + │ └── SettingsView.swift + ├── ViewModels/ + │ ├── RollsViewModel.swift + │ ├── CameraViewModel.swift + │ ├── LensViewModel.swift + │ ├── CaptureViewModel.swift + │ ├── GalleryViewModel.swift + │ └── ExposureViewModel.swift + ├── Services/ + │ ├── CameraService.swift # AVFoundation + │ ├── LocationService.swift # CoreLocation + │ ├── ExportService.swift + │ └── ImportService.swift + ├── Utils/ + │ └── ImageUtils.swift # downscale(UIImage/Data) → JPEG Data, max 1280px, quality 0.75 + └── FilmTrackerTests/ # XCUITest target + ├── AppNavigationTests.swift + ├── EquipmentManagementTests.swift + ├── FilmRollManagementTests.swift + ├── CaptureWorkflowTests.swift + ├── GalleryTests.swift + └── ImportExportTests.swift +``` + +--- + +## Verification Per Step +Each step: `xcodebuild test -scheme FilmTracker -destination 'platform=iOS Simulator,name=iPhone 16'` +Final: all XCUITests pass on iPhone 16 simulator; PWA `npm run test:e2e` unchanged (no PWA changes made). + +## Key Design Decisions +- **SwiftData over Core Data**: cleaner Swift-native API, `@Model` macro, works with `@Observable` +- **iOS 17 minimum**: enables SwiftData, `@Observable`, newer SwiftUI APIs +- **No UIKit screens**: 100% SwiftUI for maintainability +- **Radial dial as primary picker**: novel UX differentiator from the mockup design, not in PWA +- **Dark-first theme**: matches film darkroom aesthetic; light theme supported via `colorScheme` +- **Export format compatibility**: matches PWA v2.0.0 format so Python `apply_filmroll_metadata.py` script works unchanged +- **Image storage strategy**: `@Attribute(.externalStorage)` on `Exposure.imageData` keeps JPEG bytes out of the SQLite store; all images downscaled to max 1280px / JPEG 0.75 at ingestion (camera, PHPicker, import) via shared `ImageUtils`, keeping a 36-frame JSON-with-images export comfortably under 10 MB diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..a6baf26 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,40 @@ +name: FilmTracker +options: + bundleIdPrefix: com.filmtracker +targets: + FilmTracker: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: FilmTracker + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.filmtracker.FilmTracker + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + DEVELOPMENT_ASSET_PATHS: '"FilmTracker/Preview Content"' + SWIFT_VERSION: 5.9 + INFOPLIST_KEY_UILaunchScreen_Generation: YES + GENERATE_INFOPLIST_FILE: YES + FilmTrackerTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "17.0" + sources: + - path: FilmTrackerTests + dependencies: + - target: FilmTracker + settings: + base: + GENERATE_INFOPLIST_FILE: YES + FilmTrackerUITests: + type: bundle.ui-testing + platform: iOS + deploymentTarget: "17.0" + sources: + - path: FilmTrackerUITests + dependencies: + - target: FilmTracker + settings: + base: + GENERATE_INFOPLIST_FILE: YES