diff --git a/Caps.xcodeproj/project.pbxproj b/Caps.xcodeproj/project.pbxproj index 8b463db..84eec13 100644 --- a/Caps.xcodeproj/project.pbxproj +++ b/Caps.xcodeproj/project.pbxproj @@ -8,6 +8,13 @@ /* Begin PBXBuildFile section */ 88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBE72D285495B100D1573B /* FancyTextField.swift */; }; + E20D104A285612AF0019BD91 /* ImageGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D1049285612AF0019BD91 /* ImageGrid.swift */; }; + E20D104C28563DB10019BD91 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D104B28563DB10019BD91 /* ImageCache.swift */; }; + E20D104E28574C7C0019BD91 /* CachedCapImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D104D28574C7C0019BD91 /* CachedCapImage.swift */; }; + E20D105028574E190019BD91 /* CapImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D104F28574E190019BD91 /* CapImage.swift */; }; + E20D105228589AAC0019BD91 /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */; }; + E20D10562858CDFA0019BD91 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D10552858CDFA0019BD91 /* View+Extensions.swift */; }; + E20D10582858CEBD0019BD91 /* IconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D10572858CEBD0019BD91 /* IconButton.swift */; }; E25AAC7C283D855D006E9E7F /* CapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7B283D855D006E9E7F /* CapsApp.swift */; }; E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7D283D855D006E9E7F /* ContentView.swift */; }; E25AAC80283D855F006E9E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E25AAC7F283D855F006E9E7F /* Assets.xcassets */; }; @@ -19,7 +26,6 @@ E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC93283D88A4006E9E7F /* Cap.swift */; }; E25AAC96283E14DF006E9E7F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC95283E14DF006E9E7F /* Database.swift */; }; E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC9A283E3395006E9E7F /* CapRowView.swift */; }; - E27E15E1283E418600F6804A /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = E27E15E0283E418600F6804A /* CachedAsyncImage */; }; E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2EA00C2283E672A00F7B269 /* SFSafeSymbols */; }; E2EA00C5283EA72000F7B269 /* SortCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C4283EA72000F7B269 /* SortCriteria.swift */; }; E2EA00C7283EAA0100F7B269 /* SortSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */; }; @@ -45,6 +51,13 @@ /* Begin PBXFileReference section */ 88DBE72D285495B100D1573B /* FancyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyTextField.swift; sourceTree = ""; }; + E20D1049285612AF0019BD91 /* ImageGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrid.swift; sourceTree = ""; }; + E20D104B28563DB10019BD91 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; + E20D104D28574C7C0019BD91 /* CachedCapImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedCapImage.swift; sourceTree = ""; }; + E20D104F28574E190019BD91 /* CapImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapImage.swift; sourceTree = ""; }; + E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = ""; }; + E20D10552858CDFA0019BD91 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + E20D10572858CEBD0019BD91 /* IconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButton.swift; sourceTree = ""; }; E25AAC78283D855D006E9E7F /* Caps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Caps.app; sourceTree = BUILT_PRODUCTS_DIR; }; E25AAC7B283D855D006E9E7F /* CapsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsApp.swift; sourceTree = ""; }; E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -85,7 +98,6 @@ files = ( E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */, E2EA00CA283EACB200F7B269 /* BottomSheet in Frameworks */, - E27E15E1283E418600F6804A /* CachedAsyncImage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -114,11 +126,11 @@ E25AAC7B283D855D006E9E7F /* CapsApp.swift */, E25AAC7D283D855D006E9E7F /* ContentView.swift */, E2EA00CF283EDD2C00F7B269 /* Camera */, - E25AAC97283E337C006E9E7F /* Views */, E25AAC89283D8666006E9E7F /* Data */, - E25AAC7F283D855F006E9E7F /* Assets.xcassets */, + E25AAC97283E337C006E9E7F /* Views */, E25AAC8E283D870F006E9E7F /* Extensions */, E25AAC8C283D86CF006E9E7F /* Logger.swift */, + E25AAC7F283D855F006E9E7F /* Assets.xcassets */, E25AAC81283D855F006E9E7F /* Preview Content */, ); path = Caps; @@ -135,11 +147,14 @@ E25AAC89283D8666006E9E7F /* Data */ = { isa = PBXGroup; children = ( - E25AAC8A283D868D006E9E7F /* Classifier.swift */, - E2EA00C4283EA72000F7B269 /* SortCriteria.swift */, E25AAC93283D88A4006E9E7F /* Cap.swift */, E25AAC91283D8808006E9E7F /* CapData.swift */, + E25AAC8A283D868D006E9E7F /* Classifier.swift */, E25AAC95283E14DF006E9E7F /* Database.swift */, + E20D104B28563DB10019BD91 /* ImageCache.swift */, + E20D1049285612AF0019BD91 /* ImageGrid.swift */, + E2EA00C4283EA72000F7B269 /* SortCriteria.swift */, + E20D104F28574E190019BD91 /* CapImage.swift */, ); path = Data; sourceTree = ""; @@ -152,6 +167,8 @@ E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */, E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */, E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */, + E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */, + E20D10552858CDFA0019BD91 /* View+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -159,15 +176,17 @@ E25AAC97283E337C006E9E7F /* Views */ = { isa = PBXGroup; children = ( - E25AAC9A283E3395006E9E7F /* CapRowView.swift */, - E2EA00E2283F662800F7B269 /* GridView.swift */, - E2EA00E0283F658E00F7B269 /* SettingsView.swift */, - E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */, - E2EA00CD283EBEB600F7B269 /* SearchField.swift */, - 88DBE72D285495B100D1573B /* FancyTextField.swift */, + E20D104D28574C7C0019BD91 /* CachedCapImage.swift */, E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */, - E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */, + E25AAC9A283E3395006E9E7F /* CapRowView.swift */, + 88DBE72D285495B100D1573B /* FancyTextField.swift */, + E2EA00E2283F662800F7B269 /* GridView.swift */, + E20D10572858CEBD0019BD91 /* IconButton.swift */, + E2EA00CD283EBEB600F7B269 /* SearchField.swift */, + E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */, + E2EA00E0283F658E00F7B269 /* SettingsView.swift */, E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */, + E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */, ); path = Views; sourceTree = ""; @@ -175,13 +194,13 @@ E2EA00CF283EDD2C00F7B269 /* Camera */ = { isa = PBXGroup; children = ( + E2EA00D2283EDDF700F7B269 /* CameraError.swift */, E2EA00D0283EDD6300F7B269 /* CameraManager.swift */, E2EA00D8283F5BB900F7B269 /* CameraView.swift */, E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */, - E2EA00DC283F5C6A00F7B269 /* FrameView.swift */, - E2EA00D4283EDFA200F7B269 /* FrameManager.swift */, - E2EA00D2283EDDF700F7B269 /* CameraError.swift */, E2EA00DE283F5CA000F7B269 /* ErrorView.swift */, + E2EA00D4283EDFA200F7B269 /* FrameManager.swift */, + E2EA00DC283F5C6A00F7B269 /* FrameView.swift */, ); path = Camera; sourceTree = ""; @@ -203,7 +222,6 @@ ); name = Caps; packageProductDependencies = ( - E27E15E0283E418600F6804A /* CachedAsyncImage */, E2EA00C2283E672A00F7B269 /* SFSafeSymbols */, E2EA00C9283EACB200F7B269 /* BottomSheet */, ); @@ -236,7 +254,6 @@ ); mainGroup = E25AAC6F283D855D006E9E7F; packageReferences = ( - E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */, ); @@ -266,14 +283,18 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E20D10582858CEBD0019BD91 /* IconButton.swift in Sources */, E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */, E2EA00F328438E6B00F7B269 /* CapNameEntryView.swift in Sources */, E25AAC8B283D868D006E9E7F /* Classifier.swift in Sources */, + E20D10562858CDFA0019BD91 /* View+Extensions.swift in Sources */, E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */, E2EA00D9283F5BB900F7B269 /* CameraView.swift in Sources */, E2EA00E3283F662800F7B269 /* GridView.swift in Sources */, E2EA00EB284109CC00F7B269 /* CGImage+Extensions.swift in Sources */, + E20D104E28574C7C0019BD91 /* CachedCapImage.swift in Sources */, E2EA00DF283F5CA000F7B269 /* ErrorView.swift in Sources */, + E20D104A285612AF0019BD91 /* ImageGrid.swift in Sources */, E2EA00D5283EDFA200F7B269 /* FrameManager.swift in Sources */, E25AAC90283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift in Sources */, E2EA00CE283EBEB600F7B269 /* SearchField.swift in Sources */, @@ -289,10 +310,13 @@ E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */, E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */, E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */, + E20D104C28563DB10019BD91 /* ImageCache.swift in Sources */, E25AAC92283D8808006E9E7F /* CapData.swift in Sources */, E25AAC96283E14DF006E9E7F /* Database.swift in Sources */, E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */, + E20D105028574E190019BD91 /* CapImage.swift in Sources */, E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */, + E20D105228589AAC0019BD91 /* FileManager+Extensions.swift in Sources */, E2EA00E5283F69DF00F7B269 /* SettingsStatisticRow.swift in Sources */, E2EA00E1283F658E00F7B269 /* SettingsView.swift in Sources */, ); @@ -427,6 +451,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Export cap grids to Photos"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -458,6 +483,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones"; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Export cap grids to Photos"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -501,14 +527,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; @@ -528,11 +546,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - E27E15E0283E418600F6804A /* CachedAsyncImage */ = { - isa = XCSwiftPackageProductDependency; - package = E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; - productName = CachedAsyncImage; - }; E2EA00C2283E672A00F7B269 /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; package = E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; diff --git a/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bcf05e2..3547906 100644 --- a/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -17,15 +17,6 @@ "revision" : "c8c33d947d8a1c883aa19fd24e14fd738b06e369", "version" : "3.3.2" } - }, - { - "identity" : "swiftui-cached-async-image", - "kind" : "remoteSourceControl", - "location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image", - "state" : { - "revision" : "eeb1565d780d1b75d045e21b5ca2a1e3650b0fc2", - "version" : "2.1.0" - } } ], "version" : 2 diff --git a/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate b/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate index ee68d55..65ecd56 100644 Binary files a/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate and b/Caps.xcodeproj/project.xcworkspace/xcuserdata/ch.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Caps/Camera/CameraManager.swift b/Caps/Camera/CameraManager.swift index 5dc4bf8..801850d 100644 --- a/Caps/Camera/CameraManager.swift +++ b/Caps/Camera/CameraManager.swift @@ -20,6 +20,8 @@ class CameraManager: ObservableObject { private let photoOutput = AVCapturePhotoOutput() private var status = Status.unconfigured + private var camera: AVCaptureDevice? + private init() { configure() } @@ -67,11 +69,11 @@ class CameraManager: ObservableObject { session.commitConfiguration() } - let device = AVCaptureDevice.default( + self.camera = AVCaptureDevice.default( .builtInWideAngleCamera, for: .video, position: .back) - guard let camera = device else { + guard let camera = camera else { set(error: .cameraUnavailable) status = .failed return @@ -169,4 +171,28 @@ class CameraManager: ObservableObject { self.photoOutput.capturePhoto(with: photoSettings, delegate: delegate) } } + + // MARK: Focus + + func continuouslyFocusOnMiddle() { + guard let device = camera else { + return + } + do { + try device.lockForConfiguration() + + if device.isFocusPointOfInterestSupported { + device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5) + device.focusMode = .continuousAutoFocus + } + print("Enabled continuous autofocus") + device.unlockForConfiguration() + } catch { + self.error("Could not lock device for configuration: \(error)") + } + } +} + +extension CameraManager: Logger { + } diff --git a/Caps/Camera/CameraView.swift b/Caps/Camera/CameraView.swift index 2889865..b25e1d0 100644 --- a/Caps/Camera/CameraView.swift +++ b/Caps/Camera/CameraView.swift @@ -87,7 +87,8 @@ struct CameraView: View { .overlay(RoundedRectangle(cornerRadius: circleSize/2) .stroke(lineWidth: circleStrength) .foregroundColor(circleColor)) - + .onTapGesture(perform: didTapCircle) + .background(Color(white: 1, opacity: 0.01)) Spacer() } Spacer() @@ -116,6 +117,10 @@ struct CameraView: View { private func capture() { model.captureImage() } + + private func didTapCircle() { + model.continuouslyFocusOnMiddle() + } } struct CameraView_Previews: PreviewProvider { diff --git a/Caps/Camera/ContentViewModel.swift b/Caps/Camera/ContentViewModel.swift index c506130..68f999c 100644 --- a/Caps/Camera/ContentViewModel.swift +++ b/Caps/Camera/ContentViewModel.swift @@ -54,4 +54,8 @@ class ContentViewModel: ObservableObject { func captureImage() { cameraManager.capturePhoto(delegate: frameManager) } + + func continuouslyFocusOnMiddle() { + cameraManager.continuouslyFocusOnMiddle() + } } diff --git a/Caps/CapsApp.swift b/Caps/CapsApp.swift index 7fe3d48..61273da 100644 --- a/Caps/CapsApp.swift +++ b/Caps/CapsApp.swift @@ -1,11 +1,15 @@ import SwiftUI #warning("TODO: Add colors") -#warning("TODO: Grid view") +#warning("TODO: Rearrange caps in grid view") +#warning("TODO: Change main image") +#warning("TODO: Load/save grid images") @main struct CapsApp: App { + static let thumbnailImageSize: CGFloat = 60 + let database = Database(server: URL(string: "https://christophhagen.de/caps")!) var body: some Scene { diff --git a/Caps/ContentView.swift b/Caps/ContentView.swift index 333ed9a..f5bcc00 100644 --- a/Caps/ContentView.swift +++ b/Caps/ContentView.swift @@ -245,7 +245,7 @@ struct ContentView: View { SettingsView(isPresented: $showSettingsSheet) } .sheet(isPresented: $showGridView) { - GridView() + GridView(isPresented: $showGridView) }.alert(isPresented: $showNewClassifierAlert) { Alert(title: Text("New classifier available"), message: Text("Classifier \(database.serverClassifierVersion) is available. You have version \(database.classifierVersion). Do you want to download it now?"), diff --git a/Caps/Data/Cap.swift b/Caps/Data/Cap.swift index 43a293d..0145399 100644 --- a/Caps/Data/Cap.swift +++ b/Caps/Data/Cap.swift @@ -27,6 +27,10 @@ struct Cap { String(format: "images/%04d/%04d-%02d.jpg", id, id, mainImage) } + var image: CapImage { + .init(cap: id, version: mainImage) + } + /** Create a new cap. - Parameter id: The unique id of the cap diff --git a/Caps/Data/CapImage.swift b/Caps/Data/CapImage.swift new file mode 100644 index 0000000..836e495 --- /dev/null +++ b/Caps/Data/CapImage.swift @@ -0,0 +1,8 @@ +import Foundation + +struct CapImage: Codable, Equatable, Hashable { + + let cap: Int + + let version: Int +} diff --git a/Caps/Data/Database.swift b/Caps/Data/Database.swift index c0504f5..576dae4 100644 --- a/Caps/Data/Database.swift +++ b/Caps/Data/Database.swift @@ -5,52 +5,23 @@ import CryptoKit final class Database: ObservableObject { - static let imageCacheMemory = 10_000_000 - - static let imageCacheStorage = 200_000_000 - private let imageCompressionQuality: CGFloat = 0.3 - private static var documentDirectory: URL { - try! FileManager.default.url( - for: .documentDirectory, - in: .userDomainMask, - appropriateFor: nil, create: true) - } + @AppStorage("classifier") + private(set) var classifierVersion = 0 - private var fm: FileManager { - .default - } + @AppStorage("serverClassifier") + private(set) var serverClassifierVersion = 0 - private var localDbUrl: URL { - Database.documentDirectory.appendingPathComponent("db.json") - } - - private var localClassifierUrl: URL { - Database.documentDirectory.appendingPathComponent("classifier.mlmodel") - } - - private var imageUploadFolderUrl: URL { - Database.documentDirectory.appendingPathComponent("uploads") - } - - private var serverDbUrl: URL { - serverUrl.appendingPathComponent("caps.json") - } - - private var serverClassifierUrl: URL { - serverUrl.appendingPathComponent("classifier.mlmodel") - } - - private var serverClassifierVersionUrl: URL { - serverUrl.appendingPathComponent("classifier.version") - } + let images: ImageCache private let encoder = JSONEncoder() private let decoder = JSONDecoder() let serverUrl: URL + let folderUrl: URL + @AppStorage("authKey") private var serverAuthenticationKey: String = "" @@ -108,22 +79,68 @@ final class Database: ObservableObject { */ private var nextSaveTime: Date? - let imageCache: URLCache + @Published + var isUploading = false - init(server: URL) { + init(server: URL, folder: URL = FileManager.default.documentDirectory) { self.serverUrl = server + self.folderUrl = folder self.caps = [:] - let cacheDirectory = Database.documentDirectory.appendingPathComponent("images") - self.imageCache = URLCache( - memoryCapacity: Database.imageCacheMemory, - diskCapacity: Database.imageCacheStorage, - directory: cacheDirectory) + let imageFolder = folder.appendingPathComponent("images") + self.images = try! ImageCache( + folder: imageFolder, + server: server, + thumbnailSize: CapsApp.thumbnailImageSize) + + ensureFolderExistence(gridStorageFolder) loadCaps() } - @Published - var isUploading = false + func mainImage(for cap: Int) -> Int { + caps[cap]?.mainImage ?? 0 + } + + // MARK: URLs + + private var fm: FileManager { + .default + } + + private var localDbUrl: URL { + folderUrl.appendingPathComponent("db.json") + } + + private var localClassifierUrl: URL { + folderUrl.appendingPathComponent("classifier.mlmodel") + } + + private var imageUploadFolderUrl: URL { + folderUrl.appendingPathComponent("uploads") + } + + private var serverDbUrl: URL { + serverUrl.appendingPathComponent("caps.json") + } + + private var serverClassifierUrl: URL { + serverUrl.appendingPathComponent("classifier.mlmodel") + } + + private var serverClassifierVersionUrl: URL { + serverUrl.appendingPathComponent("classifier.version") + } + + private var gridStorageFolder: URL { + folderUrl.appendingPathComponent("grids") + } + + func mainImageUrl(for cap: Int) -> URL? { + guard let path = caps[cap]?.mainImagePath else { + return nil + } + return serverUrl.appendingPathComponent(path) + } // MARK: Disk storage @@ -185,6 +202,7 @@ final class Database: ObservableObject { print("Database saved") } + @discardableResult private func ensureFolderExistence(_ url: URL) -> Bool { guard !fm.fileExists(atPath: url.path) else { return true @@ -395,6 +413,14 @@ final class Database: ObservableObject { DispatchQueue.main.async { self.isUploading = true } + defer { + DispatchQueue.main.async { + self.isUploading = false + } + } + guard !changedCaps.isEmpty || pendingImageUploadCount > 0 else { + return + } log("Starting uploads") let uploaded = await uploadAllChangedCaps() DispatchQueue.main.async { @@ -402,9 +428,6 @@ final class Database: ObservableObject { } await uploadAllImages() log("Uploads finished") - DispatchQueue.main.async { - self.isUploading = false - } } /** @@ -595,6 +618,82 @@ final class Database: ObservableObject { return Classifier(model: model) } + // MARK: Grid + + var availableGrids: [String] { + do { + return try fm.contentsOfDirectory(at: gridStorageFolder, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "caps" } + .map { $0.deletingPathExtension().lastPathComponent } + } catch { + print("Failed to load available grids: \(error)") + return [] + } + } + + private func gridFileUrl(_ grid: String) -> URL { + gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("caps") + } + + private func gridImageUrl(_ grid: String) -> URL { + gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("jpg") + } + + func load(grid: String) -> ImageGrid? { + let url = gridFileUrl(grid) + guard fm.fileExists(atPath: url.path) else { + return nil + } + do { + let data = try Data(contentsOf: url) + var loaded = try decoder.decode(ImageGrid.self, from: data) + // Add all missing caps to the end of the image + let newCaps = Set(caps.keys).subtracting(loaded.capPlacements).sorted() + loaded.capPlacements += newCaps + print("Grid \(grid) loaded (\(newCaps.count) new caps)") + return loaded + } catch { + print("Failed to load grid \(grid): \(error)") + return nil + } + } + + @discardableResult + func save(_ grid: ImageGrid, named name: String) -> Bool { + let url = gridFileUrl(name) + do { + let data = try encoder.encode(grid) + try data.write(to: url) + print("Grid \(name) saved") + return true + } catch { + print("Failed to save grid \(name): \(error)") + return false + } + } + + // MARK: Grid images + + func load(gridImage: String) -> UIImage? { + let url = gridImageUrl(gridImage) + return UIImage(at: url) + } + + @discardableResult + func save(gridImage: UIImage, for grid: String) -> Bool { + guard let data = gridImage.jpegData(compressionQuality: 0.9) else { + return false + } + let url = gridImageUrl(grid) + do { + try data.write(to: url) + return true + } catch { + print("Failed to save grid image \(grid): \(error)") + return false + } + } + // MARK: Statistics var numberOfCaps: Int { @@ -609,19 +708,13 @@ final class Database: ObservableObject { Float(numberOfImages) / Float(numberOfCaps) } - @AppStorage("classifier") - private(set) var classifierVersion = 0 - - @AppStorage("serverClassifier") - private(set) var serverClassifierVersion = 0 - var classifierClassCount: Int { let version = classifierVersion return caps.values.filter { $0.classifiable(by: version) }.count } var imageCacheSize: Int { - imageCache.currentDiskUsage + fm.directorySize(images.folder) } var databaseSize: Int { @@ -648,4 +741,13 @@ extension Database { db.image = UIImage(systemSymbol: .photo) return db } + + static var largeMock: Database { + let db = Database(server: URL(string: "https://christophhagen.de/caps")!) + db.caps = (1..<500) + .map { Cap(id: $0, name: "Cap \($0)", classifier: nil)} + .reduce(into: [:]) { $0[$1.id] = $1 } + db.image = UIImage(systemSymbol: .photo) + return db + } } diff --git a/Caps/Data/ImageCache.swift b/Caps/Data/ImageCache.swift new file mode 100644 index 0000000..8fc8946 --- /dev/null +++ b/Caps/Data/ImageCache.swift @@ -0,0 +1,209 @@ +import Foundation +import UIKit + +final class ImageCache { + + let folder: URL + + let server: URL + + let thumbnailSize: CGFloat + + private let fm: FileManager = .default + + private let session: URLSession = .shared + + private let thumbnailQuality: CGFloat = 0.7 + + init(folder: URL, server: URL, thumbnailSize: CGFloat) throws { + self.folder = folder + self.server = server + self.thumbnailSize = thumbnailSize * UIScreen.main.scale + + if !fm.fileExists(atPath: folder.path) { + try fm.createDirectory(at: folder, withIntermediateDirectories: true) + } + } + + private func localImageUrl(_ image: CapImage) -> URL { + folder.appendingPathComponent(String(format: "%04d-%02d.jpg", image.cap, image.cap, image.version)) + } + + private func remoteImageUrl(_ image: CapImage) -> URL { + server.appendingPathComponent(String(format: "images/%04d/%04d-%02d.jpg", image.cap, image.cap, image.version)) + } + + func image(_ image: CapImage, completion: @escaping (UIImage?) -> ()) { + Task { + let image = await self.image(image) + completion(image) + } + } + + func image(_ image: CapImage, download: Bool = true) async -> UIImage? { + if let localUrl = existingLocalImageUrl(image) { + return UIImage(at: localUrl) + } + guard download else { + return nil + } + guard let downloadedImageUrl = await loadRemoteImage(image) else { + return nil + } + guard saveImage(image, at: downloadedImageUrl) else { + return UIImage(at: downloadedImageUrl) + } + let localUrl = localImageUrl(image) + return UIImage(at: localUrl) + } + + func cachedImage(_ image: CapImage) -> UIImage? { + guard let localUrl = existingLocalImageUrl(image) else { + return nil + } + return UIImage(at: localUrl) + } + + @discardableResult + func removeImage(_ image: CapImage) -> Bool { + let localUrl = localImageUrl(image) + return removePossibleFile(localUrl) + } + + @discardableResult + func refreshImage(_ image: CapImage) async -> Bool { + guard let downloadedImageUrl = await loadRemoteImage(image) else { + return false + } + return saveImage(image, at: downloadedImageUrl) + } + + private func loadRemoteImage(_ image: CapImage) async -> URL? { + let remoteURL = remoteImageUrl(image) + return await loadRemoteImage(at: remoteURL) + } + + private func loadRemoteImage(at url: URL) async -> URL? { + let tempUrl: URL + let response: URLResponse + do { + (tempUrl, response) = try await session.download(from: url) + } catch { + print("Failed to download image \(url.lastPathComponent): \(error)") + return nil + } + guard let httpResponse = response as? HTTPURLResponse else { + print("Failed to download image \(url.lastPathComponent): Not a HTTP response: \(response)") + return nil + } + guard httpResponse.statusCode == 200 else { + print("Failed to download image \(url.path): Response \(httpResponse.statusCode)") + return nil + } + return tempUrl + } + + private func saveImage(_ image: CapImage, at tempUrl: URL) -> Bool { + let localUrl = localImageUrl(image) + guard removePossibleFile(localUrl) else { + return false + } + do { + try fm.moveItem(at: tempUrl, to: localUrl) + return true + } catch { + print("failed to save image \(localUrl.lastPathComponent): \(error)") + return false + } + } + + private func existingLocalImageUrl(_ image: CapImage) -> URL? { + let localFile = localImageUrl(image) + guard exists(localFile) else { + return nil + } + return localFile + } + + private func exists(_ url: URL) -> Bool { + fm.fileExists(atPath: url.path) + } + + @discardableResult + private func removePossibleFile(_ file: URL) -> Bool { + guard exists(file) else { + return true + } + return remove(file) + } + + @discardableResult + private func remove(_ url: URL) -> Bool { + do { + try fm.removeItem(at: url) + return true + } catch { + print("Failed to remove \(url.lastPathComponent): \(error)") + return false + } + } + + // MARK: Thumbnails + + private func localThumbnailUrl(cap: Int) -> URL { + folder.appendingPathComponent(String(format: "%04d.jpg", cap)) + } + + func thumbnail(for image: CapImage, download: Bool = true) async -> UIImage? { + let localUrl = localThumbnailUrl(cap: image.cap) + if exists(localUrl) { + return UIImage(at: localUrl) + } + + guard let mainImage = await self.image(image, download: download) else { + return nil + } + let thumbnail = await createThumbnail(mainImage) + save(thumbnail: thumbnail, for: image.cap) + return thumbnail + } + + func cachedThumbnail(for image: CapImage) -> UIImage? { + let localUrl = localThumbnailUrl(cap: image.cap) + guard exists(localUrl) else { + return nil + } + return UIImage(at: localUrl) + } + + @discardableResult + func createThumbnail(for image: CapImage, download: Bool = false) async -> Bool { + await thumbnail(for: image, download: download) != nil + } + + private func createThumbnail(_ image: UIImage) async -> UIImage { + let size = thumbnailSize + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .background).async { + let small = image.resize(to: CGSize(width: size, height: size)) + continuation.resume(returning: small) + } + } + } + + @discardableResult + private func save(thumbnail: UIImage, for cap: Int) -> Bool { + guard let data = thumbnail.jpegData(compressionQuality: thumbnailQuality) else { + print("Failed to get thumbnail JPEG data") + return false + } + let localUrl = localThumbnailUrl(cap: cap) + do { + try data.write(to: localUrl) + return true + } catch { + print("Failed to save thumbnail \(cap): \(error)") + return false + } + } +} diff --git a/Caps/Data/ImageGrid.swift b/Caps/Data/ImageGrid.swift new file mode 100644 index 0000000..596f166 --- /dev/null +++ b/Caps/Data/ImageGrid.swift @@ -0,0 +1,82 @@ +import Foundation + +struct ImageGrid: Codable { + + struct Position { + + let x: Int + + let y: Int + } + + struct Item: Identifiable { + + let id: Int + + let cap: Int + } + + let columns: Int + + /** + The place of each cap. + + The index is the position in the image, + where `x = index % columns` and `y = index / columns` + */ + var capPlacements: [Int] + + /// All caps currently present in the image + var caps: Set { + Set(capPlacements) + } + + var items: [Item] { + capPlacements.enumerated().map { + .init(id: $0, cap: $1) + } + } + + var capCount: Int { + capPlacements.count + } + + func index(of position: Position) -> Int? { + return index(x: position.x, y: position.y) + } + + func index(x: Int, y: Int) -> Int? { + let index = y * columns + y + guard index < capCount else { + return nil + } + return capPlacements[index] + } + + mutating func switchCaps(at x: Int, _ y: Int, with otherX: Int, _ otherY: Int) { + guard let other = index(x: x, y: y), let index = index(x: otherX, y: otherY) else { + return + } + switchCaps(at: index, with: other) + } + + mutating func switchCaps(at position: Position, with other: Position) { + guard let other = index(of: other), let index = index(of: position) else { + return + } + switchCaps(at: index, with: other) + } + + mutating func switchCaps(at index: Int, with other: Int) { + guard index < capCount, other < capCount else { + return + } + let temp = capPlacements[index] + capPlacements[index] = capPlacements[other] + capPlacements[other] = temp + } + + static func mock(columns: Int, count: Int) -> ImageGrid { + .init(columns: columns, capPlacements: Array(0.. DirectoryEnumerator? { + enumerator(at: directory, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey], + options: []) { (_, error) -> Bool in + print(error) + return false + } + } + + func directorySize(_ directory: URL) -> Int { + return fileSizeEnumerator(at: directory)? + .compactMap { $0 as? URL } + .reduce(0) { $0 + $1.fileSize } ?? 0 + } +} diff --git a/Caps/Extensions/UIImage+Extensions.swift b/Caps/Extensions/UIImage+Extensions.swift index 0bf66e2..71380da 100644 --- a/Caps/Extensions/UIImage+Extensions.swift +++ b/Caps/Extensions/UIImage+Extensions.swift @@ -147,3 +147,14 @@ private func saturate(_ component: UInt8) -> CGFloat { } extension CIImage: Logger { } + + +extension UIImage { + + convenience init?(at url: URL) { + guard let data = try? Data(contentsOf: url) else { + return nil + } + self.init(data: data, scale: UIScreen.main.scale) + } +} diff --git a/Caps/Extensions/URL+Extensions.swift b/Caps/Extensions/URL+Extensions.swift index eb550a6..54f4b19 100644 --- a/Caps/Extensions/URL+Extensions.swift +++ b/Caps/Extensions/URL+Extensions.swift @@ -22,4 +22,14 @@ extension URL { var creationDate: Date? { return attributes?[.creationDate] as? Date } + + var fileSizeAlt: Int? { + do { + let val = try self.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) + return val.totalFileAllocatedSize ?? val.fileAllocatedSize + } catch { + print(error) + return nil + } + } } diff --git a/Caps/Extensions/View+Extensions.swift b/Caps/Extensions/View+Extensions.swift new file mode 100644 index 0000000..564cb56 --- /dev/null +++ b/Caps/Extensions/View+Extensions.swift @@ -0,0 +1,19 @@ +import SwiftUI + +extension View { + + func snapshot() -> UIImage { + let controller = UIHostingController(rootView: self) + let view = controller.view + + let targetSize = controller.view.intrinsicContentSize + view?.bounds = CGRect(origin: .zero, size: targetSize) + view?.backgroundColor = .clear + + let renderer = UIGraphicsImageRenderer(size: targetSize) + + return renderer.image { _ in + view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) + } + } +} diff --git a/Caps/Views/CachedCapImage.swift b/Caps/Views/CachedCapImage.swift new file mode 100644 index 0000000..036c989 --- /dev/null +++ b/Caps/Views/CachedCapImage.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct CachedCapImage: View where Content: View, T: Equatable { + + @State private var phase: AsyncImagePhase + + let id: T + + let check: () -> UIImage? + + let fetch: () async -> UIImage? + + private let transaction: Transaction + + private let content: (AsyncImagePhase) -> Content + + var body: some View { + content(phase) + .task(id: id, load) + } + + init(_ id: T, _ image: CapImage, cache: ImageCache, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { + self.init(id, image: image, cache: cache) { phase in + if let image = phase.image { + content(image) + } else { + placeholder() + } + } + } + + init(_ id: T, image: CapImage, cache: ImageCache, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) { + self.init(id, + check: { cache.cachedImage(image) }, + fetch: { await cache.image(image) }, + transaction: transaction, + content: content) + } + + init(_ id: T, check: @escaping () -> UIImage?, fetch: @escaping () async -> UIImage?, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent, I : View, P : View { + self.init(id, check: check, fetch: fetch) { phase in + if let image = phase.image { + content(image) + } else { + placeholder() + } + } + } + + init(_ id: T, check: @escaping () -> UIImage?, fetch: @escaping () async -> UIImage?, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) { + self.id = id + self.check = check + self.fetch = fetch + self.transaction = transaction + self.content = content + + self._phase = State(wrappedValue: .empty) + + guard let image = check() else { + return + } + let wrapped = Image(uiImage: image) + self._phase = State(wrappedValue: .success(wrapped)) + } + + @Sendable + private func load() async { + guard let image = await fetch() else { + withAnimation(transaction.animation) { + phase = .empty + } + return + } + let wrapped = Image(uiImage: image) + withAnimation(transaction.animation) { + phase = .success(wrapped) + } + } +} diff --git a/Caps/Views/CapRowView.swift b/Caps/Views/CapRowView.swift index 3e66131..921e233 100644 --- a/Caps/Views/CapRowView.swift +++ b/Caps/Views/CapRowView.swift @@ -1,5 +1,4 @@ import SwiftUI -import CachedAsyncImage struct CapRowView: View { @@ -56,7 +55,7 @@ struct CapRowView: View { } }//.padding(.vertical) Spacer() - CachedAsyncImage(url: imageUrl, urlCache: database.imageCache) { image in + CachedCapImage(cap, cap.image, cache: database.images) { image in image.resizable() } placeholder: { ProgressView() diff --git a/Caps/Views/GridView.swift b/Caps/Views/GridView.swift index 043e6bf..85ba0f8 100644 --- a/Caps/Views/GridView.swift +++ b/Caps/Views/GridView.swift @@ -1,13 +1,171 @@ import SwiftUI +import SFSafeSymbols struct GridView: View { + + @EnvironmentObject + var database: Database + + + @AppStorage("currentGridName") + private(set) var currentGridName = "default" + + private var defaultImageGrid: ImageGrid { + .init(columns: 40, capPlacements: database.caps.keys.sorted()) + } + + var imageSize: CGFloat { + CapsApp.thumbnailImageSize + } + + private let verticalInsetFactor: CGFloat = cos(.pi / 6) + + private let minScale: CGFloat = 1.0 + + private let maxScale: CGFloat = 0.5 + + private let cancelButtonSize: CGFloat = 75 + private let cancelIconSize: CGFloat = 25 + + @Binding + var isPresented: Bool + + var image: ImageGrid + + @State var scale: CGFloat = 1.0 + + @State var lastScaleValue: CGFloat = 1.0 + + init(isPresented: Binding) { + self._isPresented = isPresented + self.image = .init(columns: 1, capPlacements: []) + + if let image = database.load(grid: currentGridName) { + self.image = image + } else { + self.image = defaultImageGrid + currentGridName = "default" + } + } + + var columnCount: Int { + image.columns + } + + var capCount: Int { + image.capCount + } + + var imageHeight: CGFloat { + (CGFloat(capCount) / CGFloat(columnCount)).rounded(.up) * verticalInsetFactor * imageSize + (1-verticalInsetFactor) * imageSize + } + + var imageWidth: CGFloat { + imageSize * (CGFloat(columnCount) + 0.5) + } + + var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { val in + let delta = val / self.lastScaleValue + self.lastScaleValue = val + self.scale = max(min(self.scale * delta, minScale), maxScale) + } + .onEnded { val in + // without this the next gesture will be broken + self.lastScaleValue = 1.0 + } + } + + var gridView: some View { + let gridItems = Array(repeating: GridItem(.fixed(imageSize), spacing: 0), count: columnCount) + + return LazyVGrid(columns: gridItems, alignment: .leading, spacing: 0) { + ForEach(image.items) { item in + CachedCapImage( + item.id, + check: { cachedImage(item.cap) }, + fetch: { await fetchImage(item.cap) }, + content: { $0.resizable() }, + placeholder: { ProgressView() }) + .frame(width: imageSize, + height: imageSize) + .clipShape(Circle()) + .offset(x: isEvenRow(item.id) ? 0 : imageSize / 2) + .frame(width: imageSize, + height: imageSize * verticalInsetFactor) + } + } + .frame(width: imageWidth) + } + var body: some View { - Text("Grid view") + ZStack { + ScrollView([.vertical, .horizontal]) { + gridView + .scaleEffect(scale) + .frame( + width: imageWidth * scale, + height: imageHeight * scale + ) + } + .gesture(magnificationGesture) + .onDisappear { + database.save(image, named: currentGridName) + } + + VStack { + Spacer() + HStack { + Spacer() + IconButton(action: saveScreenshot, + icon: .squareAndArrowDown, + iconSize: cancelIconSize, + buttonSize: cancelButtonSize) + .padding() + IconButton(action: dismiss, + icon: .xmark, + iconSize: cancelIconSize, + buttonSize: cancelButtonSize) + .padding() + } + } + } + } + + func isEvenRow(_ idx: Int) -> Bool { + (idx / columnCount) % 2 == 0 + } + + private func cachedImage(_ cap: Int) -> UIImage? { + let image = CapImage( + cap: cap, + version: database + .mainImage(for: cap)) + return database.images.cachedImage(image) + } + + private func fetchImage(_ cap: Int) async -> UIImage? { + let image = CapImage( + cap: cap, + version: database + .mainImage(for: cap)) + return await database.images.thumbnail(for: image) + } + + private func dismiss() { + isPresented = false + } + + private func saveScreenshot() { + let image = gridView.snapshot() + UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil) } } struct GridView_Previews: PreviewProvider { static var previews: some View { - GridView() + GridView(isPresented: .constant(true)) + .environmentObject(Database.largeMock) } } diff --git a/Caps/Views/IconButton.swift b/Caps/Views/IconButton.swift new file mode 100644 index 0000000..4e42297 --- /dev/null +++ b/Caps/Views/IconButton.swift @@ -0,0 +1,41 @@ +import SwiftUI +import SFSafeSymbols + +struct IconButton: View { + + let action: () -> Void + + let icon: SFSymbol + + let iconSize: CGFloat + + let buttonSize: CGFloat + + private var padding: CGFloat { + (buttonSize - iconSize) / 2 + } + + private var cornerRadius: CGFloat { + buttonSize / 2 + } + + var body: some View { + Button(action: action) { + Image(systemSymbol: icon) + .resizable() + .frame(width: iconSize, height: iconSize) + .padding(padding) + .background(.thinMaterial) + .cornerRadius(cornerRadius) + } + } +} + +struct IconButton_Previews: PreviewProvider { + static var previews: some View { + IconButton(action: { }, + icon: .xmark, + iconSize: 20, + buttonSize: 25) + } +}