diff --git a/Caps.xcodeproj/project.pbxproj b/Caps.xcodeproj/project.pbxproj index fce0092..1f8e577 100644 --- a/Caps.xcodeproj/project.pbxproj +++ b/Caps.xcodeproj/project.pbxproj @@ -3,227 +3,196 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 55; objects = { /* Begin PBXBuildFile section */ - 5904C33A2199C9FA0046A573 /* SortController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5904C3392199C9FA0046A573 /* SortController.swift */; }; - 5904C33C2199D0260046A573 /* AlwaysShowPopup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5904C33B2199D0260046A573 /* AlwaysShowPopup.swift */; }; - 59158B1621E37B0200D90CB0 /* GridViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59158B1521E37B0200D90CB0 /* GridViewController.swift */; }; - 59158B1821E4C9AC00D90CB0 /* NavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59158B1721E4C9AC00D90CB0 /* NavigationController.swift */; }; - 591832CE21A2A97E00E5987D /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591832CD21A2A97E00E5987D /* Cap.swift */; }; - 591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */ = {isa = PBXBuildFile; fileRef = 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */; }; - 591FDD20234E162000AA379E /* SearchAndDisplayAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */; }; - 88A89ECE25AF420F00323B64 /* DispatchGroup+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */; }; - CE0A501124752A9800A9E753 /* TileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A501024752A9800A9E753 /* TileImage.swift */; }; - CE0A5013247D745200A9E753 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A5012247D745200A9E753 /* Colors.swift */; }; - CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CECD209D81DE00932C01 /* AppDelegate.swift */; }; - CE56CED3209D81DE00932C01 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED1209D81DE00932C01 /* Main.storyboard */; }; - CE56CED5209D81E000932C01 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED4209D81E000932C01 /* Assets.xcassets */; }; - CE56CED8209D81E000932C01 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED6209D81E000932C01 /* LaunchScreen.storyboard */; }; - CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE0209D83B200932C01 /* CapCell.swift */; }; - CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE6209D83B300932C01 /* RoundedButton.swift */; }; - CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE7209D83B300932C01 /* CameraController.swift */; }; - CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEEA209D83B400932C01 /* RoundedImageView.swift */; }; - CE56CF03209D83B800932C01 /* TableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEEB209D83B400932C01 /* TableView.swift */; }; - CE56CF04209D83B800932C01 /* UIViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEEC209D83B400932C01 /* UIViewExtensions.swift */; }; - CE56CF05209D83B800932C01 /* ViewControllerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEED209D83B400932C01 /* ViewControllerExtensions.swift */; }; - CE56CF06209D83B800932C01 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEEE209D83B500932C01 /* CameraView.swift */; }; - CE56CF07209D83B800932C01 /* ImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEEF209D83B500932C01 /* ImageCell.swift */; }; - CE56CF08209D83B800932C01 /* Storage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF0209D83B500932C01 /* Storage.swift */; }; - CE56CF09209D83B800932C01 /* Classifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF1209D83B500932C01 /* Classifier.swift */; }; - CE56CF0A209D83B800932C01 /* CropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF2209D83B600932C01 /* CropView.swift */; }; - CE56CF0B209D83B800932C01 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF3209D83B600932C01 /* Logger.swift */; }; - CE56CF0D209D83B800932C01 /* ImageSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF5209D83B600932C01 /* ImageSelector.swift */; }; - CE56CF0E209D83B800932C01 /* PhotoCaptureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF6209D83B700932C01 /* PhotoCaptureHandler.swift */; }; - CE56CF0F209D83B800932C01 /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */; }; - CE5B7CFC24562673002E5C06 /* Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFB24562673002E5C06 /* Download.swift */; }; - CE5B7CFE245626D3002E5C06 /* Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFD245626D3002E5C06 /* Upload.swift */; }; - CE5B7D032458C921002E5C06 /* Reachability in Frameworks */ = {isa = PBXBuildFile; productRef = CE5B7D022458C921002E5C06 /* Reachability */; }; - CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */; }; - CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */; }; - CE85AA18246B012B002D1074 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85AA17246B012B002D1074 /* Array+Extensions.swift */; }; - CEB269572445DB56004B74B3 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = CEB269562445DB56004B74B3 /* SQLite */; }; - CEB269592445DB72004B74B3 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB269582445DB72004B74B3 /* Database.swift */; }; - CEB2695B2445E54E004B74B3 /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB2695A2445E54E004B74B3 /* UIColor+Extensions.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 */; }; + E25AAC83283D855F006E9E7F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E25AAC82283D855F006E9E7F /* Preview Assets.xcassets */; }; + E25AAC8B283D868D006E9E7F /* Classifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC8A283D868D006E9E7F /* Classifier.swift */; }; + E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC8C283D86CF006E9E7F /* Logger.swift */; }; + E25AAC90283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC8F283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift */; }; + E25AAC92283D8808006E9E7F /* CapData.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC91283D8808006E9E7F /* CapData.swift */; }; + 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 */; }; + E2EA00CA283EACB200F7B269 /* BottomSheet in Frameworks */ = {isa = PBXBuildFile; productRef = E2EA00C9283EACB200F7B269 /* BottomSheet */; }; + E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */; }; + E2EA00CE283EBEB600F7B269 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00CD283EBEB600F7B269 /* SearchField.swift */; }; + E2EA00D1283EDD6300F7B269 /* CameraManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00D0283EDD6300F7B269 /* CameraManager.swift */; }; + E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00D2283EDDF700F7B269 /* CameraError.swift */; }; + E2EA00D5283EDFA200F7B269 /* FrameManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00D4283EDFA200F7B269 /* FrameManager.swift */; }; + E2EA00D9283F5BB900F7B269 /* CameraView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00D8283F5BB900F7B269 /* CameraView.swift */; }; + E2EA00DB283F5C0600F7B269 /* ContentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */; }; + E2EA00DD283F5C6A00F7B269 /* FrameView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00DC283F5C6A00F7B269 /* FrameView.swift */; }; + E2EA00DF283F5CA000F7B269 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00DE283F5CA000F7B269 /* ErrorView.swift */; }; + E2EA00E1283F658E00F7B269 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00E0283F658E00F7B269 /* SettingsView.swift */; }; + E2EA00E3283F662800F7B269 /* GridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00E2283F662800F7B269 /* GridView.swift */; }; + E2EA00E5283F69DF00F7B269 /* SettingsStatisticRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */; }; + E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */; }; + E2EA00EB284109CC00F7B269 /* CGImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */; }; + E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00EC2841170100F7B269 /* UIImage+Extensions.swift */; }; + E2EA00EF28420AA000F7B269 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */; }; + E2EA00F328438E6B00F7B269 /* CapNameEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 5904C3392199C9FA0046A573 /* SortController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortController.swift; sourceTree = ""; }; - 5904C33B2199D0260046A573 /* AlwaysShowPopup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlwaysShowPopup.swift; sourceTree = ""; }; - 59158B1521E37B0200D90CB0 /* GridViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridViewController.swift; sourceTree = ""; }; - 59158B1721E4C9AC00D90CB0 /* NavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = ""; }; - 591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = ""; }; - 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = ""; }; - 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = ""; }; - 88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchGroup+Extensions.swift"; sourceTree = ""; }; - CE0A501024752A9800A9E753 /* TileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileImage.swift; sourceTree = ""; }; - CE0A5012247D745200A9E753 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; - CE56CECA209D81DD00932C01 /* Caps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Caps.app; sourceTree = BUILT_PRODUCTS_DIR; }; - CE56CECD209D81DE00932C01 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - CE56CED2209D81DE00932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - CE56CED4209D81E000932C01 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - CE56CED7209D81E000932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - CE56CED9209D81E000932C01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - CE56CEE0209D83B200932C01 /* CapCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CapCell.swift; sourceTree = ""; }; - CE56CEE6209D83B300932C01 /* RoundedButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedButton.swift; sourceTree = ""; }; - CE56CEE7209D83B300932C01 /* CameraController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraController.swift; sourceTree = ""; }; - CE56CEE8209D83B300932C01 /* UIAlertControllerExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIAlertControllerExtensions.swift; sourceTree = ""; }; - CE56CEEA209D83B400932C01 /* RoundedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedImageView.swift; sourceTree = ""; }; - CE56CEEB209D83B400932C01 /* TableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TableView.swift; path = ../TableView.swift; sourceTree = ""; }; - CE56CEEC209D83B400932C01 /* UIViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = ""; }; - CE56CEED209D83B400932C01 /* ViewControllerExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewControllerExtensions.swift; sourceTree = ""; }; - CE56CEEE209D83B500932C01 /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; - CE56CEEF209D83B500932C01 /* ImageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = ""; }; - CE56CEF0209D83B500932C01 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; - CE56CEF1209D83B500932C01 /* Classifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Classifier.swift; sourceTree = ""; }; - CE56CEF2209D83B600932C01 /* CropView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CropView.swift; sourceTree = ""; }; - CE56CEF3209D83B600932C01 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; - CE56CEF5209D83B600932C01 /* ImageSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSelector.swift; sourceTree = ""; }; - CE56CEF6209D83B700932C01 /* PhotoCaptureHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureHandler.swift; sourceTree = ""; }; - CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = ""; }; - CE5B7CFB24562673002E5C06 /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = ""; }; - CE5B7CFD245626D3002E5C06 /* Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Upload.swift; sourceTree = ""; }; - CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = ""; }; - CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extensions.swift"; sourceTree = ""; }; - CE85AA17246B012B002D1074 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = ""; }; - CEB269582445DB72004B74B3 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; - CEB2695A2445E54E004B74B3 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.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 = ""; }; + E25AAC7F283D855F006E9E7F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E25AAC82283D855F006E9E7F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + E25AAC8A283D868D006E9E7F /* Classifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Classifier.swift; sourceTree = ""; }; + E25AAC8C283D86CF006E9E7F /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; + E25AAC8F283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = ""; }; + E25AAC91283D8808006E9E7F /* CapData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapData.swift; sourceTree = ""; }; + E25AAC93283D88A4006E9E7F /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = ""; }; + E25AAC95283E14DF006E9E7F /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; + E25AAC9A283E3395006E9E7F /* CapRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapRowView.swift; sourceTree = ""; }; + E2EA00C4283EA72000F7B269 /* SortCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCriteria.swift; sourceTree = ""; }; + E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortSelectionView.swift; sourceTree = ""; }; + E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCaseRowView.swift; sourceTree = ""; }; + E2EA00CD283EBEB600F7B269 /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = ""; }; + E2EA00D0283EDD6300F7B269 /* CameraManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraManager.swift; sourceTree = ""; }; + E2EA00D2283EDDF700F7B269 /* CameraError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraError.swift; sourceTree = ""; }; + E2EA00D4283EDFA200F7B269 /* FrameManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameManager.swift; sourceTree = ""; }; + E2EA00D8283F5BB900F7B269 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; + E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = ""; }; + E2EA00DC283F5C6A00F7B269 /* FrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameView.swift; sourceTree = ""; }; + E2EA00DE283F5CA000F7B269 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; + E2EA00E0283F658E00F7B269 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + E2EA00E2283F662800F7B269 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = ""; }; + E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStatisticRow.swift; sourceTree = ""; }; + E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; + E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+Extensions.swift"; sourceTree = ""; }; + E2EA00EC2841170100F7B269 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = ""; }; + E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; + E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapNameEntryView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - CE56CEC7209D81DD00932C01 /* Frameworks */ = { + E25AAC75283D855D006E9E7F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CEB269572445DB56004B74B3 /* SQLite in Frameworks */, - CE5B7D032458C921002E5C06 /* Reachability in Frameworks */, + E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */, + E2EA00CA283EACB200F7B269 /* BottomSheet in Frameworks */, + E27E15E1283E418600F6804A /* CachedAsyncImage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - CE56CEC1209D81DD00932C01 = { + E25AAC6F283D855D006E9E7F = { isa = PBXGroup; children = ( - CE56CECC209D81DD00932C01 /* Caps */, - CE56CECB209D81DD00932C01 /* Products */, + E25AAC7A283D855D006E9E7F /* Caps */, + E25AAC79283D855D006E9E7F /* Products */, ); sourceTree = ""; }; - CE56CECB209D81DD00932C01 /* Products */ = { + E25AAC79283D855D006E9E7F /* Products */ = { isa = PBXGroup; children = ( - CE56CECA209D81DD00932C01 /* Caps.app */, + E25AAC78283D855D006E9E7F /* Caps.app */, ); name = Products; sourceTree = ""; }; - CE56CECC209D81DD00932C01 /* Caps */ = { + E25AAC7A283D855D006E9E7F /* Caps */ = { isa = PBXGroup; children = ( - CE56CECD209D81DE00932C01 /* AppDelegate.swift */, - CE56CED1209D81DE00932C01 /* Main.storyboard */, - CEF3874D209D9378001C8D3C /* Capture */, - CEF38750209D93D1001C8D3C /* Data */, - CEF3874B209D932E001C8D3C /* View Components */, - CEF3874F209D93A6001C8D3C /* Presentation */, - CEF3874C209D935E001C8D3C /* Extensions */, - CE56CEF3209D83B600932C01 /* Logger.swift */, - CE56CEDF209D81FD00932C01 /* Support */, + E25AAC7B283D855D006E9E7F /* CapsApp.swift */, + E25AAC7D283D855D006E9E7F /* ContentView.swift */, + E2EA00CF283EDD2C00F7B269 /* Camera */, + E25AAC97283E337C006E9E7F /* Views */, + E25AAC89283D8666006E9E7F /* Data */, + E25AAC7F283D855F006E9E7F /* Assets.xcassets */, + E25AAC8E283D870F006E9E7F /* Extensions */, + E25AAC8C283D86CF006E9E7F /* Logger.swift */, + E25AAC81283D855F006E9E7F /* Preview Content */, ); path = Caps; sourceTree = ""; }; - CE56CEDF209D81FD00932C01 /* Support */ = { + E25AAC81283D855F006E9E7F /* Preview Content */ = { isa = PBXGroup; children = ( - CE56CED4209D81E000932C01 /* Assets.xcassets */, - CE56CED6209D81E000932C01 /* LaunchScreen.storyboard */, - CE56CED9209D81E000932C01 /* Info.plist */, + E25AAC82283D855F006E9E7F /* Preview Assets.xcassets */, ); - name = Support; + path = "Preview Content"; sourceTree = ""; }; - CEF3874B209D932E001C8D3C /* View Components */ = { + E25AAC89283D8666006E9E7F /* Data */ = { isa = PBXGroup; children = ( - 5904C33B2199D0260046A573 /* AlwaysShowPopup.swift */, - CE56CEF2209D83B600932C01 /* CropView.swift */, - CE56CEEA209D83B400932C01 /* RoundedImageView.swift */, - CE56CEE6209D83B300932C01 /* RoundedButton.swift */, + E25AAC8A283D868D006E9E7F /* Classifier.swift */, + E2EA00C4283EA72000F7B269 /* SortCriteria.swift */, + E25AAC93283D88A4006E9E7F /* Cap.swift */, + E25AAC91283D8808006E9E7F /* CapData.swift */, + E25AAC95283E14DF006E9E7F /* Database.swift */, ); - path = "View Components"; + path = Data; sourceTree = ""; }; - CEF3874C209D935E001C8D3C /* Extensions */ = { + E25AAC8E283D870F006E9E7F /* Extensions */ = { isa = PBXGroup; children = ( - CE56CEE8209D83B300932C01 /* UIAlertControllerExtensions.swift */, - CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */, - CE85AA17246B012B002D1074 /* Array+Extensions.swift */, - CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */, - CEB2695A2445E54E004B74B3 /* UIColor+Extensions.swift */, - CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */, - CE56CEEC209D83B400932C01 /* UIViewExtensions.swift */, - CE56CEED209D83B400932C01 /* ViewControllerExtensions.swift */, - 88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */, + E25AAC8F283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift */, + E2EA00EC2841170100F7B269 /* UIImage+Extensions.swift */, + E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */, + E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */, + E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */, ); path = Extensions; sourceTree = ""; }; - CEF3874D209D9378001C8D3C /* Capture */ = { + E25AAC97283E337C006E9E7F /* Views */ = { isa = PBXGroup; children = ( - CE56CEE7209D83B300932C01 /* CameraController.swift */, - CE56CEEE209D83B500932C01 /* CameraView.swift */, - CE56CEF6209D83B700932C01 /* PhotoCaptureHandler.swift */, + E25AAC9A283E3395006E9E7F /* CapRowView.swift */, + E2EA00E2283F662800F7B269 /* GridView.swift */, + E2EA00E0283F658E00F7B269 /* SettingsView.swift */, + E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */, + E2EA00CD283EBEB600F7B269 /* SearchField.swift */, + E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */, + E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */, + E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */, ); - path = Capture; + path = Views; sourceTree = ""; }; - CEF3874F209D93A6001C8D3C /* Presentation */ = { + E2EA00CF283EDD2C00F7B269 /* Camera */ = { isa = PBXGroup; children = ( - CE56CEEB209D83B400932C01 /* TableView.swift */, - 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */, - 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */, - 59158B1721E4C9AC00D90CB0 /* NavigationController.swift */, - CE56CEE0209D83B200932C01 /* CapCell.swift */, - CE56CEF5209D83B600932C01 /* ImageSelector.swift */, - CE56CEEF209D83B500932C01 /* ImageCell.swift */, - 5904C3392199C9FA0046A573 /* SortController.swift */, - 59158B1521E37B0200D90CB0 /* GridViewController.swift */, + E2EA00D0283EDD6300F7B269 /* CameraManager.swift */, + E2EA00D8283F5BB900F7B269 /* CameraView.swift */, + E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */, + E2EA00DC283F5C6A00F7B269 /* FrameView.swift */, + E2EA00D4283EDFA200F7B269 /* FrameManager.swift */, + E2EA00D2283EDDF700F7B269 /* CameraError.swift */, + E2EA00DE283F5CA000F7B269 /* ErrorView.swift */, ); - path = Presentation; - sourceTree = ""; - }; - CEF38750209D93D1001C8D3C /* Data */ = { - isa = PBXGroup; - children = ( - CE56CEF1209D83B500932C01 /* Classifier.swift */, - 591832CD21A2A97E00E5987D /* Cap.swift */, - CE56CEF0209D83B500932C01 /* Storage.swift */, - CE5B7CFB24562673002E5C06 /* Download.swift */, - CE5B7CFD245626D3002E5C06 /* Upload.swift */, - CEB269582445DB72004B74B3 /* Database.swift */, - CE0A5012247D745200A9E753 /* Colors.swift */, - CE0A501024752A9800A9E753 /* TileImage.swift */, - ); - path = Data; + path = Camera; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - CE56CEC9209D81DD00932C01 /* Caps */ = { + E25AAC77283D855D006E9E7F /* Caps */ = { isa = PBXNativeTarget; - buildConfigurationList = CE56CEDC209D81E000932C01 /* Build configuration list for PBXNativeTarget "Caps" */; + buildConfigurationList = E25AAC86283D855F006E9E7F /* Build configuration list for PBXNativeTarget "Caps" */; buildPhases = ( - CE56CEC6209D81DD00932C01 /* Sources */, - CE56CEC7209D81DD00932C01 /* Frameworks */, - CE56CEC8209D81DD00932C01 /* Resources */, + E25AAC74283D855D006E9E7F /* Sources */, + E25AAC75283D855D006E9E7F /* Frameworks */, + E25AAC76283D855D006E9E7F /* Resources */, ); buildRules = ( ); @@ -231,141 +200,110 @@ ); name = Caps; packageProductDependencies = ( - CEB269562445DB56004B74B3 /* SQLite */, - CE5B7D022458C921002E5C06 /* Reachability */, + E27E15E0283E418600F6804A /* CachedAsyncImage */, + E2EA00C2283E672A00F7B269 /* SFSafeSymbols */, + E2EA00C9283EACB200F7B269 /* BottomSheet */, ); - productName = CapCollector; - productReference = CE56CECA209D81DD00932C01 /* Caps.app */; + productName = Caps; + productReference = E25AAC78283D855D006E9E7F /* Caps.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - CE56CEC2209D81DD00932C01 /* Project object */ = { + E25AAC70283D855D006E9E7F /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0940; - LastUpgradeCheck = 1200; - ORGANIZATIONNAME = CH; + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1340; + LastUpgradeCheck = 1340; TargetAttributes = { - CE56CEC9209D81DD00932C01 = { - CreatedOnToolsVersion = 9.4; - LastSwiftMigration = 1100; - SystemCapabilities = { - com.apple.BackgroundModes = { - enabled = 0; - }; - }; + E25AAC77283D855D006E9E7F = { + CreatedOnToolsVersion = 13.4; }; }; }; - buildConfigurationList = CE56CEC5209D81DD00932C01 /* Build configuration list for PBXProject "Caps" */; - compatibilityVersion = "Xcode 9.3"; + buildConfigurationList = E25AAC73283D855D006E9E7F /* Build configuration list for PBXProject "Caps" */; + compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); - mainGroup = CE56CEC1209D81DD00932C01; + mainGroup = E25AAC6F283D855D006E9E7F; packageReferences = ( - CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */, - CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */, + E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */, + E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */, + E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */, ); - productRefGroup = CE56CECB209D81DD00932C01 /* Products */; + productRefGroup = E25AAC79283D855D006E9E7F /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - CE56CEC9209D81DD00932C01 /* Caps */, + E25AAC77283D855D006E9E7F /* Caps */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - CE56CEC8209D81DD00932C01 /* Resources */ = { + E25AAC76283D855D006E9E7F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - CE56CED8209D81E000932C01 /* LaunchScreen.storyboard in Resources */, - 591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */, - CE56CED5209D81E000932C01 /* Assets.xcassets in Resources */, - CE56CED3209D81DE00932C01 /* Main.storyboard in Resources */, + E25AAC83283D855F006E9E7F /* Preview Assets.xcassets in Resources */, + E25AAC80283D855F006E9E7F /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - CE56CEC6209D81DD00932C01 /* Sources */ = { + E25AAC74283D855D006E9E7F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - CE56CF09209D83B800932C01 /* Classifier.swift in Sources */, - CE0A5013247D745200A9E753 /* Colors.swift in Sources */, - 5904C33A2199C9FA0046A573 /* SortController.swift in Sources */, - CE56CF0B209D83B800932C01 /* Logger.swift in Sources */, - CE56CF04209D83B800932C01 /* UIViewExtensions.swift in Sources */, - 59158B1821E4C9AC00D90CB0 /* NavigationController.swift in Sources */, - 591832CE21A2A97E00E5987D /* Cap.swift in Sources */, - CE56CF08209D83B800932C01 /* Storage.swift in Sources */, - CE56CF0F209D83B800932C01 /* UIImage+Extensions.swift in Sources */, - CE5B7CFC24562673002E5C06 /* Download.swift in Sources */, - CE85AA18246B012B002D1074 /* Array+Extensions.swift in Sources */, - CE56CF03209D83B800932C01 /* TableView.swift in Sources */, - CEB2695B2445E54E004B74B3 /* UIColor+Extensions.swift in Sources */, - 591FDD20234E162000AA379E /* SearchAndDisplayAccessory.swift in Sources */, - 59158B1621E37B0200D90CB0 /* GridViewController.swift in Sources */, - CE5B7CFE245626D3002E5C06 /* Upload.swift in Sources */, - CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */, - CE56CF0D209D83B800932C01 /* ImageSelector.swift in Sources */, - CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */, - CE56CF05209D83B800932C01 /* ViewControllerExtensions.swift in Sources */, - CE56CF0E209D83B800932C01 /* PhotoCaptureHandler.swift in Sources */, - CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */, - CE56CF07209D83B800932C01 /* ImageCell.swift in Sources */, - CE56CF06209D83B800932C01 /* CameraView.swift in Sources */, - CE56CF0A209D83B800932C01 /* CropView.swift in Sources */, - 5904C33C2199D0260046A573 /* AlwaysShowPopup.swift in Sources */, - CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */, - CEB269592445DB72004B74B3 /* Database.swift in Sources */, - CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */, - 88A89ECE25AF420F00323B64 /* DispatchGroup+Extensions.swift in Sources */, - CE0A501124752A9800A9E753 /* TileImage.swift in Sources */, - CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */, - CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */, + E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */, + E2EA00F328438E6B00F7B269 /* CapNameEntryView.swift in Sources */, + E25AAC8B283D868D006E9E7F /* Classifier.swift in Sources */, + E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */, + E2EA00D9283F5BB900F7B269 /* CameraView.swift in Sources */, + E2EA00E3283F662800F7B269 /* GridView.swift in Sources */, + E2EA00EB284109CC00F7B269 /* CGImage+Extensions.swift in Sources */, + E2EA00DF283F5CA000F7B269 /* ErrorView.swift in Sources */, + E2EA00D5283EDFA200F7B269 /* FrameManager.swift in Sources */, + E25AAC90283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift in Sources */, + E2EA00CE283EBEB600F7B269 /* SearchField.swift in Sources */, + E2EA00C7283EAA0100F7B269 /* SortSelectionView.swift in Sources */, + E2EA00DD283F5C6A00F7B269 /* FrameView.swift in Sources */, + E2EA00EF28420AA000F7B269 /* Data+Extensions.swift in Sources */, + E2EA00C5283EA72000F7B269 /* SortCriteria.swift in Sources */, + E25AAC7C283D855D006E9E7F /* CapsApp.swift in Sources */, + E2EA00D1283EDD6300F7B269 /* CameraManager.swift in Sources */, + E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */, + E2EA00DB283F5C0600F7B269 /* ContentViewModel.swift in Sources */, + E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */, + E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */, + E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */, + E25AAC92283D8808006E9E7F /* CapData.swift in Sources */, + E25AAC96283E14DF006E9E7F /* Database.swift in Sources */, + E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */, + E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */, + E2EA00E5283F69DF00F7B269 /* SettingsStatisticRow.swift in Sources */, + E2EA00E1283F658E00F7B269 /* SettingsView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXVariantGroup section */ - CE56CED1209D81DE00932C01 /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - CE56CED2209D81DE00932C01 /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - CE56CED6209D81E000932C01 /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - CE56CED7209D81E000932C01 /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ - CE56CEDA209D81E000932C01 /* Debug */ = { + E25AAC84283D855F006E9E7F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -391,7 +329,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -410,8 +347,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - MTL_ENABLE_DEBUG_INFO = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; @@ -419,14 +357,13 @@ }; name = Debug; }; - CE56CEDB209D81E000932C01 /* Release */ = { + E25AAC85283D855F006E9E7F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -452,7 +389,6 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; @@ -465,8 +401,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -474,47 +411,63 @@ }; name = Release; }; - CE56CEDD209D81E000932C01 /* Debug */ = { + E25AAC87283D855F006E9E7F /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Caps/Preview Content\""; DEVELOPMENT_TEAM = H8WR4M6QQ4; - INFOPLIST_FILE = "$(SRCROOT)/Caps/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.Caps; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; - CE56CEDE209D81E000932C01 /* Release */ = { + E25AAC88283D855F006E9E7F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_IDENTITY = "iPhone Developer"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Caps/Preview Content\""; DEVELOPMENT_TEAM = H8WR4M6QQ4; - INFOPLIST_FILE = "$(SRCROOT)/Caps/Info.plist"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = de.christophhagen.Caps; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; @@ -523,20 +476,20 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - CE56CEC5209D81DD00932C01 /* Build configuration list for PBXProject "Caps" */ = { + E25AAC73283D855D006E9E7F /* Build configuration list for PBXProject "Caps" */ = { isa = XCConfigurationList; buildConfigurations = ( - CE56CEDA209D81E000932C01 /* Debug */, - CE56CEDB209D81E000932C01 /* Release */, + E25AAC84283D855F006E9E7F /* Debug */, + E25AAC85283D855F006E9E7F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - CE56CEDC209D81E000932C01 /* Build configuration list for PBXNativeTarget "Caps" */ = { + E25AAC86283D855F006E9E7F /* Build configuration list for PBXNativeTarget "Caps" */ = { isa = XCConfigurationList; buildConfigurations = ( - CE56CEDD209D81E000932C01 /* Debug */, - CE56CEDE209D81E000932C01 /* Release */, + E25AAC87283D855F006E9E7F /* Debug */, + E25AAC88283D855F006E9E7F /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -544,36 +497,49 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */ = { + E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ashleymills/Reachability.swift"; + repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 5.0.0; + minimumVersion = 2.0.0; }; }; - CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */ = { + E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/stephencelis/SQLite.swift"; + repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.12.2; + minimumVersion = 3.0.0; + }; + }; + E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weitieda/bottom-sheet"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - CE5B7D022458C921002E5C06 /* Reachability */ = { + E27E15E0283E418600F6804A /* CachedAsyncImage */ = { isa = XCSwiftPackageProductDependency; - package = CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */; - productName = Reachability; + package = E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */; + productName = CachedAsyncImage; }; - CEB269562445DB56004B74B3 /* SQLite */ = { + E2EA00C2283E672A00F7B269 /* SFSafeSymbols */ = { isa = XCSwiftPackageProductDependency; - package = CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */; - productName = SQLite; + package = E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */; + productName = SFSafeSymbols; + }; + E2EA00C9283EACB200F7B269 /* BottomSheet */ = { + isa = XCSwiftPackageProductDependency; + package = E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */; + productName = BottomSheet; }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = CE56CEC2209D81DD00932C01 /* Project object */; + rootObject = E25AAC70283D855D006E9E7F /* Project object */; } diff --git a/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 96e9cea..bcf05e2 100644 --- a/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Caps.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,25 +1,32 @@ { - "object": { - "pins": [ - { - "package": "Reachability", - "repositoryURL": "https://github.com/ashleymills/Reachability.swift", - "state": { - "branch": null, - "revision": "c01bbdf2d633cf049ae1ed1a68a2020a8bda32e2", - "version": "5.1.0" - } - }, - { - "package": "SQLite.swift", - "repositoryURL": "https://github.com/stephencelis/SQLite.swift", - "state": { - "branch": null, - "revision": "5f5ad81ac0d0a0f3e56e39e646e8423c617df523", - "version": "0.13.2" - } + "pins" : [ + { + "identity" : "bottom-sheet", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weitieda/bottom-sheet", + "state" : { + "revision" : "4e074d49f3148577ac66cf47b85a99d016480d01", + "version" : "1.0.10" } - ] - }, - "version": 1 + }, + { + "identity" : "sfsafesymbols", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SFSafeSymbols/SFSafeSymbols", + "state" : { + "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 858f0c4..ee68d55 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.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate b/Caps.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index bed6008..0000000 Binary files a/Caps.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate and /dev/null differ diff --git a/Caps.xcodeproj/xcshareddata/xcschemes/Caps.xcscheme b/Caps.xcodeproj/xcshareddata/xcschemes/Caps.xcscheme new file mode 100644 index 0000000..794df71 --- /dev/null +++ b/Caps.xcodeproj/xcshareddata/xcschemes/Caps.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Caps.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist b/Caps.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 6ac819e..0000000 --- a/Caps.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,19 +0,0 @@ - - - - - SchemeUserState - - CapCollector.xcscheme - - orderHint - 0 - - CapCollector.xcscheme_^#shared#^_ - - orderHint - 0 - - - - diff --git a/Caps.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist similarity index 65% rename from Caps.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist rename to Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index d502ec3..f129b62 100644 --- a/Caps.xcodeproj/xcuserdata/imac.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -1,6 +1,6 @@ diff --git a/Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist b/Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist index 4dd0d17..be3d187 100644 --- a/Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Caps.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,52 +4,18 @@ SchemeUserState - CapCollector.xcscheme_^#shared#^_ + Caps.xcscheme_^#shared#^_ - orderHint - 1 - - SQLite (Playground) 1.xcscheme - - isShown - - orderHint - 3 - - SQLite (Playground) 2.xcscheme - - isShown - - orderHint - 4 - - SQLite (Playground) 3.xcscheme - - isShown - orderHint 0 - SQLite (Playground) 4.xcscheme + + SuppressBuildableAutocreation + + E25AAC77283D855D006E9E7F - isShown - - orderHint - 5 - - SQLite (Playground) 5.xcscheme - - isShown - - orderHint - 6 - - SQLite (Playground).xcscheme - - isShown - - orderHint - 2 + primary + diff --git a/Caps.xcodeproj/xcuserdata/christoph.xcuserdatad/xcschemes/xcschememanagement.plist b/Caps.xcodeproj/xcuserdata/christoph.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 6938457..0000000 --- a/Caps.xcodeproj/xcuserdata/christoph.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,19 +0,0 @@ - - - - - SchemeUserState - - CapCollector.xcscheme - - orderHint - 3 - - CapCollector.xcscheme_^#shared#^_ - - orderHint - 3 - - - - diff --git a/Caps.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist b/Caps.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist deleted file mode 100644 index 30f9840..0000000 --- a/Caps.xcodeproj/xcuserdata/imac.xcuserdatad/xcschemes/xcschememanagement.plist +++ /dev/null @@ -1,35 +0,0 @@ - - - - - SchemeUserState - - CapCollector.xcscheme_^#shared#^_ - - orderHint - 0 - - SQLite (Playground) 1.xcscheme - - isShown - - orderHint - 2 - - SQLite (Playground) 2.xcscheme - - isShown - - orderHint - 3 - - SQLite (Playground).xcscheme - - isShown - - orderHint - 1 - - - - diff --git a/Caps/AppDelegate.swift b/Caps/AppDelegate.swift deleted file mode 100644 index c21eea4..0000000 --- a/Caps/AppDelegate.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// AppDelegate.swift -// CapFinder -// -// Created by User on 31.01.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit -import CoreData - -import Reachability - -#warning("ImageSelector: Allow deletion and moving of an image of a cap") -#warning("ImageSelector: Show icons for failed downloads") -#warning("GridController: Allow sorting of caps by color") -#warning("GridController: Reorder caps by dragging") -#warning("TableView: Fix blur background of search bar after transition") -#warning("TableView: Add banner to jump down to unmatched caps / bottom") -#warning("Only show total size and percentage for classifier download") -#warning("After classifier download, test is not started") -#warning("Add result info when update button is pressed") -var shouldLaunchCamera = false - -var app: AppDelegate! - -private let unlockCode = 3849 - -@UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { - - // MARK: Static Properties - - /// Main tint color of the app - static let tintColor = UIColor(red: 122/255, green: 155/255, blue: 41/255, alpha: 1) - - var window: UIWindow? - - var mainStoryboard: UIStoryboard { .init(name: "Main", bundle: nil) } - - let documentsFolder = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) - - var database: Database! - - var reachability: Reachability! - - var dbUrl: URL { - documentsFolder.appendingPathComponent("db.sqlite3") - } - - /// Indicate if the user has write permissions. - private(set) var isUnlocked: Bool { - get { - UserDefaults.standard.bool(forKey: "unlocked") - } - set { - UserDefaults.standard.set(newValue, forKey: "unlocked") - } - } - - let serverUrl = URL(string: "https://christophhagen.de:6000")! - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - app = self - - reachability = try! Reachability() - - //resetToFactoryState() - - database = Database(url: dbUrl, server: serverUrl, storageFolder: documentsFolder) - return true - } - - private func resetToFactoryState() { - for path in try! FileManager.default.contentsOfDirectory(at: documentsFolder, includingPropertiesForKeys: nil) { - try! FileManager.default.removeItem(at: path) - } - UserDefaults.standard.removeObject(forKey: Classifier.userDefaultsKey) - } - - func lock() { - isUnlocked = false - } - - func checkUnlock(with pin: Int) -> Bool { - isUnlocked = pin == unlockCode - return isUnlocked - } - - private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { - log("Shortcut pressed") - shouldLaunchCamera = true - return true - } - - func applicationDidBecomeActive(_ application: UIApplication) { - guard shouldLaunchCamera else { return } - shouldLaunchCamera = false - if let c = (frontmostViewController as? UINavigationController)?.topViewController as? TableView { - c.showCameraView() - } - } - - /* - Called when the user activates your application by selecting a shortcut on the home screen, except when - application(_:,willFinishLaunchingWithOptions:) or application(_:didFinishLaunchingWithOptions) returns `false`. - You should handle the shortcut in those callbacks and return `false` if possible. In that case, this - callback is used if your application is already launched in the background. - */ - func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { - let handledShortCutItem = handleShortCutItem(shortcutItem) - completionHandler(handledShortCutItem) - } - - var frontmostViewController: UIViewController? { - var controller = window?.rootViewController - while let presentedViewController = controller?.presentedViewController { - controller = presentedViewController - } - return controller - } - - -} - -extension AppDelegate: Logger { } diff --git a/Caps/Assets.xcassets/AccentColor.colorset/Contents.json b/Caps/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Caps/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Caps/Assets.xcassets/AppIcon.appiconset/Contents.json b/Caps/Assets.xcassets/AppIcon.appiconset/Contents.json index 02dd3cb..b4a35da 100644 --- a/Caps/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Caps/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,107 +1,107 @@ { "images" : [ { - "size" : "20x20", - "idiom" : "iphone", "filename" : "bottle-cap40.png", - "scale" : "2x" + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" }, { - "size" : "20x20", - "idiom" : "iphone", "filename" : "bottle-cap60.png", - "scale" : "3x" + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" }, { - "size" : "29x29", - "idiom" : "iphone", "filename" : "bottle-cap58.png", - "scale" : "2x" + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" }, { - "size" : "29x29", - "idiom" : "iphone", "filename" : "bottle-cap87.png", - "scale" : "3x" + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" }, { - "size" : "40x40", - "idiom" : "iphone", "filename" : "bottle-cap80.png", - "scale" : "2x" + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" }, { - "size" : "40x40", - "idiom" : "iphone", "filename" : "bottle-cap120.png", - "scale" : "3x" + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" }, { - "size" : "60x60", - "idiom" : "iphone", "filename" : "bottle-cap120-1.png", - "scale" : "2x" - }, - { - "size" : "60x60", "idiom" : "iphone", - "filename" : "bottle-cap180.png", - "scale" : "3x" + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "bottle-cap180-1.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" }, { "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x" + "scale" : "1x", + "size" : "20x20" }, { "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x" + "scale" : "2x", + "size" : "20x20" }, { "idiom" : "ipad", - "size" : "29x29", - "scale" : "1x" + "scale" : "1x", + "size" : "29x29" }, { "idiom" : "ipad", - "size" : "29x29", - "scale" : "2x" + "scale" : "2x", + "size" : "29x29" }, { "idiom" : "ipad", - "size" : "40x40", - "scale" : "1x" + "scale" : "1x", + "size" : "40x40" }, { "idiom" : "ipad", - "size" : "40x40", - "scale" : "2x" + "scale" : "2x", + "size" : "40x40" }, { "idiom" : "ipad", - "size" : "76x76", - "scale" : "1x" + "scale" : "1x", + "size" : "76x76" }, { "idiom" : "ipad", - "size" : "76x76", - "scale" : "2x" + "scale" : "2x", + "size" : "76x76" }, { "idiom" : "ipad", - "size" : "83.5x83.5", - "scale" : "2x" + "scale" : "2x", + "size" : "83.5x83.5" }, { - "size" : "1024x1024", - "idiom" : "ios-marketing", "filename" : "bottle-cap1024.png", - "scale" : "1x" + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Caps/Assets.xcassets/AppIcon.appiconset/bottle-cap180.png b/Caps/Assets.xcassets/AppIcon.appiconset/bottle-cap180-1.png similarity index 100% rename from Caps/Assets.xcassets/AppIcon.appiconset/bottle-cap180.png rename to Caps/Assets.xcassets/AppIcon.appiconset/bottle-cap180-1.png diff --git a/Caps/Assets.xcassets/Contents.json b/Caps/Assets.xcassets/Contents.json index da4a164..73c0059 100644 --- a/Caps/Assets.xcassets/Contents.json +++ b/Caps/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Caps/Assets.xcassets/camera.imageset/Contents.json b/Caps/Assets.xcassets/camera.imageset/Contents.json deleted file mode 100644 index 1518837..0000000 --- a/Caps/Assets.xcassets/camera.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "camera.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Caps/Assets.xcassets/camera.imageset/camera.png b/Caps/Assets.xcassets/camera.imageset/camera.png deleted file mode 100644 index 6067088..0000000 Binary files a/Caps/Assets.xcassets/camera.imageset/camera.png and /dev/null differ diff --git a/Caps/Assets.xcassets/camera_square.imageset/Contents.json b/Caps/Assets.xcassets/camera_square.imageset/Contents.json deleted file mode 100644 index 5096c74..0000000 --- a/Caps/Assets.xcassets/camera_square.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "camera_square.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Caps/Assets.xcassets/camera_square.imageset/camera_square.png b/Caps/Assets.xcassets/camera_square.imageset/camera_square.png deleted file mode 100644 index 8c48ff3..0000000 Binary files a/Caps/Assets.xcassets/camera_square.imageset/camera_square.png and /dev/null differ diff --git a/Caps/Assets.xcassets/cancel.imageset/Contents.json b/Caps/Assets.xcassets/cancel.imageset/Contents.json deleted file mode 100644 index 450a4d1..0000000 --- a/Caps/Assets.xcassets/cancel.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "cancel.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Caps/Assets.xcassets/cancel.imageset/cancel.png b/Caps/Assets.xcassets/cancel.imageset/cancel.png deleted file mode 100644 index abc8ca7..0000000 Binary files a/Caps/Assets.xcassets/cancel.imageset/cancel.png and /dev/null differ diff --git a/Caps/Assets.xcassets/launch.imageset/Contents.json b/Caps/Assets.xcassets/launch.imageset/Contents.json deleted file mode 100644 index 5989f83..0000000 --- a/Caps/Assets.xcassets/launch.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "launch.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Caps/Assets.xcassets/launch.imageset/launch.png b/Caps/Assets.xcassets/launch.imageset/launch.png deleted file mode 100644 index 8802657..0000000 Binary files a/Caps/Assets.xcassets/launch.imageset/launch.png and /dev/null differ diff --git a/Caps/Assets.xcassets/mosaic.imageset/Contents.json b/Caps/Assets.xcassets/mosaic.imageset/Contents.json deleted file mode 100644 index 965d24d..0000000 --- a/Caps/Assets.xcassets/mosaic.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "picture28.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "picture56.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "picture84.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Caps/Assets.xcassets/mosaic.imageset/picture28.png b/Caps/Assets.xcassets/mosaic.imageset/picture28.png deleted file mode 100644 index 4ecaeb3..0000000 Binary files a/Caps/Assets.xcassets/mosaic.imageset/picture28.png and /dev/null differ diff --git a/Caps/Assets.xcassets/mosaic.imageset/picture56.png b/Caps/Assets.xcassets/mosaic.imageset/picture56.png deleted file mode 100644 index 7adcab3..0000000 Binary files a/Caps/Assets.xcassets/mosaic.imageset/picture56.png and /dev/null differ diff --git a/Caps/Assets.xcassets/mosaic.imageset/picture84.png b/Caps/Assets.xcassets/mosaic.imageset/picture84.png deleted file mode 100644 index a696425..0000000 Binary files a/Caps/Assets.xcassets/mosaic.imageset/picture84.png and /dev/null differ diff --git a/Caps/Assets.xcassets/search_icon.imageset/Contents.json b/Caps/Assets.xcassets/search_icon.imageset/Contents.json deleted file mode 100644 index 3333eca..0000000 --- a/Caps/Assets.xcassets/search_icon.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "search_icon.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Caps/Assets.xcassets/search_icon.imageset/search_icon.png b/Caps/Assets.xcassets/search_icon.imageset/search_icon.png deleted file mode 100644 index 6e6cbe6..0000000 Binary files a/Caps/Assets.xcassets/search_icon.imageset/search_icon.png and /dev/null differ diff --git a/Caps/Assets.xcassets/settings.imageset/Contents.json b/Caps/Assets.xcassets/settings.imageset/Contents.json deleted file mode 100644 index bb670f1..0000000 --- a/Caps/Assets.xcassets/settings.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "button_settings_white@1x.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "button_settings_white@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "button_settings_white@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Caps/Assets.xcassets/settings.imageset/button_settings_white@1x.png b/Caps/Assets.xcassets/settings.imageset/button_settings_white@1x.png deleted file mode 100644 index f8ca6af..0000000 Binary files a/Caps/Assets.xcassets/settings.imageset/button_settings_white@1x.png and /dev/null differ diff --git a/Caps/Assets.xcassets/settings.imageset/button_settings_white@2x.png b/Caps/Assets.xcassets/settings.imageset/button_settings_white@2x.png deleted file mode 100644 index 4b3fd71..0000000 Binary files a/Caps/Assets.xcassets/settings.imageset/button_settings_white@2x.png and /dev/null differ diff --git a/Caps/Assets.xcassets/settings.imageset/button_settings_white@3x.png b/Caps/Assets.xcassets/settings.imageset/button_settings_white@3x.png deleted file mode 100644 index 06394bd..0000000 Binary files a/Caps/Assets.xcassets/settings.imageset/button_settings_white@3x.png and /dev/null differ diff --git a/Caps/Base.lproj/LaunchScreen.storyboard b/Caps/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 75652b9..0000000 --- a/Caps/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Caps/Base.lproj/Main.storyboard b/Caps/Base.lproj/Main.storyboard deleted file mode 100644 index ef42e55..0000000 --- a/Caps/Base.lproj/Main.storyboard +++ /dev/null @@ -1,429 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Caps/Camera/CameraError.swift b/Caps/Camera/CameraError.swift new file mode 100644 index 0000000..ae4b2e2 --- /dev/null +++ b/Caps/Camera/CameraError.swift @@ -0,0 +1,32 @@ +import Foundation + +enum CameraError: Error { + case cameraUnavailable + case cannotAddInput + case cannotAddOutput + case createCaptureInput(Error) + case deniedAuthorization + case restrictedAuthorization + case unknownAuthorization +} + +extension CameraError: LocalizedError { + var errorDescription: String? { + switch self { + case .cameraUnavailable: + return "Camera unavailable" + case .cannotAddInput: + return "Cannot add capture input to session" + case .cannotAddOutput: + return "Cannot add video output to session" + case .createCaptureInput(let error): + return "Creating capture input for camera: \(error.localizedDescription)" + case .deniedAuthorization: + return "Camera access denied" + case .restrictedAuthorization: + return "Attempting to access a restricted capture device" + case .unknownAuthorization: + return "Unknown authorization status for capture device" + } + } +} diff --git a/Caps/Camera/CameraManager.swift b/Caps/Camera/CameraManager.swift new file mode 100644 index 0000000..5dc4bf8 --- /dev/null +++ b/Caps/Camera/CameraManager.swift @@ -0,0 +1,172 @@ +import Foundation +import AVFoundation + +class CameraManager: ObservableObject { + enum Status { + case unconfigured + case configured + case unauthorized + case failed + } + + static let shared = CameraManager() + + @Published var error: CameraError? + + let session = AVCaptureSession() + + private let sessionQueue = DispatchQueue(label: "de.christophhagen.cam") + private let videoOutput = AVCaptureVideoDataOutput() + private let photoOutput = AVCapturePhotoOutput() + private var status = Status.unconfigured + + private init() { + configure() + } + + private func set(error: CameraError?) { + DispatchQueue.main.async { + self.error = error + } + } + + private func checkPermissions() { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .notDetermined: + sessionQueue.suspend() + AVCaptureDevice.requestAccess(for: .video) { authorized in + if !authorized { + self.status = .unauthorized + self.set(error: .deniedAuthorization) + } + self.sessionQueue.resume() + } + case .restricted: + status = .unauthorized + set(error: .restrictedAuthorization) + case .denied: + status = .unauthorized + set(error: .deniedAuthorization) + case .authorized: + break + @unknown default: + status = .unauthorized + set(error: .unknownAuthorization) + } + } + + private func configureCaptureSession() { + guard status == .unconfigured else { + return + } + + session.beginConfiguration() + session.sessionPreset = .photo + + defer { + session.commitConfiguration() + } + + let device = AVCaptureDevice.default( + .builtInWideAngleCamera, + for: .video, + position: .back) + guard let camera = device else { + set(error: .cameraUnavailable) + status = .failed + return + } + + let cameraInput: AVCaptureDeviceInput + do { + cameraInput = try AVCaptureDeviceInput(device: camera) + } catch { + set(error: .createCaptureInput(error)) + status = .failed + return + } + guard session.canAddInput(cameraInput) else { + set(error: .cannotAddInput) + status = .failed + return + } + session.addInput(cameraInput) + + + if session.canAddOutput(videoOutput) { + session.addOutput(videoOutput) + + videoOutput.videoSettings = + [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] + + let videoConnection = videoOutput.connection(with: .video) + videoConnection?.videoOrientation = .portrait + } else { + set(error: .cannotAddOutput) + status = .failed + return + } + + guard session.canAddOutput(photoOutput) else { + set(error: .cannotAddOutput) + status = .failed + return + } + session.addOutput(photoOutput) + photoOutput.isHighResolutionCaptureEnabled = true + photoOutput.isDepthDataDeliveryEnabled = false + photoOutput.isLivePhotoCaptureEnabled = false + + status = .configured + } + + private func configure() { + checkPermissions() + + sessionQueue.async { + self.configureCaptureSession() + self.session.startRunning() + } + } + + func setVideoDelegate(_ delegate: AVCaptureVideoDataOutputSampleBufferDelegate, + queue: DispatchQueue) { + sessionQueue.async { + self.videoOutput.setSampleBufferDelegate(delegate, queue: queue) + } + } + + func stopVideoCaptureSession() { + sessionQueue.async { + guard self.status == .configured else { + return + } + guard self.session.isRunning else { + return + } + self.session.stopRunning() + } + } + + func startVideoCapture() { + guard status == .configured else { + return + } + sessionQueue.async { + guard !self.session.isRunning else { + return + } + self.session.startRunning() + } + } + + // MARK: Photo Capture + + func capturePhoto(delegate: AVCapturePhotoCaptureDelegate) { + sessionQueue.async { + let photoSettings = AVCapturePhotoSettings() + photoSettings.flashMode = .off + self.photoOutput.capturePhoto(with: photoSettings, delegate: delegate) + } + } +} diff --git a/Caps/Camera/CameraView.swift b/Caps/Camera/CameraView.swift new file mode 100644 index 0000000..2889865 --- /dev/null +++ b/Caps/Camera/CameraView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import SFSafeSymbols + +struct CameraView: View { + + static let cameraImagePadding: CGFloat = 300 + + private static let circleSize: CGFloat = 180 + private var circleSize: CGFloat { + CameraView.circleSize + } + + static var circleCropFactor: CGFloat { + let fullWidth = UIScreen.main.bounds.width + 2 * cameraImagePadding + return circleSize / fullWidth + } + + private let circleStrength: CGFloat = 3 + + private let circleColor: Color = .green + + private let captureButtonSize: CGFloat = 110 + private let captureButtonHeight: CGFloat = 40 + private let captureButtonWidth: CGFloat = 50 + + private let cancelButtonSize: CGFloat = 75 + private let cancelIconSize: CGFloat = 25 + + @Binding + var isPresented: Bool + + @Binding + var image: UIImage? + + @Binding + var capId: Int? + + @StateObject + private var model = ContentViewModel() + + @EnvironmentObject + var database: Database + + var body: some View { + ZStack { + FrameView(image: model.frame) + .edgesIgnoringSafeArea(.all) + .padding(-CameraView.cameraImagePadding) + + ErrorView(error: model.error) + VStack { + Spacer() + HStack { + Spacer() + Button(action: dismiss) { + Image(systemSymbol: .xmark) + .resizable() + .frame(width: cancelIconSize, height: cancelIconSize) + .padding((cancelButtonSize-cancelIconSize)/2) + .background(.thinMaterial) + .cornerRadius(cancelButtonSize/2) + }.padding() + } + } + VStack { + Spacer() + HStack { + Spacer() + Button(action: capture) { + Image(systemSymbol: .camera) + .resizable() + .frame(width: captureButtonWidth, height: captureButtonHeight) + .padding(.horizontal, (captureButtonSize - captureButtonWidth)/2) + .padding(.vertical, (captureButtonSize - captureButtonHeight)/2) + .background(.thinMaterial) + .cornerRadius(captureButtonSize / 2) + }.padding() + Spacer() + } + } + VStack { + Spacer() + HStack { + Spacer() + Text("") + .frame(width: circleSize, height: circleSize, alignment: .center) + .overlay(RoundedRectangle(cornerRadius: circleSize/2) + .stroke(lineWidth: circleStrength) + .foregroundColor(circleColor)) + + Spacer() + } + Spacer() + }.ignoresSafeArea() + + } + .onAppear() { + model.startCapture() + } + .onDisappear { + model.endCapture() + }.onChange(of: model.image) { image in + if let capId = capId, let image = image { + database.save(image, for: capId) + } else { + database.image = image + } + dismiss() + } + } + + private func dismiss() { + isPresented = false + } + + private func capture() { + model.captureImage() + } +} + +struct CameraView_Previews: PreviewProvider { + static var previews: some View { + CameraView(isPresented: .constant(true), + image: .constant(nil), + capId: .constant(nil)) + } +} diff --git a/Caps/Camera/ContentViewModel.swift b/Caps/Camera/ContentViewModel.swift new file mode 100644 index 0000000..c506130 --- /dev/null +++ b/Caps/Camera/ContentViewModel.swift @@ -0,0 +1,57 @@ +import CoreImage +import AVFoundation +import UIKit + +class ContentViewModel: ObservableObject { + + @Published var error: Error? + @Published var frame: CGImage? + @Published var image: UIImage? + + private let context = CIContext() + + private let cameraManager = CameraManager.shared + private let frameManager = FrameManager.shared + + init() { + setupSubscriptions() + } + + func setupSubscriptions() { + frameManager.image = nil + frameManager.current = nil + + cameraManager.$error + .receive(on: RunLoop.main) + .map { $0 } + .assign(to: &$error) + + frameManager.$current + .receive(on: RunLoop.main) + .compactMap { buffer in + guard let image = CGImage.create(from: buffer) else { + return nil + } + + let ciImage = CIImage(cgImage: image) + return self.context.createCGImage(ciImage, from: ciImage.extent) + } + .assign(to: &$frame) + + frameManager.$image + .receive(on: RunLoop.main) + .assign(to: &$image) + } + + func endCapture() { + cameraManager.stopVideoCaptureSession() + } + + func startCapture() { + cameraManager.startVideoCapture() + } + + func captureImage() { + cameraManager.capturePhoto(delegate: frameManager) + } +} diff --git a/Caps/Camera/ErrorView.swift b/Caps/Camera/ErrorView.swift new file mode 100644 index 0000000..4bb7d4f --- /dev/null +++ b/Caps/Camera/ErrorView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct ErrorView: View { + var error: Error? + + var body: some View { + VStack { + Text(error?.localizedDescription ?? "") + .bold() + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(8) + .foregroundColor(.white) + .background(Color.red.edgesIgnoringSafeArea(.top)) + .opacity(error == nil ? 0.0 : 1.0) + .animation(.easeInOut, value: 0.25) + + Spacer() + } + } +} + +struct ErrorView_Previews: PreviewProvider { + static var previews: some View { + ErrorView(error: CameraError.cannotAddInput) + } +} diff --git a/Caps/Camera/FrameManager.swift b/Caps/Camera/FrameManager.swift new file mode 100644 index 0000000..c4a8412 --- /dev/null +++ b/Caps/Camera/FrameManager.swift @@ -0,0 +1,70 @@ +import AVFoundation +import CoreGraphics +import UIKit + +class FrameManager: NSObject, ObservableObject { + + static let shared = FrameManager() + + @Published var current: CVPixelBuffer? + + @Published var image: UIImage? + + let videoOutputQueue = DispatchQueue( + label: "de.christophhagen.videoout", + qos: .userInitiated, + attributes: [], + autoreleaseFrequency: .workItem) + + private override init() { + super.init() + + CameraManager.shared.setVideoDelegate(self, queue: videoOutputQueue) + } +} + +extension FrameManager: AVCaptureVideoDataOutputSampleBufferDelegate { + + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + if let buffer = sampleBuffer.imageBuffer { + DispatchQueue.main.async { + self.current = buffer + } + } + } +} + +extension FrameManager: AVCapturePhotoCaptureDelegate { + + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + let image = convert(photo, error: error) + DispatchQueue.main.async { + self.image = image + } + } + + private func convert(_ photo: AVCapturePhoto, error: Error?) -> UIImage? { + guard error == nil else { + log("PhotoCaptureHandler: \(error!)") + return nil + } + + guard let cgImage = photo.cgImageRepresentation() else { + log("PhotoCaptureHandler: No image captured") + return nil + } + let image = UIImage(cgImage: cgImage, scale: UIScreen.main.scale, orientation: .right) + guard let masked = image.crop(factor: CameraView.circleCropFactor).circleMasked else { + log("Could not mask image") + return nil + } + print(image.size) + print(masked.size) + print(masked.scale) + return masked + } +} diff --git a/Caps/Camera/FrameView.swift b/Caps/Camera/FrameView.swift new file mode 100644 index 0000000..5378548 --- /dev/null +++ b/Caps/Camera/FrameView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct FrameView: View { + var image: CGImage? + + private let label = Text("Video feed") + + var body: some View { + if let image = image { + GeometryReader { geometry in + Image(image, scale: 1.0, orientation: .up, label: label) + .resizable() + .scaledToFill() + .frame( + width: geometry.size.width, + height: geometry.size.height, + alignment: .center) + .clipped() + } + } else { + EmptyView() + } + } +} + +struct FrameView_Previews: PreviewProvider { + static var previews: some View { + FrameView(image: nil) + } +} diff --git a/Caps/CapsApp.swift b/Caps/CapsApp.swift new file mode 100644 index 0000000..cc11b99 --- /dev/null +++ b/Caps/CapsApp.swift @@ -0,0 +1,18 @@ +import SwiftUI + +#warning("TODO: Create new caps") +#warning("TODO: Add colors") +#warning("TODO: Grid view") + +@main +struct CapsApp: App { + + let database = Database(server: URL(string: "https://christophhagen.de/caps")!) + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(database) + } + } +} diff --git a/Caps/Capture/CameraController.swift b/Caps/Capture/CameraController.swift deleted file mode 100644 index a7773f5..0000000 --- a/Caps/Capture/CameraController.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// CameraController.swift -// CapFinder -// -// Created by User on 22.02.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - -protocol CameraControllerDelegate { - - func didCapture(image: UIImage) - - func didCancel() -} - -class CameraController: UIViewController { - - // MARK: Outlets - - @IBOutlet weak var imageButton: UIButton! - - @IBOutlet weak var cropView: CropView! - - @IBOutlet weak var cancelButton: UIButton! - - @IBOutlet weak var cameraView: CameraView! { - didSet { - cameraView.configure() - } - } - - var delegate: CameraControllerDelegate? - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } - - override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { - return .portrait - } - - override var shouldAutorotate: Bool { - return false - } - - // MARK: Actions - - @IBAction func backButtonPressed() { - self.giveFeedback(.medium) - delegate?.didCancel() - self.dismiss(animated: true) - } - - @IBAction func imageButtonPressed() { - self.giveFeedback(.medium) - imageButton.isEnabled = false - cameraView.capture() - } - - // MARK: Life cycle - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - cameraView.delegate = self - - cameraView.launch { success, error in - guard let err = error else { - return - } - switch err { - case "No camera access": self.showNoCameraAccessAlert() - case "Camera error": self.showAlert("Unable to capture media") - default: self.showAlert("Error in camera setup") - } - } - self.imageButton.isEnabled = true - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - cameraView.complete() - } - - private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { - let generator = UIImpactFeedbackGenerator(style: style) - generator.impactOccurred() - } - - // MARK: Alerts - - private func showNoCameraAccessAlert() { - let alert = UIAlertController(title: "Unable to access the Camera", - message: "To enable access, go to Settings > Privacy > Camera and turn on Camera access for this app.", - preferredStyle: .alert)//, - //blurStyle: .dark) - - let okAction = UIAlertAction(title: "OK", style: .cancel, handler: nil) - alert.addAction(okAction) - - let settingsAction = UIAlertAction(title: "Settings", style: .default, handler: { _ in - // Take the user to Settings app to possibly change permission. - guard let settingsUrl = URL(string: UIApplication.openSettingsURLString) else { return } - if UIApplication.shared.canOpenURL(settingsUrl) { - UIApplication.shared.open(settingsUrl, completionHandler: nil) - } - }) - alert.addAction(settingsAction) - self.present(alert, animated: true, completion: nil) - } - - override func touchesBegan(_ touches: Set, with event: UIEvent?) { - cameraView.didReceiveTouch(touches.first!) - } -} - -extension CameraController: PhotoCaptureHandlerDelegate { - - func didCapture(_ image: UIImage?) { - log("Image captured") - let factor = CGFloat(cropView.relativeSize) - self.dismiss(animated: true) - guard let img = image else { - self.error("No image captured") - return - } - - guard delegate != nil else { - self.error("No delegate") - return - } - - guard let masked = img.crop(factor: factor).circleMasked else { - self.error("Could not mask image") - return - } - - let scaled = masked.resize(to: Cap.imageSize) - - delegate!.didCapture(image: scaled) - } -} - -extension CameraController: Logger { } diff --git a/Caps/Capture/CameraView.swift b/Caps/Capture/CameraView.swift deleted file mode 100644 index 92e46e7..0000000 --- a/Caps/Capture/CameraView.swift +++ /dev/null @@ -1,224 +0,0 @@ -// -// CameraView.swift -// CapFinder -// -// Created by User on 07.02.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit -import AVFoundation - -class CameraView: UIView { - - // MARK: UIView overrides - - /** - Override for AVCapture - */ - override class var layerClass: AnyClass { - return AVCaptureVideoPreviewLayer.self - } - - // MARK: Enums - - private enum SessionSetupResult { - case success - case notAuthorized - case configurationFailed - } - - // MARK: Variables - - var delegate: PhotoCaptureHandlerDelegate? { - get { - return photoCaptureProcessor.delegate - } - set { - photoCaptureProcessor.delegate = newValue - } - } - - private let session = AVCaptureSession() - - private var isSessionRunning = false - - /// Communicate with the session and other session objects on this queue. - private let sessionQueue = DispatchQueue(label: "session queue") - - private var setupResult: SessionSetupResult = .success - - var videoDeviceInput: AVCaptureDeviceInput! - - private let photoOutput = AVCapturePhotoOutput() - - private var cameraDevice: AVCaptureDevice? - - private let photoCaptureProcessor = PhotoCaptureHandler() - - var videoPreviewLayer: AVCaptureVideoPreviewLayer { - return layer as! AVCaptureVideoPreviewLayer - } - - // MARK: Life cycle - - func configure() { - videoPreviewLayer.session = session - - checkPermission() - - // Setup the capture session. - sessionQueue.async { - self.configureSession() - } - } - - func launch(completionHandler: @escaping (Bool, String?) -> ()) { - sessionQueue.async { - switch self.setupResult { - case .success: - // Only setup observers and start the session running if setup succeeded. - self.session.startRunning() - self.isSessionRunning = self.session.isRunning - - case .notAuthorized: - DispatchQueue.main.async { - completionHandler(false, "No camera access") - } - - case .configurationFailed: - DispatchQueue.main.async { - completionHandler(false, "Camera error") - } - } - } - } - - func complete() { - sessionQueue.async { - if self.setupResult == .success { - self.session.stopRunning() - self.isSessionRunning = self.session.isRunning - } - } - } - - // MARK: Photo Capture - - func capture() { - sessionQueue.async { - self.photoOutput.capturePhoto( - with: self.photoCaptureProcessor.photoSettings, - delegate: self.photoCaptureProcessor) - } - } - - // MARK: Camera permissions - - private func checkPermission() { - switch AVCaptureDevice.authorizationStatus(for: .video) { - case .authorized: break - case .notDetermined: - sessionQueue.suspend() - AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in - if !granted { - self.setupResult = .notAuthorized - } - self.sessionQueue.resume() - }) - - default: - // The user has previously denied access. - setupResult = .notAuthorized - } - } - - // Call this on the session queue. - private func configureSession() { - if setupResult != .success { - return - } - - session.beginConfiguration() - - /* - We do not create an AVCaptureMovieFileOutput when setting up the session because the - AVCaptureMovieFileOutput does not support movie recording with AVCaptureSession.Preset.Photo. - */ - session.sessionPreset = .photo - - // Add video input. - - guard let backCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { - error("No camera on device") - setupResult = .configurationFailed - session.commitConfiguration() - return - } - - let videoDeviceInput: AVCaptureDeviceInput - do { - videoDeviceInput = try AVCaptureDeviceInput(device: backCameraDevice) - } catch { - self.error("Could not create video device input: \(error)") - setupResult = .configurationFailed - session.commitConfiguration() - return - } - - guard session.canAddInput(videoDeviceInput) else { - error("Could not add video device input to the session") - setupResult = .configurationFailed - session.commitConfiguration() - return - } - - session.addInput(videoDeviceInput) - self.videoDeviceInput = videoDeviceInput - self.cameraDevice = backCameraDevice - DispatchQueue.main.async { - self.videoPreviewLayer.connection?.videoOrientation = .portrait - } - - // Add photo output. - guard session.canAddOutput(photoOutput) else { - error("Could not add photo output to the session") - setupResult = .configurationFailed - session.commitConfiguration() - return - } - session.addOutput(photoOutput) - - photoOutput.isHighResolutionCaptureEnabled = true - photoOutput.isDepthDataDeliveryEnabled = false - //photoOutput.isDualCameraDualPhotoDeliveryEnabled = false - photoOutput.isLivePhotoCaptureEnabled = false - session.commitConfiguration() - } - - func didReceiveTouch(_ touch: UITouch) { - let screenSize = bounds.size - let location = touch.location(in: self) - let focusPoint = CGPoint(x: location.y / screenSize.height, y: 1.0 - location.x / screenSize.width) - log("Focusing on point (\(focusPoint.x),\(focusPoint.y))") - if let device = cameraDevice { - do { - try device.lockForConfiguration() - - if device.isFocusPointOfInterestSupported { - device.focusPointOfInterest = focusPoint - device.focusMode = .autoFocus - } -// if device.isExposurePointOfInterestSupported { -// device.exposurePointOfInterest = focusPoint -// device.exposureMode = .autoExpose -// } - device.unlockForConfiguration() - } catch { - self.error("Could not lock device for configuration: \(error)") - } - } - } -} - -extension CameraView: Logger { } diff --git a/Caps/Capture/PhotoCaptureHandler.swift b/Caps/Capture/PhotoCaptureHandler.swift deleted file mode 100644 index e163c52..0000000 --- a/Caps/Capture/PhotoCaptureHandler.swift +++ /dev/null @@ -1,56 +0,0 @@ -/* -See LICENSE.txt for this sample’s licensing information. - -Abstract: -Photo capture delegate. -*/ - -import AVFoundation -import Photos -import UIKit - -protocol PhotoCaptureHandlerDelegate { - - func didCapture(_ image: UIImage?) -} - -class PhotoCaptureHandler: NSObject { - - var delegate: PhotoCaptureHandlerDelegate? - - var photoSettings: AVCapturePhotoSettings { - let photoSettings = AVCapturePhotoSettings() - photoSettings.flashMode = .off - return photoSettings - } -} - -extension PhotoCaptureHandler: AVCapturePhotoCaptureDelegate { - /* - This extension includes all the delegate callbacks for AVCapturePhotoCaptureDelegate protocol - */ - - func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - - guard error == nil else { - log("PhotoCaptureHandler: \(error!)") - delegate?.didCapture(nil) - return - } - - guard let cgImage = photo.cgImageRepresentation() else { - log("PhotoCaptureHandler: No image captured") - delegate?.didCapture(nil) - return - } - - let image = UIImage(cgImage: cgImage, scale: 1.0, orientation: .right) - DispatchQueue.main.async { - self.delegate?.didCapture(image) - } - } -} - -extension PhotoCaptureHandler: Logger { - -} diff --git a/Caps/ContentView.swift b/Caps/ContentView.swift new file mode 100644 index 0000000..bbe4863 --- /dev/null +++ b/Caps/ContentView.swift @@ -0,0 +1,339 @@ +import SwiftUI +import SFSafeSymbols +import BottomSheet + +struct ContentView: View { + + private let bottomIconSize: CGFloat = 25 + + private let bottomIconPadding: CGFloat = 7 + + private let capturedImageSize: CGFloat = 80 + + private let plusIconSize: CGFloat = 20 + + private var scale: CGFloat { + UIScreen.main.scale + } + + @EnvironmentObject + var database: Database + + @State + var searchString = "" + + @State + var sortType: SortCriteria = .id + + @State + var sortAscending = false + + @State + var showSortPopover = false + + @State + var showCameraSheet = false + + @State + var showSettingsSheet = false + + @State + var showGridView = false + + @State + var showNewClassifierAlert = false + + @State + var isEnteringNewCapName = false + + @State + private var capIdOfNextPhoto: Int? + + var filteredCaps: [Cap] { + let text = searchString + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard text != "" else { + return Array(database.caps.values) + } + let textParts = text.components(separatedBy: " ").filter { $0 != "" } + return database.caps.values.compactMap { cap -> Cap? in + // For each part of text, check if name contains it + for textItem in textParts { + if !cap.cleanName.contains(textItem) { + return nil + } + } + return cap + } + } + + var shownCaps: [Cap] { + let caps = filteredCaps + if sortAscending { + switch sortType { + case .id: + return caps.sorted { $0.id < $1.id } + case .count: + return caps.sorted { + $0.imageCount < $1.imageCount + } + case .name: + return caps.sorted { + $0.name < $1.name + } + case .match: + return caps.sorted { + match(for: $0.id) ?? 0 < match(for: $1.id) ?? 0 + } + } + } else { + switch sortType { + case .id: + return caps.sorted { $0.id > $1.id } + case .count: + return caps.sorted { + $0.imageCount > $1.imageCount + } + case .name: + return caps.sorted { + $0.name > $1.name + } + case .match: + return caps.sorted { + match(for: $0.id) ?? 0 > match(for: $1.id) ?? 0 + } + } + } + } + + func match(for cap: Int) -> Float? { + database.matches[cap] + } + + var body: some View { + NavigationView { + ZStack { + List(shownCaps) { cap in + CapRowView(cap: cap, match: match(for: cap.id)) + .onTapGesture { + didTap(cap: cap) + } + } + .refreshable { + refresh() + } + .navigationTitle("Caps") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: showSettings) { + Image(systemSymbol: .gearshape) + } + } + } + VStack(spacing: 0) { + Spacer() + if let image = database.image { + HStack(alignment: .bottom, spacing: 0) { + Spacer() + if isEnteringNewCapName { + Text(String(format: "ID: %d", database.nextCapId)) + .font(.headline) + .padding(.vertical, 3) + .padding(.horizontal, 8) + .background(.regularMaterial) + .cornerRadius(5) + .padding(5) + } + Button(action: didTapClassifiedImage) { + ZStack(alignment: Alignment.bottomTrailing) { + Image(uiImage: image.resize(to: CGSize(width: capturedImageSize, height: capturedImageSize))) + .frame(width: capturedImageSize, height: capturedImageSize) + .background(.gray) + .cornerRadius(capturedImageSize/2) + .padding(5) + .background(.regularMaterial) + .cornerRadius(capturedImageSize/2 + 5) + .padding(5) + if !isEnteringNewCapName { + Image(systemSymbol: .plus) + .frame(width: plusIconSize, height: plusIconSize) + .padding(6) + .background(.regularMaterial) + .cornerRadius(plusIconSize/2 + 6) + .padding(5) + } + } + } + } + } + HStack(spacing: 0) { + if isEnteringNewCapName { + Button(action: removeCapturedImage) { + Image(systemSymbol: .xmark) + .resizable() + .frame(width: bottomIconSize-6, + height: bottomIconSize-6) + .padding() + .padding(3) + } + } else { + Button(action: filter) { + Image(systemSymbol: .line3HorizontalDecreaseCircle) + .resizable() + .frame(width: bottomIconSize, + height: bottomIconSize) + .padding() + } + } + if isEnteringNewCapName { + CapNameEntryView(name: $searchString) + .disableAutocorrection(true) + } else { + SearchField(searchString: $searchString) + .disableAutocorrection(true) + } + if isEnteringNewCapName { + Button(action: saveNewCap) { + Image(systemSymbol: .squareAndArrowDown) + .resizable() + .frame(width: bottomIconSize-3, + height: bottomIconSize) + .padding() + .padding(.horizontal, (bottomIconPadding-3)/2) + } + } else if database.image != nil { + Button(action: removeCapturedImage) { + Image(systemSymbol: .xmark) + .resizable() + .frame(width: bottomIconSize-6, + height: bottomIconSize-6) + .padding(3) + .padding(bottomIconPadding) + } + } else { + Button(action: openCamera) { + Image(systemSymbol: .camera) + .resizable() + .frame(width: bottomIconSize+bottomIconPadding, + height: bottomIconSize) + .padding() + } + } + } + .background(.regularMaterial) + } + } + } + .onAppear { + UIScrollView.appearance().keyboardDismissMode = .onDrag + } + .bottomSheet(isPresented: $showSortPopover, height: 280) { + SortSelectionView( + hasMatches: !database.matches.isEmpty, + isPresented: $showSortPopover, + sortType: $sortType, + sortAscending: $sortAscending, + showGridView: $showGridView) + } + .sheet(isPresented: $showCameraSheet) { + CameraView(isPresented: $showCameraSheet, + image: $database.image, + capId: $capIdOfNextPhoto) + } + .bottomSheet(isPresented: $showSettingsSheet, height: 360) { + SettingsView(isPresented: $showSettingsSheet) + } + .sheet(isPresented: $showGridView) { + GridView() + }.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?"), + primaryButton: .default(Text("Download"), action: downloadClassifier), + secondaryButton: .cancel()) + } + .onChange(of: database.image) { newImage in + if newImage != nil { + sortType = .id + sortAscending = false + return + } + }.onChange(of: database.matches) { newMatches in + if newMatches.isEmpty { + sortType = .id + sortAscending = false + } else { + sortType = .match + sortAscending = false + } + } + } + + private func refresh() { + Task { + await database.downloadCaps() + let hasNewClassifier = await database.serverHasNewClassifier() + guard hasNewClassifier else { + return + } + DispatchQueue.main.async { + self.showNewClassifierAlert = true + } + } + } + + private func filter() { + showSortPopover.toggle() + } + + private func openCamera() { + removeCapturedImage() + showCameraSheet.toggle() + } + + private func showSettings() { + showSettingsSheet.toggle() + } + + private func downloadClassifier() { + Task { + await database.downloadClassifier() + } + } + + private func didTapClassifiedImage() { + isEnteringNewCapName = true + } + + private func removeCapturedImage() { + database.image = nil + } + + private func didTap(cap: Cap) { + guard let image = database.image else { + capIdOfNextPhoto = cap.id + openCamera() + return + } + database.save(image, for: cap.id) + database.image = nil + } + + private func saveNewCap() { + guard let image = database.image else { + return + } + let name = searchString.trimmingCharacters(in: .whitespacesAndNewlines) + let newCap = database.save(newCap: name) + database.save(image, for: newCap.id) + removeCapturedImage() + isEnteringNewCapName = false + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + .environmentObject(Database.mock) + } +} diff --git a/Caps/Data/Cap.swift b/Caps/Data/Cap.swift index 361e4b5..bd8d9f0 100644 --- a/Caps/Data/Cap.swift +++ b/Caps/Data/Cap.swift @@ -1,202 +1,159 @@ -// -// Cap.swift -// CapCollector -// -// Created by Christoph on 19.11.18. -// Copyright © 2018 CH. All rights reserved. -// - import Foundation -import UIKit -import CoreImage - -import SQLite struct Cap { - - // MARK: - Static constants - - static let sufficientImageCount = 10 - - static let imageWidth = 299 // New for XCode models, 227/229 for turicreate - static let imageSize = CGSize(width: imageWidth, height: imageWidth) - - static let jpgQuality: CGFloat = 0.3 - - private static let mosaicColumns = 40 - - static let mosaicCellSize: CGFloat = 60 - - private static let mosaicRowHeight = mosaicCellSize * 0.866 - - private static let mosaicMargin = mosaicCellSize - mosaicRowHeight - - // MARK: - Variables - /// The unique number of the cap let id: Int - + /// The name of the cap - let name: String - + var name: String + /// The name of the cap without special characters - let cleanName: String - + var cleanName: String + /// The number of images existing for the cap - let count: Int - - /// Indicate if the cap can be found by the recognition model - let matched: Bool - - /// Indicate if the cap is present on the server - let uploaded: Bool - - // MARK: Init - - init(name: String, id: Int) { - self.id = id - self.count = 1 - self.name = name - self.cleanName = "" - self.matched = false - self.uploaded = false + var imageCount: Int + + /// The index of the main image for the cap + var mainImage: Int + + /// The version of the first classifier capable of recognizing the cap + var classifierVersion: Int? + + var color: Color? + + /// The subpath to the main image on the server + var mainImagePath: String { + String(format: "images/%04d/%04d-%02d.jpg", id, id, mainImage) } - - init(id: Int, name: String, count: Int) { + + /** + Create a new cap. + - Parameter id: The unique id of the cap + - Parameter name: The name associated with the cap + */ + init(id: Int, name: String, classifier: Int? = nil) { self.id = id self.name = name - self.count = count self.cleanName = name.clean - self.matched = false - self.uploaded = true + self.imageCount = 1 + self.mainImage = 0 + self.classifierVersion = classifier } - - func renamed(to name: String) -> Cap { - Cap(from: self, renamed: name) - } - - init(from cap: Cap, renamed newName: String) { - self.id = cap.id - self.count = cap.count - self.name = newName - self.cleanName = newName.clean - self.matched = cap.matched - self.uploaded = cap.uploaded - } - - // MARK: SQLite - - init(row: Row) { - self.id = row[Cap.columnId] - self.name = row[Cap.columnName] - self.count = row[Cap.columnCount] - self.cleanName = name.clean - self.matched = row[Cap.columnMatched] - self.uploaded = row[Cap.columnUploaded] + init(data: CapData) { + self.id = data.id + self.name = data.name + self.cleanName = data.name.clean + self.imageCount = data.count + self.mainImage = data.mainImage + self.classifierVersion = data.classifierVersion } - - static let table = Table("data") - - static var createQuery: String { - table.create(ifNotExists: true) { t in - t.column(columnId, primaryKey: true) - t.column(columnName) - t.column(columnCount) - t.column(columnMatched) - t.column(columnUploaded) - } - } - - static let columnId = Expression("id") - - static let columnName = Expression("name") - - static let columnCount = Expression("count") - - static let columnMatched = Expression("matched") - - static let columnUploaded = Expression("uploaded") - - var insertQuery: Insert { - return Cap.table.insert( - Cap.columnId <- id, - Cap.columnName <- name, - Cap.columnCount <- count, - Cap.columnMatched <- matched, - Cap.columnUploaded <- uploaded) - } - - // MARK: Display - - func matchLabelText(match: Float?, appIsUnlocked: Bool) -> String { - if let match = match { - let percent = Int((match * 100).rounded()) - return String(format: "%d %%", arguments: [percent]) - } - - guard matched else { - return "📵" - } - guard appIsUnlocked, !hasSufficientImages else { - return "" - } - return "⚠️" - } - - func countLabelText(appIsUnlocked: Bool) -> String { - guard appIsUnlocked else { - return "\(id)" - } - guard count != 1 else { - return "\(id) (1 image)" - } - return "\(id) (\(count) images)" - } - - // MARK: Images - var hasSufficientImages: Bool { - count >= Cap.sufficientImageCount + mutating func update(with data: CapData) { + self.name = data.name + self.cleanName = data.name.clean + self.imageCount = data.count + self.mainImage = data.mainImage + self.classifierVersion = data.classifierVersion } - - static func thumbnail(for image: UIImage) -> UIImage { - let len = GridViewController.len * 2 - return image.resize(to: CGSize.init(width: len, height: len)) + + static func ==(lhs: Cap, rhs: CapData) -> Bool { + lhs.id == rhs.id && + lhs.name == rhs.name && + lhs.imageCount == rhs.count && + lhs.mainImage == rhs.mainImage && + lhs.classifierVersion == rhs.classifierVersion + } + + static func !=(lhs: Cap, rhs: CapData) -> Bool { + !(lhs == rhs) + } + + func classifiable(by classifierVersion: Int?) -> Bool { + guard let version = classifierVersion else { + return false + } + guard let own = self.classifierVersion else { + return false + } + return version >= own } } -// MARK: - Protocol Hashable +extension Cap { + + struct Color: Codable, Equatable { + + let r: Int + + let g: Int + + let b: Int + } +} + +// MARK: Protocol Identifiable + +extension Cap: Identifiable { + +} + +// MARK: Protocol Comparable + +extension Cap: Codable { + + enum CodingKeys: String, CodingKey { + case id = "u" + case name = "n" + case cleanName = "c" + case imageCount = "i" + case mainImage = "m" + case classifierVersion = "v" + case color = "f" + } +} + +// MARK: Protocol Comparable + +extension Cap: Comparable { + + static func < (lhs: Cap, rhs: Cap) -> Bool { + lhs.id < rhs.id + } +} + +// MARK: Protocol Equatable + +extension Cap: Equatable { -extension Cap: Hashable { - static func == (lhs: Cap, rhs: Cap) -> Bool { return lhs.id == rhs.id } - +} + +// MARK: Protocol Hashable + +extension Cap: Hashable { + func hash(into hasher: inout Hasher) { hasher.combine(id) } } -// MARK: - Protocol Logger +// MARK: String extension -extension Cap: Logger { } +private extension String { -// MARK: - String extension - -extension String { - var clean: String { return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression) } } -// MARK: - Int extension +// MARK: Int extension private extension Int { - + var isEven: Bool { return self % 2 == 0 } diff --git a/Caps/Data/CapData.swift b/Caps/Data/CapData.swift new file mode 100644 index 0000000..7413694 --- /dev/null +++ b/Caps/Data/CapData.swift @@ -0,0 +1,32 @@ +import Foundation + +struct CapData: Codable { + + let id: Int + + var name: String + + var count: Int + + var mainImage: Int + + var classifierVersion: Int? + + var color: Cap.Color? + + enum CodingKeys: String, CodingKey { + case id = "i" + case name = "n" + case count = "c" + case mainImage = "m" + case classifierVersion = "v" + case color = "f" + } +} + +extension CapData: Comparable { + + static func < (lhs: CapData, rhs: CapData) -> Bool { + lhs.id < rhs.id + } +} diff --git a/Caps/Data/Classifier.swift b/Caps/Data/Classifier.swift index 50b2241..446231e 100644 --- a/Caps/Data/Classifier.swift +++ b/Caps/Data/Classifier.swift @@ -1,11 +1,3 @@ -// -// VisionHandler.swift -// CapFinder -// -// Created by User on 12.02.18. -// Copyright © 2018 User. All rights reserved. -// - import Foundation import Vision import CoreML @@ -13,28 +5,24 @@ import UIKit /// Recognise categories in images class Classifier: Logger { - + static let userDefaultsKey = "classifier.version" - + let model: VNCoreMLModel - + init(model: VNCoreMLModel) { self.model = model } /** Classify an image - Parameter image: The image to classify + - Parameter completion: The callback with the match results + - Parameter matches: A dictionary with a map from cap id to classifier match - Note: This method should not be scheduled on the main thread. */ - func recognize(image: UIImage, completion: @escaping (_ matches: [Int: Float]?) -> Void) { - guard let ciImage = CIImage(image: image) else { - error("Unable to create CIImage") - completion(nil) - return - } - - let orientation = CGImagePropertyOrientation(image.imageOrientation) - let handler = VNImageRequestHandler(ciImage: ciImage, orientation: orientation) + func recognize(image: CGImage, completion: @escaping (_ matches: [Int: Float]?) -> Void) { + let image = CIImage(cgImage: image) + let handler = VNImageRequestHandler(ciImage: image, orientation: .up) let request = VNCoreMLRequest(model: model) { request, error in let matches = self.process(request: request, error: error) completion(matches) @@ -46,7 +34,7 @@ class Classifier: Logger { self.error("Failed to perform classification: \(error)") } } - + private func process(request: VNRequest, error: Error?) -> [Int : Float]? { if let e = error { self.error("Unable to classify image: \(e.localizedDescription)") @@ -57,7 +45,7 @@ class Classifier: Logger { return nil } let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence } - + log("Classifed image with \(matches.count) classes") return matches } diff --git a/Caps/Data/Colors.swift b/Caps/Data/Colors.swift deleted file mode 100644 index 4d7d0e6..0000000 --- a/Caps/Data/Colors.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// Colors.swift -// CapCollector -// -// Created by Christoph on 26.05.20. -// Copyright © 2020 CH. All rights reserved. -// - -import UIKit -import SQLite - -extension Database { - - enum Colors { - - static let table = Table("colors") - - static let columnRed = Expression("red") - - static let columnGreen = Expression("green") - - static let columnBlue = Expression("blue") - - static var createQuery: String { - table.create(ifNotExists: true) { t in - t.column(Cap.columnId, primaryKey: true) - t.column(columnRed) - t.column(columnGreen) - t.column(columnBlue) - } - } - } - - var colors: [Int : UIColor] { - do { - let rows = try db.prepare(Database.Colors.table) - return rows.reduce(into: [:]) { dict, row in - let id = row[Cap.columnId] - let r = CGFloat(row[Database.Colors.columnRed]) - let g = CGFloat(row[Database.Colors.columnGreen]) - let b = CGFloat(row[Database.Colors.columnBlue]) - dict[id] = UIColor(red: r, green: g, blue: b, alpha: 1.0) - } - } catch { - log("Failed to load cap colors: \(error)") - return [:] - } - } - - var capsWithColors: Set { - do { - let rows = try db.prepare(Database.Colors.table.select(Cap.columnId)) - return Set(rows.map { $0[Cap.columnId]}) - } catch { - log("Failed to load caps with colors: \(error)") - return [] - } - } - - var capsWithoutColors: Set { - Set(1...capCount).subtracting(capsWithColors) - } - - func removeColor(for cap: Int) -> Bool { - do { - try db.run(Colors.table.filter(Cap.columnId == cap).delete()) - return true - } catch { - log("Failed to delete cap color \(cap): \(error)") - return false - } - } - - func set(color: UIColor, for cap: Int) -> Bool { - guard let _ = row(for: cap) else { - return insert(color: color, for: cap) - } - return update(color: color, for: cap) - } - - private func insert(color: UIColor, for cap: Int) -> Bool { - let (red, green, blue) = color.rgb - let query = Database.Colors.table.insert( - Cap.columnId <- cap, - Database.Colors.columnRed <- red, - Database.Colors.columnGreen <- green, - Database.Colors.columnBlue <- blue) - - do { - try db.run(query) - return true - } catch { - log("Failed to insert color for cap \(cap): \(error)") - return false - } - } - - private func update(color: UIColor, for cap: Int) -> Bool { - let (red, green, blue) = color.rgb - let query = Database.Colors.table.filter(Cap.columnId == cap).update( - Database.Colors.columnRed <- red, - Database.Colors.columnGreen <- green, - Database.Colors.columnBlue <- blue) - - do { - try db.run(query) - return true - } catch { - log("Failed to update color for cap \(cap): \(error)") - return false - } - } - - private func row(for cap: Int) -> Row? { - do { - return try db.pluck(Database.Colors.table.filter(Cap.columnId == cap)) - } catch { - log("Failed to get color for cap \(cap): \(error)") - return nil - } - } - - func color(for cap: Int) -> UIColor? { - guard let row = self.row(for: cap) else { - return nil - } - let r = CGFloat(row[Database.Colors.columnRed]) - let g = CGFloat(row[Database.Colors.columnGreen]) - let b = CGFloat(row[Database.Colors.columnBlue]) - return UIColor(red: r, green: g, blue: b, alpha: 1.0) - } -} diff --git a/Caps/Data/Database.swift b/Caps/Data/Database.swift index 29a4f24..e699b5a 100644 --- a/Caps/Data/Database.swift +++ b/Caps/Data/Database.swift @@ -1,1042 +1,636 @@ -// -// Database.swift -// CapCollector -// -// Created by Christoph on 14.04.20. -// Copyright © 2020 CH. All rights reserved. -// - import Foundation -import UIKit -import CoreML -import SQLite +import SwiftUI +import Vision +import CryptoKit -protocol DatabaseDelegate: AnyObject { - - func database(didAddCap cap: Cap) - - func database(didChangeCap cap: Int) - - func database(didLoadImageForCap cap: Int) - - func database(completedBackgroundWorkItem title: String, subtitle: String) - - func database(needsUserConfirmation title: String, body: String, shouldProceed: @escaping (Bool) -> Void) - - func database(didFailBackgroundWork title: String, subtitle: String) - - func databaseHasNewClassifier() - - func databaseDidFinishBackgroundWork() - - func databaseNeedsFullRefresh() -} +final class Database: ObservableObject { -private enum BackgroundWorkTaskType: Int, CustomStringConvertible, Comparable { - - case downloadCapNames = 9 - case downloadCounts = 8 - case downloadClassifier = 7 - case uploadingCaps = 6 - case uploadingImages = 5 - case downloadMainImages = 4 - case creatingThumbnails = 3 - case creatingColors = 2 - - var description: String { - switch self { - case .downloadCapNames: - return "Downloading names" - case .downloadCounts: - return "Downloading counts" - case .downloadClassifier: - return "Downloading classifier" - case .uploadingCaps: - return "Uploading caps" - case .uploadingImages: - return "Uploading images" - case .downloadMainImages: - return "Downloading images" - case .creatingThumbnails: - return "Creating thumbnails" - case .creatingColors: - return "Creating colors" - } - } - - - var maximumNumberOfSimultaneousItems: Int { - switch self { - case .downloadMainImages: - return 50 - case .creatingThumbnails: - return 10 - case .creatingColors: - return 10 - default: - return 1 - } - } - - var nextType: BackgroundWorkTaskType? { - BackgroundWorkTaskType(rawValue: rawValue - 1) - } - - static func < (lhs: BackgroundWorkTaskType, rhs: BackgroundWorkTaskType) -> Bool { - lhs.rawValue < rhs.rawValue - } - -} + static let imageCacheMemory = 10_000_000 + static let imageCacheStorage = 200_000_000 -final class Database { - - // MARK: Variables - - let db: Connection - - private let upload: Upload - - private let download: Download - - let storage: Storage - - weak var delegate: DatabaseDelegate? + private let imageCompressionQuality: CGFloat = 0.3 - init?(url: URL, server: URL, storageFolder: URL) { - guard let db = try? Connection(url.path) else { - return nil - } - - let upload = Upload(server: server) - let download = Download(server: server) - - do { - try db.run(Cap.createQuery) - try db.run(upload.createQuery) - try db.run(Database.Colors.createQuery) - try db.run(Database.TileImage.createQuery) - } catch { - return nil - } + private static var documentDirectory: URL { + try! FileManager.default.url( + for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, create: true) + } - self.db = db - self.upload = upload - self.download = download - self.storage = Storage(in: storageFolder) - log("Database loaded with \(capCount) caps") + private var fm: FileManager { + .default } - - // MARK: Computed properties - /// All caps currently in the database - var caps: [Cap] { - (try? db.prepare(Cap.table))?.map(Cap.init) ?? [] + private var localDbUrl: URL { + Database.documentDirectory.appendingPathComponent("db.json") } - - /// The ids of all caps - var capIds: Set { - Set(caps.map { $0.id }) + + private var localClassifierUrl: URL { + Database.documentDirectory.appendingPathComponent("classifier.mlmodel") } - - /// A dictionary of all caps, indexed by their ids - var capDict: [Int : Cap] { - caps.reduce(into: [:]) { $0[$1.id] = $1 } + + private var imageUploadFolderUrl: URL { + Database.documentDirectory.appendingPathComponent("uploads") } - - /// The ids of the caps which weren't included in the last classification - var unmatchedCaps: [Int] { - let query = Cap.table.select(Cap.columnId).filter(Cap.columnMatched == false) - return (try? db.prepare(query).map { $0[Cap.columnId] }) ?? [] + + private var serverDbUrl: URL { + serverUrl.appendingPathComponent("caps.json") } - - /// The number of caps which could be recognized during the last classification - var recognizedCapCount: Int { - (try? db.scalar(Cap.table.filter(Cap.columnMatched == true).count)) ?? 0 + + private var serverClassifierUrl: URL { + serverUrl.appendingPathComponent("classifier.mlmodel") } - - /// The number of caps currently in the database - var capCount: Int { - (try? db.scalar(Cap.table.count)) ?? 0 + + private var serverClassifierVersionUrl: URL { + serverUrl.appendingPathComponent("classifier.version") } - - /// The total number of images for all caps - var imageCount: Int { - (try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.columnCount] }) ?? 0 + + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + let serverUrl: URL + + @AppStorage("authKey") + private var serverAuthenticationKey: String? + + var hasServerAuthentication: Bool { + serverAuthenticationKey != nil } - - var nextPendingCapUpload: Cap? { - do { - guard let row = try db.pluck(Cap.table.filter(Cap.columnUploaded == false).order(Cap.columnId.asc)) else { - return nil - } - return Cap(row: row) - } catch { - log("Failed to get next pending cap upload") - return nil - } + + @Published + private(set) var caps: [Int : Cap] { + didSet { scheduleSave() } } - - var pendingCapUploadCount: Int { - do { - let query = Cap.table.filter(Cap.columnUploaded == false).count - return try db.scalar(query) - } catch { - log("Failed to get pending cap upload count") - return 0 - } + + var nextCapId: Int { + (caps.values.max()?.id ?? 0) + 1 } - - var nextPendingImageUpload: (id: Int, version: Int)? { - do { - guard let row = try db.pluck(upload.table) else { - return nil - } - return (id: row[upload.rowCapId], version: row[upload.rowCapVersion]) - } catch { - log("Failed to get pending image uploads") - return nil - } - } - - var capsWithImages: Set { - capIds.filter { storage.hasImage(for: $0) } - } - - var capsWithThumbnails: Set { - capIds.filter { storage.hasThumbnail(for: $0) } - } - - var pendingImageUploadCount: Int { - ((try? db.scalar(upload.table.count)) ?? 0) - } - - /// The number of caps without a thumbnail on disk - var pendingCapForThumbnailCreation: Int { - caps.reduce(0) { $0 + (storage.hasThumbnail(for: $1.id) ? 0 : 1) } - } - - var pendingCapsForColorCreation: Int { - do { - return try capCount - db.scalar(Colors.table.count) - } catch { - log("Failed to get count of caps without color: \(error)") - return 0 - } - } - - - - var classifierVersion: Int { - set { - UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey) - log("Classifier version set to \(newValue)") - } + + @AppStorage("changed") + private var changedCapStorage: String = "" + + private(set) var changedCaps: Set { get { - UserDefaults.standard.integer(forKey: Classifier.userDefaultsKey) + Set(changedCapStorage.components(separatedBy: ",").compactMap(Int.init)) } - } - - var isInOfflineMode: Bool { set { - UserDefaults.standard.set(newValue, forKey: Upload.offlineKey) - log("Offline mode set to \(newValue)") + changedCapStorage = newValue.map { "\($0)" }.joined(separator: ",") } - get { - UserDefaults.standard.bool(forKey: Upload.offlineKey) - } - } - - // MARK: Data updates - - /** - Create a new cap with an image. - - The cap is inserted into the database, and the name and image will be uploaded to the server. - - - parameter image: The main image of the cap - - parameter name: The name of the cap - - note: Must be called on the main queue. - - note: The registered delegates will be informed about the added cap through `database(didAddCap:)` - - returns: `true`, if the cap was created. - */ - func createCap(image: UIImage, name: String) -> Bool { - let cap = Cap(name: name, id: capCount + 1) - guard insert(cap: cap) else { - log("Cap not inserted") - return false - } - guard storage.save(image: image, for: cap.id) else { - log("Cap image not saved") - return false - } - addPendingUpload(for: cap.id, version: 0) - startBackgroundWork() - return true - } - - /** - Insert a new cap. - - Only inserts the cap into the database, and optionally notifies the delegates. - - note: When a new cap is created, use `createCap(image:name:)` instead - */ - @discardableResult - private func insert(cap: Cap, notify: Bool = true) -> Bool { - do { - try db.run(cap.insertQuery) - if notify { - DispatchQueue.main.async { - self.delegate?.database(didAddCap: cap) - } - } - return true - } catch { - log("Failed to insert cap \(cap.id): \(error)") - return false - } - } - - func add(image: UIImage, for cap: Int) -> Bool { - guard let version = count(for: cap) else { - log("Failed to get count for cap \(cap)") - return false - } - guard storage.save(image: image, for: cap, version: version) else { - log("Failed to save image \(version) for cap \(cap) to disk") - return false - } - guard update(count: version + 1, for: cap) else { - log("Failed update count \(version) for cap \(cap)") - return false - } - guard addPendingUpload(for: cap, version: version) else { - log("Failed to add cap \(cap) version \(version) to upload queue") - return false - } - startBackgroundWork() - return true - } - - // MARK: Updating cap properties - - private func update(_ property: String, for cap: Int, notify: Bool = true, setter: Setter...) -> Bool { - do { - let query = updateQuery(for: cap).update(setter) - try db.run(query) - if notify { - DispatchQueue.main.async { - self.delegate?.database(didChangeCap: cap) - } - } - return true - } catch { - log("Failed to update \(property) for cap \(cap): \(error)") - return false - } - } - - @discardableResult - private func update(uploaded: Bool, for cap: Int) -> Bool { - update("uploaded", for: cap, setter: Cap.columnUploaded <- uploaded) } - @discardableResult - private func update(count: Int, for cap: Int) -> Bool { - update("count", for: cap, setter: Cap.columnCount <- count) + private lazy var imageUploads: [Int: Int] = loadImageUploadCounts() + + private var uploadTimer: Timer? + + /// The classifications for all caps from the classifier + @Published + var matches = [Int : Float]() + + @Published + var image: UIImage? = nil { + didSet { classifyImage() } } - - @discardableResult - private func update(matched: Bool, for cap: Int) -> Bool { - update("matched", for: cap, setter: Cap.columnMatched <- matched) - } - - // MARK: External editing - + + private var classifier: Classifier? + /** - Update the `name` of a cap. + The time to wait for changes to be written to disk. + + This delay is used to prevent file writes for each small update to the caps. */ - @discardableResult - func update(name: String, for cap: Int) -> Bool { - guard update("name", for: cap, setter: Cap.columnName <- name, Cap.columnUploaded <- false) else { - return false - } - startBackgroundWork() - return true + private let saveDelay: TimeInterval = 1 + + /** + The time when a save should occur. + + No save is necessary if this property is `nil`. + */ + private var nextSaveTime: Date? + + let imageCache: URLCache + + init(server: URL) { + self.serverUrl = server + self.caps = [:] + + let cacheDirectory = Database.documentDirectory.appendingPathComponent("images") + self.imageCache = URLCache( + memoryCapacity: Database.imageCacheMemory, + diskCapacity: Database.imageCacheStorage, + directory: cacheDirectory) + loadCaps() + } - - @discardableResult - private func updateWithoutUpload(name: String, for cap: Int) -> Bool { - update("name", for: cap, notify: false, setter: Cap.columnName <- name) - } - - func update(recognizedCaps: Set) { - let unrecognized = self.unmatchedCaps - // Update caps which haven't been recognized before - let newlyRecognized = recognizedCaps.intersection(unrecognized) - let logIndividualMessages = newlyRecognized.count < 10 - if !logIndividualMessages { - log("Marking \(newlyRecognized.count) caps as matched") + + @Published + var isUploading = false + + // MARK: Disk storage + + private func loadCaps() { + guard fm.fileExists(atPath: localDbUrl.path) else { + return } - for cap in newlyRecognized { - if logIndividualMessages { - log("Marking cap \(cap) as matched") - } - update(matched: true, for: cap) - } - // Update caps which are no longer recognized - let missing = Set(1...capCount).subtracting(recognizedCaps).subtracting(unrecognized) - for cap in missing { - log("Marking cap \(cap) as not matched") - update(matched: false, for: cap) - } - } - - // MARK: Uploads - - @discardableResult - private func addPendingUpload(for cap: Int, version: Int) -> Bool { + let data: Data do { - guard try db.scalar(upload.existsQuery(for: cap, version: version)) == 0 else { - return true - } - try db.run(upload.insertQuery(for: cap, version: version)) + data = try Data(contentsOf: localDbUrl) + } catch { + print("Failed to read database file: \(error)") + return + } + do { + let array = try JSONDecoder().decode([Cap].self, from: data) + self.caps = array.reduce(into: [:]) { $0[$1.id] = $1 } + // Prevent immediate save after modifying caps + nextSaveTime = nil + } catch { + print("Failed to decode database file: \(error)") + return + } + } + + private func scheduleSave() { + nextSaveTime = Date.now.addingTimeInterval(saveDelay) + DispatchQueue.main.asyncAfter(deadline: .now() + saveDelay) { + self.performScheduledSave() + } + } + + private func performScheduledSave() { + guard let date = nextSaveTime else { + // No save necessary, or already saved + return + } + guard date < .now else { + // Save pushed to future + return + } + save() + nextSaveTime = nil + } + + private func save() { + let data: Data + do { + data = try encoder.encode(caps.values.sorted()) + } catch { + print("Failed to encode database: \(error)") + return + } + do { + try data.write(to: localDbUrl) + } catch { + print("Failed to save database: \(error)") + } + print("Database saved") + } + + private func ensureFolderExistence(_ url: URL) -> Bool { + guard !fm.fileExists(atPath: url.path) else { + return true + } + do { + try fm.createDirectory(at: url, withIntermediateDirectories: true) return true } catch { - log("Failed to add pending upload of cap \(cap) version \(version): \(error)") + log("Failed to create folder \(url.path): \(error)") return false } } - - @discardableResult - private func removePendingUpload(for cap: Int, version: Int) -> Bool { - do { - try db.run(upload.deleteQuery(for: cap, version: version)) - return true - } catch { - log("Failed to remove pending upload of cap \(cap) version \(version): \(error)") - return false - } - } - - // MARK: Information retrieval - - func cap(for id: Int) -> Cap? { - do { - guard let row = try db.pluck(updateQuery(for: id)) else { - log("No cap with id \(id) in database") - return nil - } - return Cap(row: row) - } catch { - log("Failed to get cap \(id): \(error)") - return nil - } - } - - private func count(for cap: Int) -> Int? { - do { - let row = try db.pluck(updateQuery(for: cap).select(Cap.columnCount)) - return row?[Cap.columnCount] - } catch { - log("Failed to get count for cap \(cap)") - return nil - } - } - - func countOfCaps(withImageCountLessThan limit: Int) -> Int { - do { - return try db.scalar(Cap.table.filter(Cap.columnCount < limit).count) - } catch { - log("Failed to get caps with less than \(limit) images") - return 0 - } - } - - func lowestImageCountForCaps(startingAt start: Int) -> (count: Int, numberOfCaps: Int) { - do { - var currentCount = start - 1 - var capsFound = 0 - repeat { - currentCount += 1 - capsFound = try db.scalar(Cap.table.filter(Cap.columnCount == currentCount).count) - } while capsFound == 0 - - return (currentCount, capsFound) - } catch { - return (0,0) - } - } - - func updateQuery(for cap: Int) -> Table { - Cap.table.filter(Cap.columnId == cap) - } - + // MARK: Downloads - + @discardableResult - func downloadImage(for cap: Int, version: Int = 0, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { - let url = storage.localImageUrl(for: cap, version: version) - return download.image(for: cap, version: version, to: url) { success in - if version == 0 && success { + func downloadCaps() async -> Bool { + print("Downloading cap data") + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(from: serverDbUrl) + } catch { + print("Failed to download classifier version: \(error)") + return false + } + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + return false + } + + let capData: [CapData] + do { + capData = try decoder.decode([CapData].self, from: data) + } catch { + print("Failed to decode server database: \(error)") + return false + } + var inserts = 0 + var updates = 0 + for cap in capData { + guard var oldCap = caps[cap.id] else { + caps[cap.id] = Cap(data: cap) + inserts += 1 + continue + } + guard oldCap != cap else { + continue + } + if changedCaps.contains(oldCap.id) { +#warning("Merge changed caps with server updates") + } else { + oldCap.update(with: cap) + caps[cap.id] = oldCap + updates += 1 + } + } + print("Updated database from server (\(inserts) added, \(updates) updated)") + return true + } + + @discardableResult + func serverHasNewClassifier() async -> Bool { + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(from: serverClassifierVersionUrl) + } catch { + print("Failed to download classifier version: \(error)") + return false + } + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + return false + } + + guard let string = String(data: data, encoding: .utf8) else { + log("Classifier version is invalid data (not a string)") + return false + } + guard let serverVersion = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else { + log("Classifier version has an invalid value '\(string)'") + return false + } + DispatchQueue.main.async { + self.serverClassifierVersion = serverVersion + } + guard serverVersion > self.classifierVersion else { + print("No new classifier available") + return false + } + print("New classifier \(serverVersion) available") + return true + } + + @discardableResult + func downloadClassifier() async -> Bool { + print("Downloading classifier") + let tempUrl: URL + let response: URLResponse + do { + (tempUrl, response) = try await URLSession.shared.download(from: serverClassifierUrl) + } catch { + print("Failed to download classifier version: \(error)") + return false + } + guard (response as? HTTPURLResponse)?.statusCode == 200 else { + return false + } + do { + let url = self.localClassifierUrl + if fm.fileExists(atPath: url.path) { + try self.fm.removeItem(at: url) + } + try self.fm.moveItem(at: tempUrl, to: url) + } catch { + print("Failed to replace classifier: \(error)") + return false + } + DispatchQueue.main.async { + self.classifierVersion = self.serverClassifierVersion + self.classifier = nil + } + print("Downloaded classifier \(classifierVersion)") + return true + } + + /** + Indicate that the cap has pending operations, such as determining the color or a thumbnail + */ + func hasPendingOperations(for cap: Int) -> Bool { + return false + } + + // MARK: Adding new data + + func save(newCap name: String) -> Cap { + let cap = Cap(id: nextCapId, name: name, classifier: serverClassifierVersion) + caps[cap.id] = cap + #warning("Upload new cap") + return cap + } + + @discardableResult + func save(_ image: UIImage, for capId: Int) -> Bool { + guard caps[capId] != nil else { + log("Failed to save image for missing cap \(capId)") + return false + } + guard ensureFolderExistence(imageUploadFolderUrl) else { + return false + } + guard let data = image.jpegData(compressionQuality: imageCompressionQuality) else { + log("Failed to compress image for cap: \(capId)") + return false + } + let hash = Data(SHA256.hash(data: data)).hexEncoded.prefix(16) + let url = imageUploadFolderUrl.appendingPathComponent("\(capId)-\(hash).jpg") + do { + try data.write(to: url) + } catch { + log("Failed to save \(url.lastPathComponent): \(error)") + return false + } + log("Saved \(url.lastPathComponent) for upload") + caps[capId]?.imageCount += 1 + updateImageUploadCounts() + return true + } + + private func updateImageUploadCounts() { + + } + + private func loadImageUploadCounts() -> [Int : Int] { + var result = [Int : Int]() + pendingImageUploads.forEach { url in + guard let capId = capId(from: url) else { + return + } + if let old = result[capId] { + result[capId] = old + 1 + } else { + result[capId] = 1 + } + } + return result + } + + // MARK: Uploads + + func startRegularUploads() { + guard uploadTimer != nil else { + return + } + DispatchQueue.main.async { + self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: self.uploadTimerElapsed) + } + } + + private func uploadTimerElapsed(timer: Timer) { + Task { + await uploadAll() + } + } + + private func uploadAll() async { + guard !isUploading else { + return + } + DispatchQueue.main.async { + self.isUploading = true + } + await uploadAllChangedCaps() + await uploadAllImages() + DispatchQueue.main.async { + self.isUploading = false + } + } + + /** + Indicate that the cap has pending uploads, either changes or images + */ + func hasPendingUpdates(for cap: Int) -> Bool { + changedCaps.contains(cap) || imageUploads[cap] != nil + } + + private var pendingImageUploads: [URL] { + (try? fm.contentsOfDirectory(at: imageUploadFolderUrl, includingPropertiesForKeys: nil)) ?? [] + } + + var pendingImageUploadCount: Int { + pendingImageUploads.count + } + + private func capId(from url: URL) -> Int? { + Int(url.lastPathComponent.components(separatedBy: "-").first!) + } + + private func uploadAllImages() async { + guard hasServerAuthentication else { + log("No server authentication to upload to server") + return + } + updateImageUploadCounts() + for url in pendingImageUploads { + guard let capId = capId(from: url) else { + log("Unexpected image \(url.lastPathComponent) in upload folder") + continue + } + guard await upload(imageAt: url, for: capId) else { + continue + } + do { + try fm.removeItem(at: url) + updateImageUploadCounts() + } catch { + log("Failed to remove uploaded image \(url.lastPathComponent): \(error)") + } + } + } + + @discardableResult + private func upload(imageAt url: URL, for cap: Int) async -> Bool { + guard let key = serverAuthenticationKey else { + return false + } + let url = serverUrl + .appendingPathComponent("images") + .appendingPathComponent("\(cap)?key=\(key)") + var request = URLRequest(url: url) + request.httpMethod = "POST" + do { + let (_, response) = try await URLSession.shared.upload(for: request, fromFile: url) + guard let httpResponse = response as? HTTPURLResponse else { + log("Unexpected response for upload of image \(url.lastPathComponent): \(response)") + return false + } + guard httpResponse.statusCode == 200 else { + log("Failed to upload image \(url.lastPathComponent): Response \(httpResponse.statusCode)") + return false + } + return true + } catch { + log("Failed to upload image \(url.lastPathComponent): \(error)") + return false + } + } + + var pendingCapUploadCount: Int { + changedCaps.count + } + + private func uploadAllChangedCaps() async { + guard hasServerAuthentication else { + log("No server authentication to upload to server") + return + } + var uploaded = Set() + for capId in changedCaps { + guard let cap = caps[capId] else { + uploaded.insert(capId) + continue + } + guard await upload(cap: cap) else { + continue + } + uploaded.insert(capId) + } + changedCaps.subtract(uploaded) + } + + @discardableResult + private func upload(cap: Cap) async -> Bool { + guard let key = serverAuthenticationKey else { + return false + } + let data: Data + do { + /// `Cap` and `CapData` have equivalent JSON layout + data = try encoder.encode(cap) + } catch { + log("Failed to encode cap \(cap.id) for upload: \(error)") + return false + } + let url = serverUrl + .appendingPathComponent("images") + .appendingPathComponent("\(cap)?key=\(key)") + var request = URLRequest(url: url) + request.httpMethod = "POST" + do { + let (_, response) = try await URLSession.shared.upload(for: request, from: data) + guard let httpResponse = response as? HTTPURLResponse else { + log("Unexpected response for upload of cap \(cap.id): \(response)") + return false + } + guard httpResponse.statusCode == 200 else { + log("Failed to upload cap \(cap.id): Response \(httpResponse.statusCode)") + return false + } + changedCaps.remove(cap.id) + return true + } catch { + log("Failed to upload cap \(cap.id): \(error)") + return false + } + } + + // MARK: Classification + + /// The compiled recognition model on disk + private var recognitionModel: VNCoreMLModel? { + guard fm.fileExists(atPath: localClassifierUrl.path) else { + log("No recognition model to load from disk") + return nil + } + do { + log("Loading model from disk") + let newUrl = try MLModel.compileModel(at: localClassifierUrl) + let model = try MLModel(contentsOf: newUrl) + return try VNCoreMLModel(for: model) + } catch { + log("Failed to load recognition model: \(error)") + return nil + } + } + + private func classifyImage() { + guard let image = image?.cgImage else { + matches.removeAll() + log("Image removed") + return + } + DispatchQueue.global().async { + guard let classifier = self.getClassifier() else { + return + } + log("Image classification started") + classifier.recognize(image: image) { matches in DispatchQueue.main.async { - self.delegate?.database(didLoadImageForCap: cap) - } - } - let image = self.storage.image(for: cap, version: version) - completion(image) - } - } - - private func update(names: [String]) { - let notify = capCount > 0 - log("Downloaded cap names (initialDownload: \(!notify))") - let caps = self.capDict - let changed: [Int] = names.enumerated().compactMap { id, name in - let id = id + 1 - guard let existingName = caps[id]?.name else { - // Insert cap - let cap = Cap(id: id, name: name, count: 0) - guard insert(cap: cap, notify: notify) else { - return nil - } - return id - } - guard existingName != name else { - // Name unchanged - return nil - } - guard updateWithoutUpload(name: name, for: id) else { - return nil - } - return id - } - if !notify { - log("Added \(changed.count) new caps after initial download") - delegate?.databaseNeedsFullRefresh() - } - } - - var isDoingWorkInBackgound: Bool { - backgroundTaskStatus != nil - } - - private var didUpdateBackgroundItems = false - private var backgroundTaskStatus: BackgroundWorkTaskType? = nil - private var expectedBackgroundWorkStatus: BackgroundWorkTaskType? = nil - - private var nextBackgroundWorkStatus: BackgroundWorkTaskType? { - guard let oldType = backgroundTaskStatus else { - return expectedBackgroundWorkStatus - } - guard let type = expectedBackgroundWorkStatus else { - return backgroundTaskStatus?.nextType - } - guard oldType > type else { - return type - } - return oldType.nextType - } - - private func setNextBackgroundWorkStatus() -> BackgroundWorkTaskType? { - backgroundTaskStatus = nextBackgroundWorkStatus - expectedBackgroundWorkStatus = nil - return backgroundTaskStatus - } - - private let context = CIContext(options: [.workingColorSpace: kCFNull!]) - - - func startInitialDownload() { - startBackgroundWork(startingWith: .downloadCapNames) - } - - func scheduleClassifierDownload() { - startBackgroundWork(startingWith: .downloadClassifier) - } - - func startBackgroundWork() { - startBackgroundWork(startingWith: .uploadingCaps) - } - - private func startBackgroundWork(startingWith type: BackgroundWorkTaskType) { - guard !isDoingWorkInBackgound else { - if expectedBackgroundWorkStatus?.rawValue ?? 0 < type.rawValue { - log("Background work scheduled: \(type)") - expectedBackgroundWorkStatus = type - } - return - } - DispatchQueue.global(qos: .utility).async { - self.performAllBackgroundWorkItems(allItemsStartingAt: type) - } - } - - private func performAllBackgroundWorkItems(allItemsStartingAt type: BackgroundWorkTaskType) { - didUpdateBackgroundItems = false - expectedBackgroundWorkStatus = type - log("Starting background task") - while let type = setNextBackgroundWorkStatus() { - log("Handling background task: \(type)") - guard performAllItems(for: type) else { - // If an error occurs, stop the background tasks - backgroundTaskStatus = nil - expectedBackgroundWorkStatus = nil - break - } - } - log("Background work completed") - delegate?.databaseDidFinishBackgroundWork() - } - - private func performAllItems(for type: BackgroundWorkTaskType) -> Bool { - switch type { - case .downloadCapNames: - return downloadCapNames() - case .downloadCounts: - return downloadImageCounts() - case .downloadClassifier: - return downloadClassifier() - case .uploadingCaps: - return uploadCaps() - case .uploadingImages: - return uploadImages() - case .downloadMainImages: - return downloadMainImages() - case .creatingThumbnails: - return createThumbnails() - case .creatingColors: - return createColors() - } - } - - private func downloadCapNames() -> Bool { - log("Downloading cap names") - let result = DispatchGroup.singleTask { callback in - download.names { names in - guard let names = names else { - callback(false) - return - } - self.update(names: names) - callback(true) - } - } - log("Completed download of cap names") - return result - } - - private func downloadImageCounts() -> Bool { - log("Downloading cap image counts") - let result = DispatchGroup.singleTask { callback in - download.imageCounts { counts in - guard let counts = counts else { - self.log("Failed to download server image counts") - callback(false) - return - } - let newCaps = self.didDownload(imageCounts: counts) - - guard newCaps.count > 0 else { - callback(true) - return - } - self.log("Found \(newCaps.count) new caps on the server.") - self.downloadInfo(for: newCaps) { success in - callback(success) + self.matches = matches ?? [:] } } } - guard result else { - log("Failed download of cap image counts") - return false - } - log("Completed download of cap image counts") - return true } - - private func downloadClassifier() -> Bool { - log("Downloading classifier (if needed)") - let result = DispatchGroup.singleTask { callback in - download.classifierVersion { version in - guard let version = version else { - self.log("Failed to download server model version") - callback(false) - return - } - let ownVersion = self.classifierVersion - guard ownVersion < version else { - self.log("Not updating classifier: Own version \(ownVersion), server version \(version)") - callback(true) - return - } - let title = "Download classifier" - let detail = ownVersion == 0 ? - "A classifier to match caps is available for download (version \(version)). Would you like to download it now?" : - "Version \(version) of the classifier is available for download (You have version \(ownVersion)). Would you like to download it now?" - self.delegate!.database(needsUserConfirmation: title, body: detail) { proceed in - guard proceed else { - self.log("User skipped classifier download") - callback(true) - return - } - self.download.classifier { progress, received, total in - let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) - let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) - let title = String(format: "%.0f", progress * 100) + " % (\(r) / \(t))" - self.delegate?.database(completedBackgroundWorkItem: "Downloading classifier", subtitle: title) - } completion: { url in - guard let url = url else { - self.log("Failed to download classifier") - callback(false) - return - } - let compiledUrl: URL - do { - compiledUrl = try MLModel.compileModel(at: url) - } catch { - self.log("Failed to compile downloaded classifier: \(error)") - callback(false) - return - } - - guard self.storage.save(recognitionModelAt: compiledUrl) else { - self.log("Failed to save compiled classifier") - callback(false) - return - } - callback(true) - self.classifierVersion = version - } - } - } + + private func getClassifier() -> Classifier? { + if let classifier = classifier { + return classifier } - log("Downloaded classifier (if new version existed)") - return result + guard let model = recognitionModel else { + return nil + } + return Classifier(model: model) } - - private func uploadCaps() -> Bool { - var completed = 0 - while let cap = nextPendingCapUpload { - guard upload.upload(cap) else { - delegate?.database(didFailBackgroundWork: "Upload failed", - subtitle: "Cap \(cap.id) not uploaded") - return false - } - update(uploaded: true, for: cap.id) - completed += 1 - let total = completed + pendingCapUploadCount - delegate?.database(completedBackgroundWorkItem: "Uploading caps", subtitle: "\(completed) of \(total)") - } - return true + + // MARK: Statistics + + var numberOfCaps: Int { + caps.count } - - private func uploadImages() -> Bool { - var completed = 0 - while let (id, version) = nextPendingImageUpload { - guard let cap = self.cap(for: id) else { - log("No cap \(id) to upload image \(version)") - removePendingUpload(for: id, version: version) - continue - } - guard let url = storage.existingImageUrl(for: cap.id, version: version) else { - log("No image \(version) of cap \(id) to upload") - removePendingUpload(for: id, version: version) - continue - } - guard let count = upload.upload(imageAt: url, of: cap.id) else { - delegate?.database(didFailBackgroundWork: "Upload failed", subtitle: "Image \(version) of cap \(id)") - return false - } - if count > cap.count { - update(count: count, for: cap.id) - } - removePendingUpload(for: id, version: version) - - completed += 1 - let total = completed + pendingImageUploadCount - delegate?.database(completedBackgroundWorkItem: "Uploading images", subtitle: "\(completed + 1) of \(total)") - } - return true + + var numberOfImages: Int { + caps.values.reduce(0) { $0 + $1.imageCount } } - - private func downloadMainImages() -> Bool { - let missing = caps.map { $0.id }.filter { !storage.hasImage(for: $0) } - let count = missing.count - guard count > 0 else { - log("No images to download") - return true - } - log("Starting image downloads") - - let group = DispatchGroup() - group.enter() - - var shouldDownload = true - let title = "Download images" - let detail = "\(count) caps have no image. Would you like to download them now? (~ \(ByteCountFormatter.string(fromByteCount: Int64(count * 10000), countStyle: .file))). Grid view is not available until all images are downloaded." - delegate?.database(needsUserConfirmation: title, body: detail) { proceed in - shouldDownload = proceed - group.leave() - } - group.wait() - guard shouldDownload else { - log("User skipped image download") - return false - } - - group.enter() - let queue = DispatchQueue(label: "images") - let semaphore = DispatchSemaphore(value: 5) - - var downloadsAreSuccessful = true - var completed = 0 - for cap in missing { - queue.async { - guard downloadsAreSuccessful else { - return - } - semaphore.wait() - let url = self.storage.localImageUrl(for: cap) - self.download.image(for: cap, to: url, queue: queue) { success in - defer { semaphore.signal() } - guard success else { - self.delegate?.database(didFailBackgroundWork: "Download failed", subtitle: "Image of cap \(cap)") - downloadsAreSuccessful = false - group.leave() - return - } - completed += 1 - self.delegate?.database(completedBackgroundWorkItem: "Downloading images", subtitle: "\(completed) of \(missing.count)") - if completed == missing.count { - group.leave() - } - } - } - } - guard group.wait(timeout: .now() + TimeInterval(missing.count * 2)) == .success else { - log("Timed out downloading images") - return false - } - log("Finished all image downloads") - return true + + var averageImageCount: Float { + Float(numberOfImages) / Float(numberOfCaps) } - - private func createThumbnails() -> Bool { - let missing = caps.map { $0.id }.filter { !storage.hasThumbnail(for: $0) } - guard missing.count > 0 else { - log("No thumbnails to create") - return true - } - log("Creating thumbnails") - let queue = DispatchQueue(label: "thumbnails") - let semaphore = DispatchSemaphore(value: 5) - - let group = DispatchGroup() - group.enter() - var thumbnailsAreSuccessful = true - var completed = 0 - for cap in missing { - queue.async { - guard thumbnailsAreSuccessful else { - return - } - semaphore.wait() - defer { semaphore.signal() } - guard let image = self.storage.image(for: cap) else { - self.log("No image for cap \(cap) to create thumbnail") - self.delegate?.database(didFailBackgroundWork: "Creation failed", subtitle: "Thumbnail of cap \(cap)") - thumbnailsAreSuccessful = false - group.leave() - return - } - let thumb = Cap.thumbnail(for: image) - guard self.storage.save(thumbnail: thumb, for: cap) else { - self.log("Failed to save thumbnail for cap \(cap)") - self.delegate?.database(didFailBackgroundWork: "Image not saved", subtitle: "Thumbnail of cap \(cap)") - thumbnailsAreSuccessful = false - group.leave() - return - } - completed += 1 - self.delegate?.database(completedBackgroundWorkItem: "Creating thumbnails", subtitle: "\(completed) of \(missing.count)") - if completed == missing.count { - group.leave() - } - } - } - guard group.wait(timeout: .now() + TimeInterval(missing.count * 2)) == .success else { - log("Timed out creating thumbnails") - return false - } - log("Finished all thumbnails") - return true + + @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 } - - private func createColors() -> Bool { - let missing = capIds.subtracting(capsWithColors) - guard missing.count > 0 else { - log("No colors to create") - return true - } - log("Creating colors") - let queue = DispatchQueue(label: "colors") - let semaphore = DispatchSemaphore(value: 5) - - let group = DispatchGroup() - group.enter() - var colorsAreSuccessful = true - var completed = 0 - for cap in missing { - queue.async { - guard colorsAreSuccessful else { - return - } - semaphore.wait() - defer { semaphore.signal() } - guard let image = self.storage.ciImage(for: cap) else { - self.log("No image for cap \(cap) to create color") - self.delegate?.database(didFailBackgroundWork: "No thumbnail found", subtitle: "Color of cap \(cap)") - colorsAreSuccessful = false - group.leave() - return - } - defer { self.context.clearCaches() } - guard let color = image.averageColor(context: self.context) else { - self.log("Failed to create color for cap \(cap)") - self.delegate?.database(didFailBackgroundWork: "Calculation failed", subtitle: "Color of cap \(cap)") - colorsAreSuccessful = false - group.leave() - return - } - guard self.set(color: color, for: cap) else { - self.log("Failed to save color for cap \(cap)") - self.delegate?.database(didFailBackgroundWork: "Color not saved", subtitle: "Color of cap \(cap)") - colorsAreSuccessful = false - group.leave() - return - } - completed += 1 - self.delegate?.database(completedBackgroundWorkItem: "Creating colors", subtitle: "\(completed) of \(missing.count)") - if completed == missing.count { - group.leave() - } - } - } - guard group.wait(timeout: .now() + TimeInterval(missing.count * 2)) == .success else { - log("Timed out creating colors") - return false - } - log("Finished all colors") - return true + + var imageCacheSize: Int { + imageCache.currentDiskUsage } - - func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) { - download.classifierVersion { version in - guard let version = version else { - self.log("Failed to download server model version") - completion(nil, nil) - return - } - let ownVersion = self.classifierVersion - guard ownVersion < version else { - self.log("Not updating classifier: Own version \(ownVersion), server version \(version)") - completion(nil, nil) - return - } - self.log("Getting size of classifier \(version)") - self.download.classifierSize { size in - completion(version, size) - } - } + + var databaseSize: Int { + localDbUrl.fileSize } - - private func didDownload(imageCounts newCounts: [Int]) -> [Int : Int] { - let capsCounts = capDict - if newCounts.count != capsCounts.count { - log("Downloaded \(newCounts.count) image counts, but \(capsCounts.count) caps stored locally") - } - var newCaps = [Int : Int]() - let changed = newCounts.enumerated().compactMap { id, newCount -> Int? in - let id = id + 1 - guard let oldCount = capsCounts[id]?.count else { - log("Received count \(newCount) for unknown cap \(id)") - newCaps[id] = newCount - return nil - } - guard oldCount != newCount else { - return nil - } - self.update(count: newCount, for: id) - return id - } - switch changed.count { - case 0: - log("Refreshed image counts for all caps without changes") - case 1: - log("Refreshed image counts for caps, changed cap \(changed[0])") - case 2...10: - log("Refreshed image counts for caps \(changed.map(String.init).joined(separator: ", ")).") - default: - log("Refreshed image counts for all caps (\(changed.count) changed)") - } - - return newCaps - } - - private func downloadInfo(for newCaps: [Int : Int], completion: @escaping (_ success: Bool) -> Void) { - var success = true - let group = DispatchGroup() - for (id, count) in newCaps { - group.enter() - download.name(for: id) { name in - guard let name = name else { - self.log("Failed to get name for new cap \(id)") - success = false - group.leave() - return - } - let cap = Cap(id: id, name: name, count: count) - self.insert(cap: cap) - group.leave() - } - } - - if group.wait(timeout: .now() + .seconds(30)) != .success { - self.log("Timed out waiting for images to be downloaded") - } - completion(success) - } - - func downloadImageCount(for cap: Int) { - download.imageCount(for: cap) { count in - guard let count = count else { - return - } - self.update(count: count, for: cap) - } - } - - func setMainImage(of cap: Int, to version: Int) { - guard version != 0 else { - log("No need to switch main image with itself for cap \(cap)") - return - } - upload.setMainImage(for: cap, to: version) { success in - guard success else { - self.log("Could not make \(version) the main image for cap \(cap)") - return - } - guard self.storage.switchMainImage(to: version, for: cap) else { - self.log("Could not switch \(version) to main image for cap \(cap)") - return - } - DispatchQueue.main.async { - self.delegate?.database(didLoadImageForCap: cap) - } - - } + + var classifierSize: Int { + localClassifierUrl.fileSize } } -extension Database: Logger { } +extension Database { + + static var mock: Database { + let db = Database(server: URL(string: "https://christophhagen.de/caps")!) + db.caps = [ + Cap(id: 123, name: "My new cap"), + Cap(id: 234, name: "My favorite cap"), + Cap(id: 345, name: "My oldest cap"), + Cap(id: 456, name: "My new cap"), + Cap(id: 567, name: "My favorite cap"), + Cap(id: 678, name: "My oldest cap"), + ].reduce(into: [:]) { $0[$1.id] = $1 } + db.image = UIImage(systemSymbol: .photo) + return db + } +} diff --git a/Caps/Data/Download.swift b/Caps/Data/Download.swift deleted file mode 100644 index a961c1f..0000000 --- a/Caps/Data/Download.swift +++ /dev/null @@ -1,358 +0,0 @@ -// -// Download.swift -// CapCollector -// -// Created by Christoph on 26.04.20. -// Copyright © 2020 CH. All rights reserved. -// - -import Foundation -import UIKit - -final class Download { - - let serverUrl: URL - - let session: URLSession - - let delegate: Delegate - - private var downloadingMainImages = Set() - - init(server: URL) { - let delegate = Delegate() - - self.serverUrl = server - self.session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) - self.delegate = delegate - } - - // MARK: Paths - - var serverNameListUrl: URL { - Download.serverNameListUrl(server: serverUrl) - } - - private static func serverNameListUrl(server: URL) -> URL { - server.appendingPathComponent("names.txt") - } - - private var serverClassifierVersionUrl: URL { - serverUrl.appendingPathComponent("classifier.version") - } - - private var serverRecognitionModelUrl: URL { - serverUrl.appendingPathComponent("classifier.mlmodel") - } - - private var serverAllCountsUrl: URL { - serverUrl.appendingPathComponent("counts") - } - - var serverImageUrl: URL { - serverUrl.appendingPathComponent("images") - } - - private func serverImageUrl(for cap: Int, version: Int = 0) -> URL { - serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version)) - } - - private func serverNameUrl(for cap: Int) -> URL { - serverUrl.appendingPathComponent("name/\(cap)") - } - - private func serverImageCountUrl(for cap: Int) -> URL { - serverUrl.appendingPathComponent("count/\(cap)") - } - - // MARK: Delegate - - final class Delegate: NSObject, URLSessionDownloadDelegate { - - typealias ProgressHandler = (_ progress: Float, _ bytesWritten: Int64, _ totalBytes: Int64) -> Void - - typealias CompletionHandler = (_ url: URL?) -> Void - - private var progress = [URLSessionDownloadTask : Float]() - - private var callbacks = [URLSessionDownloadTask : ProgressHandler]() - - private var completions = [URLSessionDownloadTask : CompletionHandler]() - - func registerForProgress(_ downloadTask: URLSessionDownloadTask, callback: ProgressHandler?, completion: @escaping CompletionHandler) { - progress[downloadTask] = 0 - callbacks[downloadTask] = callback - completions[downloadTask] = completion - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - completions[downloadTask]?(location) - callbacks[downloadTask] = nil - progress[downloadTask] = nil - completions[downloadTask] = nil - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - let ratio = totalBytesExpectedToWrite > 0 ? Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) : 0 - progress[downloadTask] = ratio - callbacks[downloadTask]?(ratio, totalBytesWritten, totalBytesExpectedToWrite) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let downloadTask = task as? URLSessionDownloadTask else { - return - } - completions[downloadTask]?(nil) - callbacks[downloadTask] = nil - progress[downloadTask] = nil - completions[downloadTask] = nil - } - } - - // MARK: Downloading data - - func image(for cap: Int, to url: URL, timeout: TimeInterval = 30) -> Bool { - let group = DispatchGroup() - group.enter() - var result = true - let success = image(for: cap, version: 0, to: url) { success in - result = success - group.leave() - } - guard success else { - log("Already downloading image for cap \(cap)") - return false - } - guard group.wait(timeout: .now() + timeout) == .success else { - log("Timed out downloading image for cap \(cap)") - return false - } - return result - } - - /** - Download an image for a cap. - - Parameter cap: The id of the cap. - - Parameter version: The image version to download. - - Parameter completion: A closure with the resulting image - - Returns: `true`, of the file download was started, `false`, if the image is already downloading. - */ - @discardableResult - func image(for cap: Int, version: Int = 0, to url: URL, queue: DispatchQueue = .main, completion: @escaping (Bool) -> Void) -> Bool { - // Check if main image, and already being downloaded - if version == 0 { - guard !downloadingMainImages.contains(cap) else { - return false - } - downloadingMainImages.insert(cap) - } - let serverUrl = serverImageUrl(for: cap, version: version) - let query = "Image of cap \(cap) version \(version)" - let task = session.downloadTask(with: serverUrl) { fileUrl, response, error in - if version == 0 { - queue.async { - self.downloadingMainImages.remove(cap) - } - } - guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { - completion(false) - return - } - do { - if FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(at: url) - } - try FileManager.default.moveItem(at: fileUrl, to: url) - } catch { - self.log("Failed to move downloaded image for cap \(cap): \(error)") - completion(false) - } - completion(true) - } - task.resume() - return true - } - - func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) { - let url = serverImageCountUrl(for: cap) - let query = "Image count for cap \(cap)" - session.startTaskExpectingInt(with: url, query: query, completion: completion) - } - - func name(for cap: Int, completion: @escaping (_ name: String?) -> Void) { - let url = serverNameUrl(for: cap) - let query = "Name for cap \(cap)" - session.startTaskExpectingString(with: url, query: query, completion: completion) - } - - func imageCounts(completion: @escaping ([Int]?) -> Void) { - let query = "Image count of all caps" - session.startTaskExpectingData(with: serverAllCountsUrl, query: query) { data in - guard let data = data else { - completion(nil) - return - } - completion(data.map(Int.init)) - } - } - - func names(completion: @escaping ([String]?) -> Void) { - let query = "Download of server database" - session.startTaskExpectingString(with: serverNameListUrl, query: query) { string in - completion(string?.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n")) - } - } - - func databaseSize(completion: @escaping (_ size: Int64?) -> Void) { - size(of: "database size", to: serverNameListUrl, completion: completion) - } - - func classifierVersion(completion: @escaping (Int?) -> Void) { - let query = "Server classifier version" - session.startTaskExpectingInt(with: serverClassifierVersionUrl, query: query, completion: completion) - } - - func classifierSize(completion: @escaping (Int64?) -> Void) { - size(of: "classifier size", to: serverRecognitionModelUrl, completion: completion) - } - - func classifier(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) { - let task = session.downloadTask(with: serverRecognitionModelUrl) - delegate.registerForProgress(task, callback: progress) { url in - self.log("Classifier download complete") - completion(url) - } - task.resume() - } - - // MARK: Requests - - private func size(of query: String, to url: URL, completion: @escaping (_ size: Int64?) -> Void) { - var request = URLRequest(url: url) - request.httpMethod = "HEAD" - let task = session.dataTask(with: request) { _, response, _ in - guard let r = response else { - self.log("Request '\(query)' received no response") - completion(nil) - return - } - completion(r.expectedContentLength) - } - task.resume() - } - - private func convertIntResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> Int? { - guard let string = self.convertStringResponse(to: query, data, response, error) else { - return nil - } - guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else { - self.log("Request '\(query)' received an invalid value '\(string)'") - return nil - } - return int - } - - private func convertStringResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> String? { - guard let data = self.convertResponse(to: query, data, response, error) else { - return nil - } - guard let string = String(data: data, encoding: .utf8) else { - self.log("Request '\(query)' received invalid data (not a string)") - return nil - } - return string - } - - private func convertResponse(to query: String, _ result: T?, _ response: URLResponse?, _ error: Error?) -> T? { - if let error = error { - log("Request '\(query)' produced an error: \(error)") - return nil - } - guard let response = response else { - log("Request '\(query)' received no response") - return nil - } - guard let urlResponse = response as? HTTPURLResponse else { - log("Request '\(query)' received an invalid response: \(response)") - return nil - } - guard urlResponse.statusCode == 200 else { - log("Request '\(query)' failed with status code \(urlResponse.statusCode)") - return nil - } - guard let r = result else { - log("Request '\(query)' received no item") - return nil - } - return r - } - - -} - -extension Download: Logger { } - -extension URLSession { - - func startTaskExpectingData(with url: URL, query: String, completion: @escaping (Data?) -> Void) { - let task = dataTask(with: url) { data, response, error in - if let error = error { - log("Request '\(query)' produced an error: \(error)") - completion(nil) - return - } - guard let response = response else { - log("Request '\(query)' received no response") - completion(nil) - return - } - guard let urlResponse = response as? HTTPURLResponse else { - log("Request '\(query)' received an invalid response: \(response)") - completion(nil) - return - } - guard urlResponse.statusCode == 200 else { - log("Request '\(query)' failed with status code \(urlResponse.statusCode)") - completion(nil) - return - } - guard let d = data else { - log("Request '\(query)' received no data") - completion(nil) - return - } - completion(d) - } - task.resume() - } - - func startTaskExpectingString(with url: URL, query: String, completion: @escaping (String?) -> Void) { - startTaskExpectingData(with: url, query: query) { data in - guard let data = data else { - completion(nil) - return - } - guard let string = String(data: data, encoding: .utf8) else { - log("Request '\(query)' received invalid data (not a string)") - completion(nil) - return - } - completion(string) - } - } - - func startTaskExpectingInt(with url: URL, query: String, completion: @escaping (Int?) -> Void) { - startTaskExpectingString(with: url, query: query) { string in - guard let string = string else { - completion(nil) - return - } - guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else { - log("Request '\(query)' received an invalid value '\(string)'") - completion(nil) - return - } - completion(int) - } - } -} diff --git a/Caps/Data/SortCriteria.swift b/Caps/Data/SortCriteria.swift new file mode 100644 index 0000000..214f524 --- /dev/null +++ b/Caps/Data/SortCriteria.swift @@ -0,0 +1,28 @@ +import Foundation + +enum SortCriteria: Int, CaseIterable { + case id = 0 + case name = 1 + case count = 2 + case match = 3 + + var text: String { + switch self { + case .id: + return "Id" + case .name: + return "Name" + case .count: + return "Count" + case .match: + return "Match" + } + } +} + +extension SortCriteria: Identifiable { + + var id: Int { + rawValue + } +} diff --git a/Caps/Data/Storage.swift b/Caps/Data/Storage.swift deleted file mode 100644 index 2ca0371..0000000 --- a/Caps/Data/Storage.swift +++ /dev/null @@ -1,374 +0,0 @@ -// -// DiskManager.swift -// CapFinder -// -// Created by User on 23.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import UIKit -import CoreML -import Vision - - - -protocol ImageProvider: AnyObject { - - func image(for cap: Int) -> UIImage? - - func image(for cap: Int, version: Int) -> UIImage? - - func ciImage(for cap: Int) -> CIImage? -} - -protocol ThumbnailCreationDelegate { - - func thumbnailCreation(progress: Int, total: Int) - - func thumbnailCreationFailed() - - func thumbnailCreationIsMissingImages() - - func thumbnailCreationCompleted() -} - - -final class Storage: ImageProvider { - - // MARK: Paths - - let fm = FileManager.default - - let baseUrl: URL - - // MARK: INIT - - init(in folder: URL) { - self.baseUrl = folder - } - - // MARK: File/folder urls - - var dbUrl: URL { - baseUrl.appendingPathComponent("db.sqlite3") - } - - var modelUrl: URL { - baseUrl.appendingPathComponent("model.mlmodel") - } - - func localImageUrl(for cap: Int, version: Int = 0) -> URL { - baseUrl.appendingPathComponent("\(cap)-\(version).jpg") - } - - private func thumbnailUrl(for cap: Int) -> URL { - baseUrl.appendingPathComponent("\(cap)-thumb.jpg") - } - - private func tileImageUrl(for image: String) -> URL { - baseUrl.appendingPathComponent(image.clean + ".tile") - } - - // MARK: Storage - - /** - Save an image to disk - - parameter url: The url where the downloaded image is stored - - parameter cap: The cap id - - parameter version: The version of the image to get - - returns: True, if the image was saved - */ - func saveImage(at url: URL, for cap: Int, version: Int = 0) -> UIImage? { - let targetUrl = localImageUrl(for: cap, version: version) - do { - if fm.fileExists(atPath: targetUrl.path) { - try fm.removeItem(at: targetUrl) - } - try fm.moveItem(at: url, to: targetUrl) - return UIImage(contentsOfFile: targetUrl.path) - } catch { - log("Failed to delete or move image \(version) for cap \(cap)") - return nil - } - } - - /** - Save an image to disk - - parameter image: The image - - parameter cap: The cap id - - parameter version: The version of the image - - returns: True, if the image was saved - */ - func save(image: UIImage, for cap: Int, version: Int = 0) -> Bool { - guard let data = image.jpegData(compressionQuality: Cap.jpgQuality) else { - return false - } - return save(imageData: data, for: cap, version: version) - } - - /** - Save image data to disk - - parameter image: The data of the image - - parameter cap: The cap id - - parameter version: The version of the image - - returns: True, if the image was saved - */ - func save(imageData: Data, for cap: Int, version: Int = 0) -> Bool { - write(imageData, to: localImageUrl(for: cap, version: version)) - } - - /** - Save a thumbnail. - - parameter thumbnail: The image - - parameter cap: The cap id - - returns: True, if the image was saved - */ - func save(thumbnail: UIImage, for cap: Int) -> Bool { - guard let data = thumbnail.jpegData(compressionQuality: Cap.jpgQuality) else { - return false - } - return save(thumbnailData: data, for: cap) - } - - /** - Save a thumbnail to the download folder - - parameter thumbnailData: The data of the image - - parameter cap: The cap id - - returns: True, if the image was saved - */ - private func save(thumbnailData: Data, for cap: Int) -> Bool { - write(thumbnailData, to: thumbnailUrl(for: cap)) - } - - /** - Save the downloaded and compiled recognition model. - - Parameter url: The temporary location to which the model was compiled. - - Returns: `true`, if the model was moved. - */ - func save(recognitionModelAt url: URL) -> Bool { - move(url, to: modelUrl) - } - - /** - Save the downloaded and database. - - Parameter url: The temporary location to which the database was downloaded. - - Returns: `true`, if the database was moved. - */ - func save(databaseAt url: URL) -> Bool { - move(url, to: dbUrl) - } - - private func move(_ url: URL, to destination: URL) -> Bool { - if fm.fileExists(atPath: destination.path) { - do { - try fm.removeItem(at: destination) - } catch { - log("Failed to remove file \(destination.lastPathComponent) before writing new version: \(error)") - return false - } - } - do { - try fm.moveItem(at: url, to: destination) - return true - } catch { - self.error("Failed to move file \(destination.lastPathComponent) to permanent location: \(error)") - return false - } - } - - private func write(_ data: Data, to url: URL) -> Bool { - do { - try data.write(to: url) - } catch { - self.error("Could not write data to \(url): \(error)") - return false - } - return true - } - - // MARK: High-level functions - - func switchMainImage(to version: Int, for cap: Int) -> Bool { - guard deleteThumbnail(for: cap) else { - return false - } - let newImagePath = localImageUrl(for: cap, version: version) - guard fm.fileExists(atPath: newImagePath.path) else { - return deleteImage(for: cap, version: version) - } - let oldImagePath = localImageUrl(for: cap, version: 0) - return move(newImagePath, to: oldImagePath) - } - - // MARK: Status - - /** - Check if an image exists for a cap - - parameter cap: The id of the cap - - returns: True, if an image exists - */ - func hasImage(for cap: Int) -> Bool { - fm.fileExists(atPath: localImageUrl(for: cap, version: 0).path) - } - - /** - Check if a thumbnail exists for a cap - - parameter cap: The id of the cap - - returns: True, if a thumbnail exists - */ - func hasThumbnail(for cap: Int) -> Bool { - fm.fileExists(atPath: thumbnailUrl(for: cap).path) - } - - func existingImageUrl(for cap: Int, version: Int = 0) -> URL? { - let url = localImageUrl(for: cap, version: version) - return fm.fileExists(atPath: url.path) ? url : nil - } - - // MARK: Retrieval - - /** - Get the image data for a cap. - If the image exists on disk, it is returned. - If no image exists locally, then this function returns nil. - - parameter cap: The id of the cap - - parameter version: The image version - - returns: The image data, or `nil` - */ - func imageData(for cap: Int, version: Int = 0) -> Data? { - readData(from: localImageUrl(for: cap, version: version)) - } - - /** - Get the image for a cap. - If the image exists on disk, it is returned. - If no image exists locally, then this function returns nil. - - parameter cap: The id of the cap - - parameter version: The image version - - returns: The image, or `nil` - - note: Removes invalid image data on disk, if the data is not a valid image - - note: Must be called on the main thread - */ - func image(for cap: Int, version: Int) -> UIImage? { - guard let data = imageData(for: cap, version: version) else { - return nil - } - guard let image = UIImage(data: data) else { - log("Removing invalid image \(version) of cap \(cap) from disk") - deleteImage(for: cap, version: version) - return nil - } - return image - } - - func image(for cap: Int) -> UIImage? { - image(for: cap, version: 0) - } - - /** - Get the thumbnail data for a cap. - If the image exists on disk, it is returned. - If no image exists locally, then this function returns nil. - - parameter cap: The id of the cap - - returns: The image data, or `nil` - */ - func thumbnailData(for cap: Int) -> Data? { - readData(from: thumbnailUrl(for: cap)) - } - - /** - Get the thumbnail for a cap. - If the image exists on disk, it is returned. - If no image exists locally, then this function returns nil. - - parameter cap: The id of the cap - - returns: The image, or `nil` - */ - func thumbnail(for cap: Int) -> UIImage? { - guard let data = thumbnailData(for: cap) else { - return nil - } - return UIImage(data: data) - } - - /// The compiled recognition model on disk - var recognitionModel: VNCoreMLModel? { - guard fm.fileExists(atPath: modelUrl.path) else { - log("No recognition model to load") - return nil - } - do { - let model = try MLModel(contentsOf: modelUrl) - - return try VNCoreMLModel(for: model) - } catch { - self.error("Failed to load recognition model: \(error)") - return nil - } - } - - func ciImage(for cap: Int) -> CIImage? { - let url = thumbnailUrl(for: cap) - guard fm.fileExists(atPath: url.path) else { - return nil - } - guard let image = CIImage(contentsOf: url) else { - error("Failed to read CIImage for main image of cap \(cap)") - return nil - } - return image - } - - private func readData(from url: URL) -> Data? { - guard fm.fileExists(atPath: url.path) else { - return nil - } - do { - return try Data(contentsOf: url) - } catch { - self.error("Could not read data from \(url): \(error)") - return nil - } - } - - // MARK: Deleting data - - @discardableResult - func deleteDatabase() -> Bool { - do { - try fm.removeItem(at: dbUrl) - return true - } catch { - log("Failed to delete database: \(error)") - return false - } - } - - @discardableResult - func deleteImage(for cap: Int, version: Int) -> Bool { - let url = localImageUrl(for: cap, version: version) - return delete(at: url) - } - - @discardableResult - func deleteThumbnail(for cap: Int) -> Bool { - let url = thumbnailUrl(for: cap) - return delete(at: url) - } - - private func delete(at url: URL) -> Bool { - guard fm.fileExists(atPath: url.path) else { - return true - } - do { - try fm.removeItem(at: url) - return true - } catch { - log("Failed to delete file \(url.lastPathComponent): \(error)") - return false - } - } -} - -extension Storage: Logger { } - diff --git a/Caps/Data/TileImage.swift b/Caps/Data/TileImage.swift deleted file mode 100644 index 8ae0c68..0000000 --- a/Caps/Data/TileImage.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// TileImage.swift -// CapCollector -// -// Created by Christoph on 20.05.20. -// Copyright © 2020 CH. All rights reserved. -// - -import Foundation -import SQLite - -extension Database { - - struct TileImage { - - let name: String - - let width: Int - - /// The tiles of each cap, with the index being the tile, and the value being the cap id. - let caps: [Int] - - var encodedCaps: Data { - caps.map(UInt16.init).withUnsafeBytes { (p) in - Data(buffer: p.bindMemory(to: UInt8.self)) - } - } - - init(name: String, width: Int, caps: [Int]) { - self.name = name - self.width = width - self.caps = caps - } - - init(row: Row) { - self.name = row[TileImage.columnName] - self.width = row[TileImage.columnWidth] - self.caps = row[TileImage.columnCaps].withUnsafeBytes { p in - p.bindMemory(to: UInt16.self).map(Int.init) - } - } - - var insertQuery: Insert { - TileImage.table.insert( - TileImage.columnName <- name, - TileImage.columnWidth <- width, - TileImage.columnCaps <- encodedCaps) - } - - var updateQuery: Update { - TileImage.table.update( - TileImage.columnWidth <- width, - TileImage.columnCaps <- encodedCaps) - } - - static let columnName = Expression("name") - - static let columnWidth = Expression("width") - - static let columnCaps = Expression("caps") - - static let table = Table("images") - - - static func named(_ name: String) -> Table { - table.filter(columnName == name) - } - static func exists(_ name: String) -> Table { - named(name).select(columnName) - } - - static var createQuery: String { - table.create(ifNotExists: true) { t in - t.column(Cap.columnId, primaryKey: true) - t.column(columnName) - t.column(columnWidth) - t.column(columnCaps) - } - } - } - - - func save(tileImage: TileImage) -> Bool { - guard exists(tileImage.name) else { - return insert(tileImage) - } - return update(tileImage) - } - - var tileImages: [TileImage] { - (try? db.prepare(TileImage.table).map(TileImage.init)) ?? [] - } - - func tileImage(named name: String) -> TileImage? { - do { - guard let row = try db.pluck(TileImage.named(name)) else { - return nil - } - return TileImage(row: row) - } catch { - log("Failed to get tile image \(name): \(error)") - return nil - } - } - - private func exists(_ tileImage: String) -> Bool { - do { - return try db.pluck(TileImage.exists(tileImage)) != nil - } catch { - log("Failed to check tile image \(tileImage): \(error)") - return false - } - } - - private func insert(_ tileImage: TileImage) -> Bool { - do { - try db.run(tileImage.insertQuery) - return true - } catch { - log("Failed to insert tile image \(tileImage): \(error)") - return false - } - } - - private func update(_ tileImage: TileImage) -> Bool { - do { - try db.run(tileImage.updateQuery) - return true - } catch { - log("Failed to update tile image \(tileImage): \(error)") - return false - } - } -} diff --git a/Caps/Data/Upload.swift b/Caps/Data/Upload.swift deleted file mode 100644 index 170e922..0000000 --- a/Caps/Data/Upload.swift +++ /dev/null @@ -1,212 +0,0 @@ -// -// Upload.swift -// CapCollector -// -// Created by Christoph on 26.04.20. -// Copyright © 2020 CH. All rights reserved. -// - -import Foundation -import UIKit -import SQLite - -struct Upload { - - static let offlineKey = "offline" - - let serverUrl: URL - - let table = Table("uploads") - - let rowCapId = Expression("cap") - - let rowCapVersion = Expression("version") - - init(server: URL) { - self.serverUrl = server - } - - // MARK: Paths - - var serverImageUrl: URL { - serverUrl.appendingPathComponent("images") - } - - private func serverImageUrl(for cap: Int, version: Int = 0) -> URL { - serverImageUrl.appendingPathComponent("\(cap)/\(cap)-\(version).jpg") - } - - private func serverImageUploadUrl(for cap: Int) -> URL { - serverImageUrl.appendingPathComponent("\(cap)") - } - - private func serverNameUploadUrl(for cap: Int) -> URL { - serverUrl.appendingPathComponent("name/\(cap)") - } - - private func serverChangeMainImageUrl(for cap: Int, to newValue: Int) -> URL { - serverUrl.appendingPathComponent("switch/\(cap)/\(newValue)") - } - - // MARK: SQLite - - var createQuery: String { - table.create(ifNotExists: true) { t in - t.column(rowCapId) - t.column(rowCapVersion) - } - } - - func existsQuery(for cap: Int, version: Int) -> ScalarQuery { - table.filter(rowCapId == cap && rowCapVersion == version).count - } - - func insertQuery(for cap: Int, version: Int) -> Insert { - table.insert(rowCapId <- cap, rowCapVersion <- version) - } - - func deleteQuery(for cap: Int, version: Int) -> Delete { - table.filter(rowCapId == cap && rowCapVersion == version).delete() - } - - // MARK: Uploading data - - func upload(name: String, for cap: Int, completion: @escaping (_ success: Bool) -> Void) { - var request = URLRequest(url: serverNameUploadUrl(for: cap)) - request.httpMethod = "POST" - request.httpBody = name.data(using: .utf8) - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - self.log("Failed to upload name of cap \(cap): \(error)") - completion(false) - return - } - guard let response = response else { - self.log("Failed to upload name of cap \(cap): No response") - completion(false) - return - } - guard let urlResponse = response as? HTTPURLResponse else { - self.log("Failed to upload name of cap \(cap): \(response)") - completion(false) - return - } - guard urlResponse.statusCode == 200 else { - self.log("Failed to upload name of cap \(cap): Response \(urlResponse.statusCode)") - completion(false) - return - } - completion(true) - } - task.resume() - } - - func upload(_ cap: Cap, timeout: TimeInterval = 30) -> Bool { - upload(name: cap.name, for: cap.id, timeout: timeout) - } - - func upload(name: String, for cap: Int, timeout: TimeInterval = 30) -> Bool { - let group = DispatchGroup() - group.enter() - var result = true - upload(name: name, for: cap) { success in - if success { - self.log("Uploaded cap \(cap)") - } else { - result = false - } - group.leave() - } - guard group.wait(timeout: .now() + timeout) == .success else { - log("Timed out uploading cap \(cap)") - return false - } - return result - } - - func upload(imageAt url: URL, for cap: Int, completion: @escaping (_ count: Int?) -> Void) { - var request = URLRequest(url: serverImageUploadUrl(for: cap)) - request.httpMethod = "POST" - let task = URLSession.shared.uploadTask(with: request, fromFile: url) { data, response, error in - if let error = error { - self.log("Failed to upload image of cap \(cap): \(error)") - completion(nil) - return - } - guard let response = response else { - self.log("Failed to upload image of cap \(cap): No response") - completion(nil) - return - } - guard let urlResponse = response as? HTTPURLResponse else { - self.log("Failed to upload image of cap \(cap): \(response)") - completion(nil) - return - } - guard urlResponse.statusCode == 200 else { - self.log("Failed to upload image of cap \(cap): Response \(urlResponse.statusCode)") - completion(nil) - return - } - guard let d = data, let string = String(data: d, encoding: .utf8), let int = Int(string) else { - self.log("Failed to upload image of cap \(cap): Invalid response") - completion(nil) - return - } - completion(int) - } - task.resume() - } - - func upload(imageAt url: URL, of cap: Int, timeout: TimeInterval = 30) -> Int? { - let group = DispatchGroup() - group.enter() - var result: Int? = nil - upload(imageAt: url, for: cap) { count in - result = count - group.leave() - } - guard group.wait(timeout: .now() + timeout) == .success else { - log("Timed out uploading image of \(cap)") - return nil - } - return result - } - - /** - Sets the main image for a cap to a different version. - - - Parameter cap: The id of the cap - - Parameter version: The version to set as the main version. - - Parameter completion: A callback with the result on completion. - */ - func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ success: Bool) -> Void) { - let url = serverChangeMainImageUrl(for: cap, to: version) - let task = URLSession.shared.dataTask(with: url) { data, response, error in - if let error = error { - self.log("Failed to set main image of cap \(cap) to \(version): \(error)") - completion(false) - return - } - guard let response = response else { - self.log("Failed to set main image of cap \(cap) to \(version): No response") - completion(false) - return - } - guard let urlResponse = response as? HTTPURLResponse else { - self.log("Failed to set main image of cap \(cap) to \(version): \(response)") - completion(false) - return - } - guard urlResponse.statusCode == 200 else { - self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)") - completion(false) - return - } - completion(true) - } - task.resume() - } -} - -extension Upload: Logger { } diff --git a/Caps/Extensions/Array+Extensions.swift b/Caps/Extensions/Array+Extensions.swift deleted file mode 100644 index 4514c4e..0000000 --- a/Caps/Extensions/Array+Extensions.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Array+Extensions.swift -// CapCollector -// -// Created by Christoph on 12.05.20. -// Copyright © 2020 CH. All rights reserved. -// - -import Foundation - -extension Array { - - func split(intoPartsOf maxElements: Int) -> [ArraySlice] { - guard !isEmpty, maxElements > 0 else { - return [] - } - var result = [ArraySlice]() - var currentIndex = 0 - while true { - let nextIndex = currentIndex + maxElements - if nextIndex >= count { - result.append(self[currentIndex.. CGImage? { + guard let pixelBuffer = cvPixelBuffer else { + return nil + } + + var image: CGImage? + VTCreateCGImageFromCVPixelBuffer(pixelBuffer, options: nil, imageOut: &image) + return image + } + + /** + Crop an image to a square, centered around the middle of the frame + - parameter size: The height and width of the resulting image + - returns: The cropped image + */ + func crop(to size: CGFloat) -> CGImage? { + let rect = CGRect( + x: (CGFloat(height) - size) / 2, + y: (CGFloat(width) - size) / 2, + width: size, + height: size) + + return cropping(to: rect) + } +} diff --git a/Caps/Extensions/CGImagePropertyOrientation+Extensions.swift b/Caps/Extensions/CGImagePropertyOrientation+Extensions.swift index efcd191..963812c 100644 --- a/Caps/Extensions/CGImagePropertyOrientation+Extensions.swift +++ b/Caps/Extensions/CGImagePropertyOrientation+Extensions.swift @@ -1,11 +1,3 @@ -// -// CGImagePropertyOrientation+Extensions.swift -// CapCollector -// -// Created by Christoph on 13.05.20. -// Copyright © 2020 CH. All rights reserved. -// - import UIKit extension CGImagePropertyOrientation { diff --git a/Caps/Extensions/Data+Extensions.swift b/Caps/Extensions/Data+Extensions.swift new file mode 100644 index 0000000..159efeb --- /dev/null +++ b/Caps/Extensions/Data+Extensions.swift @@ -0,0 +1,59 @@ +import Foundation + +extension Data { + + public var hexEncoded: String { + return map { String(format: "%02hhx", $0) }.joined() + } + + // Convert 0 ... 9, a ... f, A ...F to their decimal value, + // return nil for all other input characters + private func decodeNibble(_ u: UInt16) -> UInt8? { + switch(u) { + case 0x30 ... 0x39: + return UInt8(u - 0x30) + case 0x41 ... 0x46: + return UInt8(u - 0x41 + 10) + case 0x61 ... 0x66: + return UInt8(u - 0x61 + 10) + default: + return nil + } + } + + public init?(fromHexEncodedString string: String) { + let utf16 = string.utf16 + self.init(capacity: utf16.count/2) + + var i = utf16.startIndex + guard utf16.count % 2 == 0 else { + return nil + } + while i != utf16.endIndex { + guard let hi = decodeNibble(utf16[i]), + let lo = decodeNibble(utf16[utf16.index(i, offsetBy: 1, limitedBy: utf16.endIndex)!]) else { + return nil + } + var value = hi << 4 + lo + self.append(&value, count: 1) + i = utf16.index(i, offsetBy: 2, limitedBy: utf16.endIndex)! + } + } +} + +extension Data { + + + func convert(into value: T) -> T { + withUnsafeBytes { + $0.baseAddress!.load(as: T.self) + } + } + + init(from value: T) { + var target = value + self = Swift.withUnsafeBytes(of: &target) { + Data($0) + } + } +} diff --git a/Caps/Extensions/DispatchGroup+Extensions.swift b/Caps/Extensions/DispatchGroup+Extensions.swift deleted file mode 100644 index 7b5dad0..0000000 --- a/Caps/Extensions/DispatchGroup+Extensions.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// DispatchGroup+Extensions.swift -// CapCollector -// -// Created by iMac on 13.01.21. -// Copyright © 2021 CH. All rights reserved. -// - -import Foundation - -extension DispatchGroup { - - typealias AsyncSuccessCallback = (Bool) -> Void - - static func singleTask(timeout: TimeInterval = 30, _ block: (@escaping AsyncSuccessCallback) -> Void) -> Bool { - let group = DispatchGroup() - group.enter() - var result = true - block { success in - result = success - group.leave() - } - guard group.wait(timeout: .now() + timeout) == .success else { - return false - } - return result - } -} diff --git a/Caps/Extensions/UIAlertControllerExtensions.swift b/Caps/Extensions/UIAlertControllerExtensions.swift deleted file mode 100644 index 97b65ad..0000000 --- a/Caps/Extensions/UIAlertControllerExtensions.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// UIAlertControllerExtensions.swift -// CapFinder -// -// Created by User on 23.03.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import UIKit - -extension UIAlertController { - - private struct AssociatedKeys { - static var blurStyleKey = "UIAlertController.blurStyleKey" - } - - public var blurStyle: UIBlurEffect.Style { - get { - return objc_getAssociatedObject(self, &AssociatedKeys.blurStyleKey) as? UIBlurEffect.Style ?? .extraLight - } set (style) { - objc_setAssociatedObject(self, &AssociatedKeys.blurStyleKey, style, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - - view.setNeedsLayout() - view.layoutIfNeeded() - } - } - - public var cancelButtonColor: UIColor? { - return blurStyle == .dark ? UIColor(red: 28.0/255.0, green: 28.0/255.0, blue: 28.0/255.0, alpha: 1.0) : nil - } - - private var visualEffectView: UIVisualEffectView? { - if let presentationController = presentationController, presentationController.responds(to: Selector(("popoverView"))), let view = presentationController.value(forKey: "popoverView") as? UIView // We're on an iPad and visual effect view is in a different place. - { - return view.recursiveSubviews.compactMap({$0 as? UIVisualEffectView}).first - } - - return view.recursiveSubviews.compactMap({$0 as? UIVisualEffectView}).first - } - - private var cancelActionView: UIView? { - return view.recursiveSubviews.compactMap({ - $0 as? UILabel} - ).first(where: { - $0.text == actions.first(where: { $0.style == .cancel })?.title - })?.superview?.superview - } - - public convenience init(title: String?, message: String?, preferredStyle: UIAlertController.Style, blurStyle: UIBlurEffect.Style) { - self.init(title: title, message: message, preferredStyle: preferredStyle) - self.blurStyle = blurStyle - } - - open override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - - visualEffectView?.effect = UIBlurEffect(style: blurStyle) - cancelActionView?.backgroundColor = cancelButtonColor - } -} diff --git a/Caps/Extensions/UIColor+Extensions.swift b/Caps/Extensions/UIColor+Extensions.swift deleted file mode 100644 index 14d6f7f..0000000 --- a/Caps/Extensions/UIColor+Extensions.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// UIColor+Extensions.swift -// CapCollector -// -// Created by Christoph on 14.04.20. -// Copyright © 2020 CH. All rights reserved. -// - -import UIKit - -extension UIColor { - - var rgb: (red: Double, green: Double, blue: Double) { - var fRed: CGFloat = 0 - var fGreen: CGFloat = 0 - var fBlue: CGFloat = 0 - var fAlpha: CGFloat = 0 - getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha) - return (Double(fRed), Double(fGreen), Double(fBlue)) - } -} - diff --git a/Caps/Extensions/UIImage+Extensions.swift b/Caps/Extensions/UIImage+Extensions.swift index 74e1aa1..0bf66e2 100644 --- a/Caps/Extensions/UIImage+Extensions.swift +++ b/Caps/Extensions/UIImage+Extensions.swift @@ -1,11 +1,3 @@ -// -// UIImageExtensions.swift -// CapFinder -// -// Created by User on 13.02.18. -// Copyright © 2018 User. All rights reserved. -// - import Foundation import UIKit @@ -22,7 +14,7 @@ extension UIImage { func resize(to targetSize: CGSize) -> UIImage { let rect = CGRect(x: 0, y: 0, width: targetSize.width, height: targetSize.height) - UIGraphicsBeginImageContextWithOptions(targetSize, false, 1.0) + UIGraphicsBeginImageContextWithOptions(targetSize, false, scale) self.draw(in: rect) let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() @@ -35,7 +27,7 @@ extension UIImage { - returns: The cropped image */ func crop(factor: CGFloat) -> UIImage { - let width = self.size.width * factor + let width = size.width * scale * factor return crop(to: width) } @@ -45,15 +37,11 @@ extension UIImage { - returns: The cropped image */ func crop(to size: CGFloat) -> UIImage { - var rect = CGRect( - x: (self.size.height - size) / 2, - y: (self.size.width - size) / 2, - width: size, - height: size) - rect.origin.x *= scale - rect.origin.y *= scale - rect.size.width *= scale - rect.size.height *= scale + let rect = CGRect( + x: (self.size.height * scale - size) / 2, + y: (self.size.width * scale - size) / 2, + width: size * scale, + height: size * scale) let imageRef = cgImage!.cropping(to: rect) return UIImage(cgImage: imageRef!, scale: scale, orientation: imageOrientation) @@ -77,10 +65,10 @@ extension UIImage { return nil } UIBezierPath(ovalIn: breadthRect).addClip() - UIImage(cgImage: cgImage, scale: 1, orientation: imageOrientation).draw(in: breadthRect) + UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation).draw(in: breadthRect) return UIGraphicsGetImageFromCurrentImageContext() } - + var averageColor: UIColor? { let image = ciImage ?? CIImage(cgImage: cgImage!) return image.averageColor @@ -88,14 +76,14 @@ extension UIImage { } extension CIImage { - + func averageColor(context: CIContext) -> UIColor? { let extentVector = CIVector( x: extent.origin.x, y: extent.origin.y, z: extent.size.width, w: extent.size.height) - + guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: self, kCIInputExtentKey: extentVector]) else { log("Failed to create filter") return nil @@ -104,27 +92,27 @@ extension CIImage { log("Failed get filter output") return nil } - + var bitmap = [UInt8](repeating: 0, count: 4) - + context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - + return UIColor( red: saturate(bitmap[0]), green: saturate(bitmap[1]), blue: saturate(bitmap[2]), alpha: CGFloat(bitmap[3]) / 255) } - + var averageColor: UIColor? { let extentVector = CIVector( x: extent.origin.x, y: extent.origin.y, z: extent.size.width, w: extent.size.height) - + guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: self, kCIInputExtentKey: extentVector]) else { log("Failed to create filter") return nil @@ -133,7 +121,7 @@ extension CIImage { log("Failed get filter output") return nil } - + var bitmap = [UInt8](repeating: 0, count: 4) guard let null = kCFNull else { return nil @@ -142,13 +130,13 @@ extension CIImage { context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - + let color = UIColor( red: saturate(bitmap[0]), green: saturate(bitmap[1]), blue: saturate(bitmap[2]), alpha: CGFloat(bitmap[3]) / 255) - + return color } } diff --git a/Caps/Extensions/UINavigationItem+Extensions.swift b/Caps/Extensions/UINavigationItem+Extensions.swift deleted file mode 100644 index 7ac106f..0000000 --- a/Caps/Extensions/UINavigationItem+Extensions.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// UINavigationItem+Extensions.swift -// CapCollector -// -// Created by Christoph on 12.05.20. -// Copyright © 2020 CH. All rights reserved. -// - -import UIKit - -extension UINavigationItem { - - func setTitle(_ title: String, subtitle: String) { - let titleLabel = UILabel() - titleLabel.text = title - titleLabel.font = .systemFont(ofSize: 17.0) - titleLabel.textColor = .black - - let subtitleLabel = UILabel() - subtitleLabel.text = subtitle - subtitleLabel.font = .systemFont(ofSize: 12.0) - subtitleLabel.textColor = .gray - - let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) - stackView.distribution = .equalCentering - stackView.alignment = .center - stackView.axis = .vertical - - self.titleView = stackView - } -} diff --git a/Caps/Extensions/UIViewExtensions.swift b/Caps/Extensions/UIViewExtensions.swift deleted file mode 100644 index 4619a0a..0000000 --- a/Caps/Extensions/UIViewExtensions.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// UIViewExtensions.swift -// CapFinder -// -// Created by User on 23.03.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import UIKit - -extension UIView { - - var recursiveSubviews: [UIView] { - var subviews = self.subviews.compactMap{ $0 } - subviews.forEach { subviews.append(contentsOf: $0.recursiveSubviews) } - return subviews - } - - func fromNib() -> T { // 2 - return Bundle(for: type(of: self)).loadNibNamed(String(describing: type(of: self)), owner: self, options: nil)!.first! as! T - } -} diff --git a/Caps/Extensions/URL+Extensions.swift b/Caps/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..eb550a6 --- /dev/null +++ b/Caps/Extensions/URL+Extensions.swift @@ -0,0 +1,25 @@ +import Foundation + +extension URL { + + var attributes: [FileAttributeKey : Any]? { + do { + return try FileManager.default.attributesOfItem(atPath: path) + } catch let error as NSError { + print("FileAttribute error: \(error)") + } + return nil + } + + var fileSize: Int { + return Int(attributes?[.size] as? UInt64 ?? 0) + } + + var fileSizeString: String { + return ByteCountFormatter.string(fromByteCount: Int64(fileSize), countStyle: .file) + } + + var creationDate: Date? { + return attributes?[.creationDate] as? Date + } +} diff --git a/Caps/Extensions/ViewControllerExtensions.swift b/Caps/Extensions/ViewControllerExtensions.swift deleted file mode 100644 index a0fb98e..0000000 --- a/Caps/Extensions/ViewControllerExtensions.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// ViewControllerExtensions.swift -// CapFinder -// -// Created by User on 18.03.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import UIKit - -extension UIViewController { - - // MARK: Alerts - - /// Present an alert with a message to the user - func showAlert(_ message: String, title: String = "Error") { - let alertController = UIAlertController( - title: title, - message: message, - preferredStyle: .alert)//, - //blurStyle: .dark) - - alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - - self.present(alertController, animated: true, completion: nil) - } -} diff --git a/Caps/Info.plist b/Caps/Info.plist deleted file mode 100644 index c0cb3d1..0000000 --- a/Caps/Info.plist +++ /dev/null @@ -1,76 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Caps - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - 1 - LSApplicationQueriesSchemes - - dbapi-8-emm - dbapi-2 - - LSRequiresIPhoneOS - - NSCameraUsageDescription - Take images to identify matching caps and register new ones - UIApplicationShortcutItems - - - UIApplicationShortcutItemIconType - UIApplicationShortcutIconTypeCapturePhoto - UIApplicationShortcutItemSubtitle - Compare a new image - UIApplicationShortcutItemTitle - Take image - UIApplicationShortcutItemType - firstShortcut - UIApplicationShortcutItemUserInfo - - firstShortcutKey1 - firstShortcutKeyValue1 - - - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIRequiredDeviceCapabilities - - armv7 - - UIStatusBarStyle - UIStatusBarStyleDefault - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationLandscapeLeft - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - - diff --git a/Caps/Logger.swift b/Caps/Logger.swift index 23e4478..93f66cd 100644 --- a/Caps/Logger.swift +++ b/Caps/Logger.swift @@ -1,11 +1,3 @@ -// -// Logger.swift -// CapFinder -// -// Created by User on 11.04.18. -// Copyright © 2018 User. All rights reserved. -// - import Foundation protocol Logger { @@ -27,7 +19,7 @@ func log(_ message: String, file: String = #file, line: Int = #line) { } extension Logger { - + static var logToken: String { "[" + String(describing: self) + "] " } @@ -47,7 +39,7 @@ extension Logger { static func log(_ message: String) { addToFile(logToken + message) } - + private static func addToFile(_ message: String) { Log.write("\n" + message) print(message) @@ -55,7 +47,7 @@ extension Logger { } enum Log { - + static func set(logFile: String) throws { let url = URL(fileURLWithPath: logFile) if !FileManager.default.fileExists(atPath: logFile) { @@ -63,14 +55,14 @@ enum Log { } file = FileHandle(forWritingAtPath: logFile) } - + static func write(_ message: String) { guard let f = file else { return } f.write(message.data(using: .utf8)!) } - + private static var file: FileHandle? } diff --git a/Caps/Presentation/CapCell.swift b/Caps/Presentation/CapCell.swift deleted file mode 100644 index 5d09794..0000000 --- a/Caps/Presentation/CapCell.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// CapCell.swift -// CapFinder -// -// Created by User on 22.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - - -class CapCell: UITableViewCell { - - @IBOutlet private weak var capImage: RoundedImageView! - - @IBOutlet private weak var matchLabel: UILabel! - - @IBOutlet private weak var nameLabel: UILabel! - - @IBOutlet private weak var countLabel: UILabel! - - var id: Int = 0 - - func set(image: UIImage?) { - capImage.image = image ?? UIImage(named: "launch") - } - - func set(name: String) { - self.nameLabel.text = name - } - - func set(matchLabel: String) { - self.matchLabel.text = matchLabel - } - - func set(countLabel: String) { - self.countLabel.text = countLabel - } -} diff --git a/Caps/Presentation/GridViewController.swift b/Caps/Presentation/GridViewController.swift deleted file mode 100644 index d7bf2f7..0000000 --- a/Caps/Presentation/GridViewController.swift +++ /dev/null @@ -1,360 +0,0 @@ -// -// GridViewController.swift -// CapCollector -// -// Created by Christoph on 07.01.19. -// Copyright © 2019 CH. All rights reserved. -// - -import UIKit - -class GridViewController: UIViewController { - - /// The number of horizontal pixels for each cap. - static let len: CGFloat = 60 - - private lazy var rowHeight = GridViewController.len * 0.866 - - private lazy var margin = GridViewController.len - rowHeight - - private var myView: UIView! - - private var canvasSize: CGSize = .zero - - @IBOutlet weak var scrollView: UIScrollView! - - /// A dictionary of the caps for the tiles - private var tiles = [Int]() - - /// The name of the tile image - private var name: String = "default" - - /// The number of caps horizontally. - private var columns = 40 - - /// A dictionary for the colors of the caps - private var colors = [Int : UIColor]() - - /// The currently displaxed image views indexed by their tile ids - private var installedTiles = [Int : RoundedImageView]() - - private var selectedTile: Int? = nil - - private weak var selectionView: RoundedButton! - - override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { - return .portrait - } - - override var shouldAutorotate: Bool { - return true - } - - private var isShowingColors = false - - @IBAction func toggleAverageColor(_ sender: Any) { - isShowingColors = !isShowingColors - for (tile, view) in installedTiles { - if isShowingColors { - view.image = nil - view.backgroundColor = tileColor(tile: tile) - } else { - let id = tiles[tile] - if let image = app.database.storage.thumbnail(for: id) { - view.image = image - continue - } - self.downloadImage(cap: id, tile: tile) - } - } - } - - func load(tileImage: Database.TileImage) { - let totalCount = app.database.capCount - let firstNewId = tileImage.caps.count + 1 - if totalCount >= firstNewId { - self.tiles = tileImage.caps + (firstNewId...totalCount) - } else { - self.tiles = tileImage.caps - } - self.columns = tileImage.width - self.name = tileImage.name - } - - private func saveTileImage() { - let tileImage = Database.TileImage(name: name, width: columns, caps: tiles) - guard app.database.save(tileImage: tileImage) else { - log("Failed to save tile image") - return - } - log("Tile image saved") - } - - override func viewDidLoad() { - super.viewDidLoad() - - colors = app.database.colors - - let width = CGFloat(columns) * GridViewController.len + GridViewController.len / 2 - let height = (CGFloat(tiles.count) / CGFloat(columns)).rounded(.up) * rowHeight + margin - canvasSize = CGSize(width: width, height: height) - myView = UIView(frame: CGRect(origin: .zero, size: canvasSize)) - - scrollView.addSubview(myView) - scrollView.contentSize = canvasSize - scrollView.delegate = self - scrollView.zoomScale = 0.5 - scrollView.maximumZoomScale = 1 - setZoomRange() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - updateTiles() - - let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) - myView.addGestureRecognizer(tapRecognizer) - } - - override func viewDidLayoutSubviews() { - setZoomRange() - updateTiles() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - saveTileImage() - } - - // MARK: Tiles - - private func tileColor(tile: Int) -> UIColor? { - let id = tiles[tile] - return colors[id] - } - - /** - Switch two tiles. - */ - private func switchTiles(_ lhs: Int, _ rhs: Int) -> Bool { - let temp = tiles[rhs] - tiles[rhs] = tiles[lhs] - tiles[lhs] = temp - return true - } - - - private func setZoomRange() { - let size = scrollView.frame.size - let a = size.width / canvasSize.width - let b = size.height / canvasSize.height - let scale = min(a,b) - scrollView.minimumZoomScale = min(a,b) - if scrollView.zoomScale < scale { - scrollView.setZoomScale(scale, animated: true) - } - } - - @objc func handleTap(_ sender: UITapGestureRecognizer) { - - let loc = sender.location(in: myView) - let y = loc.y - let s = y.truncatingRemainder(dividingBy: rowHeight) - let row = Int(y / rowHeight) - guard s > margin else { - return - } - let column: Int - if row.isEven { - column = Int(loc.x / GridViewController.len) - // Abort, if user tapped outside of the grid - if column >= columns { - clearTileSelection() - return - } - } else { - column = Int((loc.x - GridViewController.len / 2) / GridViewController.len) - } - handleTileTapped(tile: row * columns + Int(column)) - } - - private func handleTileTapped(tile: Int) { - if let selected = selectedTile { - switchTiles(oldTile: selected, newTile: tile) - } else { - showSelection(tile: tile) - } - } - - private func showSelection(tile: Int) { - clearTileSelection() - - if let view = installedTiles[tile] { - view.borderWidth = 3 - view.borderColor = AppDelegate.tintColor - selectedTile = tile - } else { - selectedTile = nil - } - } - - private func tileIsVisible(tile: Int, in rect: CGRect) -> Bool { - return rect.intersects(frame(for: tile)) - } - - private func makeTile(_ tile: Int) { - let view = RoundedImageView(frame: frame(for: tile)) - myView.addSubview(view) - defer { - installedTiles[tile] = view - } - // Only set image if images are shown - guard !isShowingColors else { - view.backgroundColor = tileColor(tile: tile) - return - - } - if let image = app.database.storage.thumbnail(for: tiles[tile]) { - view.image = image - return - } - - downloadImage(tile: tile) - } - - private func downloadImage(tile: Int) { - let id = tiles[tile] - downloadImage(cap: id, tile: tile) - } - - private func downloadImage(cap id: Int, tile: Int) { - app.database.downloadImage(for: id) { img in - guard img != nil else { - return - } - guard let view = self.installedTiles[tile] else { - self.log("No installed tile for downloaded image \(id)") - return - } - guard let image = app.database.storage.thumbnail(for: id) else { - self.log("Failed to load image for cap \(id) after successful download") - return - } - DispatchQueue.main.async { - guard self.isShowingColors else { - view.image = image - return - } - guard let color = image.averageColor else { - self.log("Failed to get average color from image for cap \(id)") - return - } - view.backgroundColor = color - self.colors[id] = color - } - } - } - - private func frame(for tile: Int) -> CGRect { - let row = tile / columns - let column = tile - row * columns - let x = CGFloat(column) * GridViewController.len + (row.isEven ? 0 : GridViewController.len / 2) - let y = CGFloat(row) * rowHeight - return CGRect(x: x, y: y, width: GridViewController.len, height: GridViewController.len) - } - - private func switchTiles(oldTile: Int, newTile: Int) { - guard oldTile != newTile else { - clearTileSelection() - return - } - guard switchTiles(oldTile, newTile) else { - clearTileSelection() - return - } - // Switch cap colors - let temp = installedTiles[oldTile]?.backgroundColor - installedTiles[oldTile]?.backgroundColor = installedTiles[newTile]?.backgroundColor - installedTiles[newTile]?.backgroundColor = temp - if !isShowingColors { - let temp = installedTiles[oldTile]?.image - installedTiles[oldTile]?.image = installedTiles[newTile]?.image - installedTiles[newTile]?.image = temp - } - clearTileSelection() - - } - - private func clearTileSelection() { - guard let tile = selectedTile else { - return - } - installedTiles[tile]?.borderWidth = 0 - selectedTile = nil - } - - private func showTiles(in rect: CGRect) { - for tile in 0.. UIView? { - return myView - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - updateTiles() - } -} - -private extension Int { - - var isEven: Bool { - return self % 2 == 0 - } -} - -extension GridViewController: Logger { } diff --git a/Caps/Presentation/ImageCell.swift b/Caps/Presentation/ImageCell.swift deleted file mode 100644 index d8e6a30..0000000 --- a/Caps/Presentation/ImageCell.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// ImageCell.swift -// CapFinder -// -// Created by User on 07.02.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - -class ImageCell: UICollectionViewCell { - - @IBOutlet weak var capView: UIImageView! - -} diff --git a/Caps/Presentation/ImageSelector.swift b/Caps/Presentation/ImageSelector.swift deleted file mode 100644 index 198d7bb..0000000 --- a/Caps/Presentation/ImageSelector.swift +++ /dev/null @@ -1,186 +0,0 @@ -// -// ListViewController.swift -// CapFinder -// -// Created by User on 22.02.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - -class ImageSelector: UIViewController { - - // MARK: - Constants - - /// The number of items per row - private let itemsPerRow: CGFloat = 3 - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } - - override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { - return .portrait - } - - override var shouldAutorotate: Bool { - return false - } - - // MARK: - Variables - - private var titleLabel: UILabel! - - private var subtitleLabel: UILabel! - - private var images = [UIImage?]() - - var cap: Cap! - - weak var imageProvider: ImageProvider? - - @IBOutlet weak var collection: UICollectionView! - - private var titleText: String { - "Cap \(cap.id) (\(cap.count) images)" - } - - private var subtitleText: String { - cap.name - } - - // MARK: - Life cycle - - override func viewDidLoad() { - super.viewDidLoad() - - collection.dataSource = self - collection.delegate = self - - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - downloadImages() - } - - override func didMove(toParent parent: UIViewController?) { - super.didMove(toParent: parent) - - guard parent != nil && self.navigationItem.titleView == nil else { - return - } - initNavigationItemTitleView() - } - - private func initNavigationItemTitleView() { - self.titleLabel = UILabel() - titleLabel.text = titleText - titleLabel.font = .preferredFont(forTextStyle: .headline) - titleLabel.textColor = .label - - self.subtitleLabel = UILabel() - subtitleLabel.text = subtitleText - subtitleLabel.font = .preferredFont(forTextStyle: .footnote) - subtitleLabel.textColor = .secondaryLabel - - let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) - stackView.distribution = .equalCentering - stackView.alignment = .center - stackView.axis = .vertical - - self.navigationItem.titleView = stackView - } - - // MARK: Image download - - private func downloadImages() { - images = [UIImage?](repeating: nil, count: cap.count) - log("\(cap.count) images for cap \(cap.id)") - for version in 0.. Int { - return 1 - } - - func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - return images.count - } - - func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: "Image", - for: indexPath) as! ImageCell - - cell.capView.image = images[indexPath.row] ?? UIImage(named: "launch") - return cell - } - - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - selectedImage(nr: indexPath.row) - navigationController?.popViewController(animated: true) - } -} - -// MARK: - UICollectionViewDelegateFlowLayout - -extension ImageSelector : UICollectionViewDelegateFlowLayout { - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - sizeForItemAt indexPath: IndexPath) -> CGSize { - let widthPerItem = collectionView.frame.width / itemsPerRow - return CGSize(width: widthPerItem, height: widthPerItem) - } - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - insetForSectionAt section: Int) -> UIEdgeInsets { - return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) - } - - func collectionView(_ collectionView: UICollectionView, - layout collectionViewLayout: UICollectionViewLayout, - minimumLineSpacingForSectionAt section: Int) -> CGFloat { - return 0 - } -} - -extension ImageSelector: Logger { } - - diff --git a/Caps/Presentation/NavigationController.swift b/Caps/Presentation/NavigationController.swift deleted file mode 100644 index 873ef7a..0000000 --- a/Caps/Presentation/NavigationController.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// NavigationController.swift -// CapCollector -// -// Created by Christoph on 08.01.19. -// Copyright © 2019 CH. All rights reserved. -// - -import UIKit - -class NavigationController: UINavigationController { - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return allowLandscape ? .allButUpsideDown : .portrait - } - - override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { - return .portrait - } - - override var shouldAutorotate: Bool { - return allowLandscape - } - - var allowLandscape: Bool = false - - override func viewDidLoad() { - super.viewDidLoad() - - // Do any additional setup after loading the view. - } - -} diff --git a/Caps/Presentation/SearchAndDisplayAccessory.swift b/Caps/Presentation/SearchAndDisplayAccessory.swift deleted file mode 100644 index 97535c0..0000000 --- a/Caps/Presentation/SearchAndDisplayAccessory.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// SearchAndDisplayAccessory.swift -// CapCollector -// -// Created by Christoph on 09.10.19. -// Copyright © 2019 CH. All rights reserved. -// - -import UIKit - -class PassthroughView: UIView { - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - let view = super.hitTest(point, with: event) - return view == self ? nil : view - } -} - -protocol CapAccessoryDelegate: AnyObject { - - func capSearchWasDismissed() - - func capSearch(didChange text: String) - - func capAccessoryDidDiscardImage() - - func capAccessory(shouldSave image: UIImage) - - func capAccessoryCameraButtonPressed() -} - -class SearchAndDisplayAccessory: PassthroughView { - - // MARK: - Outlets - - @IBOutlet weak var capImage: RoundedImageView! - - @IBOutlet weak var saveButton: UIButton! - - @IBOutlet weak var cameraButton: UIButton! - - @IBOutlet weak var searchBar: UISearchBar! - - @IBOutlet weak var imageHeightContraint: NSLayoutConstraint! - - // MARK: - Actions - - @IBAction func cameraButtonPressed() { - if isShowingCapImage { - discardImage() - } else { - delegate?.capAccessoryCameraButtonPressed() - } - } - - @IBAction func saveButtonPressed() { - if let image = capImage.image { - delegate?.capAccessory(shouldSave: image) - } - } - - // MARK: - Variables - - var view: UIView? - - weak var delegate: CapAccessoryDelegate? - - var currentImage: UIImage? { - capImage.image - } - - var isShowingCapImage: Bool { - capImage.image != nil - } - - // MARK: - Setup - - convenience init(width: CGFloat) { - let frame = CGRect(origin: .zero, size: CGSize(width: width, height: 145)) - self.init(frame: frame) - } - - override init(frame: CGRect) { - super.init(frame: frame) - setup() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setup() - } - - private func setup() { - view = fromNib() - view!.frame = bounds - //view!.autoresizingMask = .flexibleHeight - addSubview(view!) - - hideImageView() - - searchBar.text = nil - searchBar.setShowsCancelButton(false, animated: false) - searchBar.delegate = self - } - - // MARK: Search bar - - func dismissAndClearSearchBar() { - searchBar.resignFirstResponder() - searchBar.text = nil - } - - // MARK: Cap image - - func showImageView(with image: UIImage) { - capImage.image = image - cameraButton.setImage(UIImage(systemName: "xmark"), for: .normal) - - imageHeightContraint.constant = 90 - capImage.alpha = 1 - capImage.isHidden = false - saveButton.isHidden = false - } - - func discardImage() { - DispatchQueue.main.async { - self.dismissAndClearSearchBar() - self.hideImageView() - } - delegate?.capAccessoryDidDiscardImage() - } - - func hideImageView() { - capImage.image = nil - cameraButton.setImage(UIImage(systemName: "camera"), for: .normal) - - //imageHeightContraint.constant = 0 - capImage.alpha = 0 - capImage.isHidden = true - saveButton.isHidden = true - } -} - -// MARK: - UISearchBarDelegate - -extension SearchAndDisplayAccessory: UISearchBarDelegate { - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - searchBar.text = nil - delegate?.capSearchWasDismissed() - } - - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - delegate?.capSearch(didChange: searchText) - } -} diff --git a/Caps/Presentation/SearchAndDisplayAccessory.xib b/Caps/Presentation/SearchAndDisplayAccessory.xib deleted file mode 100644 index 60b535f..0000000 --- a/Caps/Presentation/SearchAndDisplayAccessory.xib +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Title - Title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Caps/Presentation/SortController.swift b/Caps/Presentation/SortController.swift deleted file mode 100644 index fa53586..0000000 --- a/Caps/Presentation/SortController.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// SortController.swift -// CapCollector -// -// Created by Christoph on 12.11.18. -// Copyright © 2018 CH. All rights reserved. -// - -import UIKit - -enum SortCriteria: Int { - case id = 0 - case name = 1 - case count = 2 - case match = 3 - - var text: String { - switch self { - case .id: - return "Id" - case .name: - return "Name" - case .count: - return "Count" - case .match: - return "Match" - } - } -} - -protocol SortControllerDelegate: AnyObject { - - func sortController(didSelect sortType: SortCriteria, ascending: Bool) -} - -class SortController: UITableViewController { - - @IBOutlet weak var thirdRowLabel: UILabel! - - var selected: SortCriteria = .id - - var ascending: Bool = false - - weak var delegate: SortControllerDelegate? - - var options = [SortCriteria]() - - override func viewDidLoad() { - super.viewDidLoad() - - preferredContentSize = CGSize(width: 200, height: 139 + options.count * 40) - } - - private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { - UIImpactFeedbackGenerator(style: style).impactOccurred() - } - - private func sortCriteria(for index: Int) -> SortCriteria { - index < options.count ? options[index] : .match - } - - // MARK: - Table view data source - - override func numberOfSections(in tableView: UITableView) -> Int { - return 2 - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - section == 0 ? "Sort order" : "Sort by" - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - section == 0 ? 1 : options.count - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "SortCell")! - - guard indexPath.section != 0 else { - cell.accessoryType = ascending ? .checkmark : .none - cell.textLabel?.text = "Ascending" - return cell - } - let select = sortCriteria(for: indexPath.row) - cell.textLabel?.text = select.text - guard select == selected else { - cell.accessoryType = .none - return cell - } - cell.accessoryType = .checkmark - return cell - } - - override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - 40 - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - guard indexPath.section == 1 else { - ascending = !ascending - tableView.reloadRows(at: [indexPath], with: .automatic) - delegate?.sortController(didSelect: selected, ascending: ascending) - giveFeedback(.light) - return - } - giveFeedback(.medium) - selected = sortCriteria(for: indexPath.row) - tableView.reloadRows(at: [indexPath], with: .automatic) - delegate?.sortController(didSelect: selected, ascending: ascending) - self.dismiss(animated: true) - } -} diff --git a/Caps/Preview Content/Preview Assets.xcassets/Contents.json b/Caps/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Caps/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Caps/TableView.swift b/Caps/TableView.swift deleted file mode 100644 index f7b30f7..0000000 --- a/Caps/TableView.swift +++ /dev/null @@ -1,885 +0,0 @@ -// -// TableView.swift -// CapFinder -// -// Created by User on 22.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - -enum NavigationBarDataType { - - case appInfo - case upload - case thumbnails -} - -protocol NavigationBarDataSource { - - var title: String { get } - - var subtitle: String { get } - - var id: NavigationBarDataType { get } -} - -class TableView: UITableViewController { - - @IBOutlet weak var infoButton: UIBarButtonItem! - - private lazy var classifier: Classifier? = loadClassifier() - - private var accessory: SearchAndDisplayAccessory? - - private var titleLabel: UILabel! - - private var subtitleLabel: UILabel! - - private var caps = [Cap]() - - private var shownCaps = [Cap]() - - private var matches: [Int : Float]? - - private var sortType: SortCriteria = .id - - private var searchText: String? = nil - - private var sortAscending: Bool = false - - /// This will be set to a cap id when adding a cap to it - private var capToAddImageTo: Int? - - private var isUnlocked = false - - var imageProvider: ImageProvider { - app.database.storage - } - - // MARK: Computed properties - - private var titleText: String { - let recognized = app.database.recognizedCapCount - let all = app.database.capCount - switch all { - case 0: - return "No caps" - case 1: - return "1 cap" - case recognized: - return "\(all) caps" - default: - return "\(all) caps (\(all - recognized) new)" - } - } - - private var subtitleText: String { - let capCount = app.database.capCount - guard capCount > 0, isUnlocked else { - return "" - } - let allImages = app.database.imageCount - - let ratio = Float(allImages) / Float(capCount) - return String(format: "%d images (%.2f per cap)", allImages, ratio) - } - - // MARK: Overrides - - override var inputAccessoryView: UIView? { - get { return accessory } - } - - override var canBecomeFirstResponder: Bool { - return true - } - - // MARK: - Actions - - @IBAction func updateInfo(_ sender: UIBarButtonItem, forEvent event: UIEvent) { - guard let touch = event.allTouches?.first, touch.tapCount > 0 else { - return - } - guard !app.database.isInOfflineMode else { - showOfflineDialog() - return - } - app.database.startInitialDownload() - } - - @IBAction func showMosaic(_ sender: UIBarButtonItem) { - checkThumbnailsAndColorsBeforShowingGrid() - } - - func showCameraView() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let controller = storyboard.instantiateViewController(withIdentifier: "NewImageController") as! CameraController - controller.delegate = self - self.present(controller, animated: true) - } - - @objc private func titleWasTapped() { - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let controller = storyboard.instantiateViewController(withIdentifier: "SortController") as! SortController - controller.selected = sortType - controller.ascending = sortAscending - controller.delegate = self - - controller.options = [.id, .name] - if isUnlocked { controller.options.append(.count) } - if matches != nil { controller.options.append(.match) } - - let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller) - - presentationController.sourceView = navigationItem.titleView! - presentationController.permittedArrowDirections = [.up] - self.present(controller, animated: true) - } - - // MARK: - Life cycle - - override func viewDidLoad() { - super.viewDidLoad() - - tableView.rowHeight = 100 - - accessory = SearchAndDisplayAccessory(width: self.view.frame.width) - accessory?.delegate = self - - initInfoButton() - - app.database.delegate = self - let count = app.database.capCount - if count == 0 { - log("No caps found, downloading names") - app.database.startInitialDownload() - } else { - log("Loaded \(count) caps") - reloadCapsFromDatabase() - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - (navigationController as? NavigationController)?.allowLandscape = false - isUnlocked = app.isUnlocked - log(isUnlocked ? "App is unlocked" : "App is locked") - app.database.startBackgroundWork() - } - - override func didMove(toParent parent: UIViewController?) { - super.didMove(toParent: parent) - - guard parent != nil && self.navigationItem.titleView == nil else { - return - } - initNavigationItemTitleView() - } - - private func initInfoButton() { - let offline = app.database.isInOfflineMode - setInfoButtonIcon(offline: offline) - } - - private func setInfoButtonIcon(offline: Bool) { - let symbol = offline ? "icloud.slash" : "arrow.clockwise.icloud" - infoButton.image = UIImage(systemName: symbol) - } - - private func initNavigationItemTitleView() { - self.titleLabel = UILabel() - titleLabel.text = titleText - titleLabel.font = .preferredFont(forTextStyle: .headline) - titleLabel.textColor = .label - - self.subtitleLabel = UILabel() - subtitleLabel.text = subtitleText - subtitleLabel.font = .preferredFont(forTextStyle: .footnote) - subtitleLabel.textColor = .secondaryLabel - - let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) - stackView.distribution = .equalCentering - stackView.alignment = .center - stackView.axis = .vertical - - self.navigationItem.titleView = stackView - - let recognizer = UITapGestureRecognizer(target: self, action: #selector(titleWasTapped)) - stackView.isUserInteractionEnabled = true - stackView.addGestureRecognizer(recognizer) - - let longPress = UILongPressGestureRecognizer(target: self, action: #selector(attemptChangeOfUserPermissions)) - stackView.addGestureRecognizer(longPress) - } - - private func set(title: String, subtitle: String) { - DispatchQueue.main.async { - self.titleLabel?.text = title - self.subtitleLabel?.text = subtitle - } - } - - private func updateNavigationItemTitleView() { - DispatchQueue.main.async { - self.titleLabel?.text = self.titleText - self.subtitleLabel?.text = self.subtitleText - } - } - - // MARK: Starting updates - - private func checkThumbnailsAndColorsBeforShowingGrid() { - let colors = app.database.pendingCapsForColorCreation - let thumbs = app.database.pendingCapForThumbnailCreation - guard colors == 0 && thumbs == 0 else { - app.database.startBackgroundWork() - showAlert("Please wait until all background work is completed. \(colors) colors and \(thumbs) thumbnails need to be created.", title: "Mosaic not ready") - return - } - showGrid() - } - - private func showGrid() { - let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController - guard let nav = navigationController as? NavigationController else { - return - } - if let tileImage = app.database.tileImage(named: "default") { - log("Showing existing tile image") - vc.load(tileImage: tileImage) - } else { - let tileImage = Database.TileImage(name: "default", width: 40, caps: []) - log("Showing default tile image") - vc.load(tileImage: tileImage) - } - nav.pushViewController(vc, animated: true) - nav.allowLandscape = true - } - - private func showOfflineDialog() { - let offline = app.database.isInOfflineMode - if offline { - print("Marking as online") - app.database.isInOfflineMode = false - app.database.startBackgroundWork() - self.showAlert("Offline mode was disabled", title: "Online") - } else { - print("Marking as offline") - app.database.isInOfflineMode = true - self.showAlert("Offline mode was enabled", title: "Offline") - } - } - - private func rename(cap: Cap, at indexPath: IndexPath) { - let detail = "Choose a new name for the cap" - askUserForText("Enter new name", detail: detail, existingText: cap.name, yesText: "Save") { text in - guard app.database.update(name: text, for: cap.id) else { - self.showAlert("Name could not be set.", title: "Update failed") - return - } - } - } - - private func saveNewCap(for image: UIImage) { - let detail = "Choose a name for the image" - askUserForText("Enter name", detail: detail, existingText: accessory!.searchBar.text, yesText: "Save") { text in - DispatchQueue.global(qos: .userInitiated).async { - guard app.database.createCap(image: image, name: text) else { - self.showAlert("Cap not added", title: "Database error") - return - } - self.accessory!.discardImage() - } - } - } - - private func updateShownCaps(_ newList: [Cap], insertedId id: Int) { - // Main queue - guard shownCaps.count == newList.count - 1 else { - log("Cap list refresh mismatch: was \(shownCaps.count), is \(newList.count)") - show(sortedCaps: newList) - return - } - guard let index = newList.firstIndex(where: { $0.id == id}) else { - log("Cap list refresh without new cap \(id)") - show(sortedCaps: newList) - return - } - - self.tableView.beginUpdates() - self.shownCaps = newList - let indexPath = IndexPath(row: index, section: 0) - self.tableView.insertRows(at: [indexPath], with: .automatic) - self.tableView.endUpdates() - } - - - // MARK: User interaction - - @objc private func attemptChangeOfUserPermissions() { - guard isUnlocked else { - attemptAppUnlock() - return - } - log("Locking app.") - app.lock() - isUnlocked = false - showAllCapsAndScrollToTop() - updateNavigationItemTitleView() - showAlert("The app was locked to prevent modifications.", title: "Locked") - } - - private func attemptAppUnlock() { - log("Presenting unlock dialog to user") - askUserForText("Enter pin", detail: "Enter the correct pin to unlock write permissions for the app.", placeholder: "Pin", yesText: "Unlock") { text in - guard let pin = Int(text), app.checkUnlock(with: pin) else { - self.unlockFailed() - return - } - self.unlockDidSucceed() - } - } - - private func unlockFailed() { - showAlert("The pin you entered is incorrect.", title: "Invalid pin") - } - - private func unlockDidSucceed() { - showAlert("The app was successfully unlocked.", title: "Unlocked") - isUnlocked = true - - showAllCapsAndScrollToTop() - updateNavigationItemTitleView() - } - - private func loadClassifier() -> Classifier? { - guard let model = app.database.storage.recognitionModel else { - return nil - } - return Classifier(model: model) - } - - private func askUserForText(_ title: String, detail: String, existingText: String? = nil, placeholder: String? = "Cap name", yesText: String, noText: String = "Cancel", confirmed: @escaping (_ text: String) -> Void) { - DispatchQueue.main.async { - let alertController = UIAlertController( - title: title, - message: detail, - preferredStyle: .alert) - - alertController.addTextField { textField in - textField.placeholder = placeholder - textField.keyboardType = .default - textField.text = existingText - } - - let action = UIAlertAction(title: yesText, style: .default) { _ in - guard let name = alertController.textFields?.first?.text else { - return - } - confirmed(name) - } - - let cancel = UIAlertAction(title: noText, style: .cancel) - - alertController.addAction(action) - alertController.addAction(cancel) - self.present(alertController, animated: true) - } - } - - private func presentUserBinaryChoice(_ title: String, detail: String, yesText: String, noText: String = "Cancel", dismissed: (() -> Void)? = nil, confirmed: @escaping () -> Void) { - let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert) - - let confirm = UIAlertAction(title: yesText, style: .default) { _ in - confirmed() - } - let cancel = UIAlertAction(title: noText, style: .cancel) { _ in - dismissed?() - } - alert.addAction(confirm) - alert.addAction(cancel) - DispatchQueue.main.async { - self.present(alert, animated: true) - } - } - - // MARK: Classification - - /// The similarity of the cap to the currently processed image - private func match(for cap: Int) -> Float? { - matches?[cap] - } - - private func clearClassifierMatches() { - matches = nil - } - - private func classify(image: UIImage) { - guard let classifier = self.classifier else { - return - } - DispatchQueue.global(qos: .userInitiated).async { - self.log("Classification starting...") - classifier.recognize(image: image) { matches in - guard let matches = matches else { - self.log("Failed to classify image") - self.matches = nil - return - } - self.log("Classification finished") - self.matches = matches - self.sortType = .match - self.sortAscending = false - self.showAllCapsAndScrollToTop() - DispatchQueue.global(qos: .background).async { - app.database.update(recognizedCaps: Set(matches.keys)) - } - } - } - } - - private func classifyDummyImage() { - guard let classifier = self.classifier else { - return - } - DispatchQueue.global(qos: .userInitiated).async { - classifier.recognize(image: UIImage(named: "launch")!) { matches in - guard let matches = matches else { - self.log("Failed to classify dummy image") - self.matches = nil - return - } - self.log("Dummy classification finished") - DispatchQueue.global(qos: .background).async { - app.database.update(recognizedCaps: Set(matches.keys)) - } - } - } - } - - // MARK: Finishing downloads - - private func didDownloadClassifier() { - guard let model = app.database.storage.recognitionModel else { - classifier = nil - return - } - classifier = Classifier(model: model) - guard let image = accessory!.currentImage else { - classifyDummyImage() - return - } - classify(image: image) - } - - // MARK: - Showing caps - - private func reloadCapsFromDatabase() { - caps = app.database?.caps ?? [] - showCaps() - } - - /** - Match all cap names against the given string and return matches. - - note: Each space-separated part of the string is matched individually - */ - private func showCaps(matching text: String? = nil) { - DispatchQueue.global(qos: .userInteractive).async { - self.searchText = text - guard let t = text else { - self.show(caps: self.caps) - return - } - let found = self.filter(caps: self.caps, matching: t) - self.show(caps: found) - } - } - - private func show(caps: [Cap]) { - show(sortedCaps: sorted(caps: caps)) - } - - private func show(sortedCaps caps: [Cap]) { - shownCaps = caps - DispatchQueue.main.async { - self.tableView.reloadData() - } - } - - private func filter(caps: [Cap], matching text: String) -> [Cap] { - let textParts = text.components(separatedBy: " ").filter { $0 != "" } - return caps.compactMap { cap -> Cap? in - // For each part of text, check if name contains it - for textItem in textParts { - if !cap.cleanName.contains(textItem) { - return nil - } - } - return cap - } - } - - private func sorted(caps: [Cap]) -> [Cap] { - if sortAscending { - switch sortType { - case .id: return caps.sorted { $0.id < $1.id } - case .count: return caps.sorted { $0.count < $1.count } - case .name: return caps.sorted { $0.name < $1.name } - case .match: return caps.sorted { match(for: $0.id) ?? 0 < match(for: $1.id) ?? 0 } - } - } else { - switch sortType { - case .id: return caps.sorted { $0.id > $1.id } - case .count: return caps.sorted { $0.count > $1.count } - case .name: return caps.sorted { $0.name > $1.name } - case .match: return caps.sorted { match(for: $0.id) ?? 0 > match(for: $1.id) ?? 0 } - } - } - } - - /// Resets the cap list to its original state, discarding any previous sorting. - private func showAllCapsByDescendingId() { - sortType = .id - sortAscending = false - showAllCapsAndScrollToTop() - } - - /// Display all caps in the table, and scrolls to the top - private func showAllCapsAndScrollToTop() { - showCaps() - tableViewScrollToTop() - } - - // MARK: - TableView - - /** - Scroll the table view to the top - */ - private func tableViewScrollToTop() { - guard shownCaps.count > 0 else { return } - let path = IndexPath(row: 0, section: 0) - DispatchQueue.main.async { - self.tableView.scrollToRow(at: path, at: .top, animated: true) - } - } -} - -// MARK: - SortControllerDelegate - -extension TableView: SortControllerDelegate { - - func sortController(didSelect sortType: SortCriteria, ascending: Bool) { - self.sortType = sortType - self.sortAscending = ascending - if sortType != .match { - clearClassifierMatches() - } - showAllCapsAndScrollToTop() - } -} - -// MARK: - CameraControllerDelegate - -extension TableView: CameraControllerDelegate { - - func didCapture(image: UIImage) { - guard let cap = capToAddImageTo else { - accessory!.showImageView(with: image) - classify(image: image) - return - } - - guard app.database.add(image: image, for: cap) else { - self.error("Could not save image") - return - } - log("Added image for cap \(cap)") - self.capToAddImageTo = nil - } - - func didCancel() { - capToAddImageTo = nil - } -} - -// MARK: - UITableViewDataSource - -extension TableView { - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cap") as! CapCell - let cap = shownCaps[indexPath.row] - - configure(cell: cell, for: cap) - return cell - } - - private func configure(cell: CapCell, for cap: Cap) { - let matchText = cap.matchLabelText(match: match(for: cap.id), appIsUnlocked: self.isUnlocked) - let countText = cap.countLabelText(appIsUnlocked: self.isUnlocked) - - cell.id = cap.id - cell.set(name: cap.name) - cell.set(matchLabel: matchText) - cell.set(countLabel: countText) - - if let image = imageProvider.image(for: cap.id) { - cell.set(image: image) - } else { - cell.set(image: nil) - app.database.downloadImage(for: cap.id) { _ in - // Delegate call will update image - } - } - } - - override func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return shownCaps.count - } -} - -// MARK: - UITableViewDelegate - -extension TableView { - - private func takeImage(for cap: Int) { - self.capToAddImageTo = cap - showCameraView() - } - - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - // Prevent unauthorized users from selecting caps - isUnlocked ? indexPath : nil - } - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - defer { - tableView.deselectRow(at: indexPath, animated: true) - } - // Prevent unauthorized users from making changes - guard isUnlocked else { - return - } - let cap = shownCaps[indexPath.row] - guard let image = accessory?.capImage.image else { - self.giveFeedback(.medium) - takeImage(for: cap.id) - return - } - guard app.database.add(image: image, for: cap.id) else { - self.giveFeedback(.heavy) - self.error("Could not save image") - return - } - self.giveFeedback(.medium) - // Delegate call will update cell - self.accessory?.discardImage() - } - - private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { - UIImpactFeedbackGenerator(style: style).impactOccurred() - } - - override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - // Prevent unauthorized users from making changes - guard isUnlocked else { - return nil - } - - let cap = shownCaps[indexPath.row] - - let rename = UIContextualAction(style: .normal, title: "Rename\ncap") { (_, _, success) in - success(true) - self.rename(cap: cap, at: indexPath) - self.giveFeedback(.medium) - } - rename.backgroundColor = .blue - - let image = UIContextualAction(style: .normal, title: "Change\nimage") { (_, _, success) in - self.giveFeedback(.medium) - let storyboard = UIStoryboard(name: "Main", bundle: nil) - let controller = storyboard.instantiateViewController(withIdentifier: "ImageSelector") as! ImageSelector - controller.cap = cap - controller.imageProvider = self.imageProvider - self.navigationController?.pushViewController(controller, animated: true) - success(true) - } - image.backgroundColor = .red - - return UISwipeActionsConfiguration(actions: [rename, image]) - } - - override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - let cap = shownCaps[indexPath.row] - - var actions = [UIContextualAction]() - // Prevent unauthorized users from making changes - if isUnlocked { - let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in - self.giveFeedback(.medium) - success(true) - DispatchQueue.global(qos: .userInitiated).async { - app.database.downloadImageCount(for: cap.id) - } - } - count.backgroundColor = .orange - actions.append(count) - } - - let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in - self.giveFeedback(.medium) - self.accessory?.hideImageView() - guard let image = self.imageProvider.image(for: cap.id, version: 0) else { - success(false) - return - } - self.classify(image: image) - success(true) - } - similar.backgroundColor = .blue - actions.append(similar) - - return UISwipeActionsConfiguration(actions: actions) - } -} - -// MARK: - Logging - -extension TableView: Logger { } - -// MARK: - Protocol DatabaseDelegate - -extension TableView: DatabaseDelegate { - - func database(needsUserConfirmation title: String, body: String, shouldProceed: @escaping (Bool) -> Void) { - presentUserBinaryChoice(title, detail: body, yesText: "Download", noText: "Later", dismissed: { - shouldProceed(false) - }) { - shouldProceed(true) - } - } - - func databaseHasNewClassifier() { - didDownloadClassifier() - } - - func database(completedBackgroundWorkItem title: String, subtitle: String) { - set(title: title, subtitle: subtitle) - } - - func database(didFailBackgroundWork title: String, subtitle: String) { - set(title: title, subtitle: subtitle) - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(10)) { - self.updateNavigationItemTitleView() - } - } - - func databaseDidFinishBackgroundWork() { -// set(title: "All tasks completed", subtitle: titleText) - self.updateNavigationItemTitleView() -// DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(5)) { -// self.updateNavigationItemTitleView() -// } - } - - func database(didAddCap cap: Cap) { - caps.append(cap) - updateNavigationItemTitleView() - guard let text = searchText else { - // All caps are shown - let newList = sorted(caps: caps) - updateShownCaps(newList, insertedId: cap.id) - return - } - guard filter(caps: [cap], matching: text) != [] else { - // Cap is not shown, so don't reload - return - } - let newList = sorted(caps: filter(caps: caps, matching: text)) - updateShownCaps(newList, insertedId: cap.id) - } - - func database(didChangeCap id: Int) { - updateNavigationItemTitleView() - guard let cap = app.database.cap(for: id) else { - log("Changed cap \(id) not found in database") - return - } - if let index = caps.firstIndex(where: { $0.id == id}) { - caps[index] = cap - } else { - log("Cap not found in full list") - } - if let index = shownCaps.firstIndex(where: { $0.id == id}) { - shownCaps[index] = cap - } - guard let cell = visibleCell(for: id) else { - return - } - configure(cell: cell, for: cap) - } - - func database(didLoadImageForCap cap: Int) { - DispatchQueue.main.async { - guard let cell = self.visibleCell(for: cap) else { - return - } - guard let image = self.imageProvider.image(for: cap) else { - self.log("No image for cap \(cap), although it should be loaded") - return - } - cell.set(image: image) - } - - } - - func databaseNeedsFullRefresh() { - reloadCapsFromDatabase() - } - - private func visibleCell(for cap: Int) -> CapCell? { - tableView.visibleCells - .map { $0 as! CapCell } - .first { $0.id == cap } - } -} - -// MARK: - Protocol CapSearchDelegate - -extension TableView: CapAccessoryDelegate { - - func capSearchWasDismissed() { - showAllCapsAndScrollToTop() - } - - func capSearch(didChange text: String) { - let cleaned = text.clean - guard cleaned != "" else { - self.showCaps(matching: nil) - return - } - self.showCaps(matching: cleaned) - } - - func capAccessoryDidDiscardImage() { - matches = nil - showAllCapsByDescendingId() - } - - func capAccessory(shouldSave image: UIImage) { - guard isUnlocked else { - return - } - saveNewCap(for: image) - } - - func capAccessoryCameraButtonPressed() { - showCameraView() - } - -} diff --git a/Caps/View Components/AlwaysShowPopup.swift b/Caps/View Components/AlwaysShowPopup.swift deleted file mode 100644 index aeb4f15..0000000 --- a/Caps/View Components/AlwaysShowPopup.swift +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2018, Ralf Ebert -// License https://opensource.org/licenses/MIT -// License https://creativecommons.org/publicdomain/zero/1.0/ -// Source https://www.ralfebert.de/ios-examples/uikit/choicepopover/ - -import UIKit - -/** - By default, when you use: - - ``` - controller.modalPresentationStyle = .popover - ``` - - in a horizontally compact environment (iPhone in portrait mode), this option behaves the same as fullScreen. - You can make it to always show a popover by using: - - ``` - let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller) - ``` - */ -class AlwaysPresentAsPopover : NSObject, UIPopoverPresentationControllerDelegate { - - // `sharedInstance` because the delegate property is weak - the delegate instance needs to be retained. - private static let sharedInstance = AlwaysPresentAsPopover() - - private override init() { - super.init() - } - - func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { - return .none - } - - static func configurePresentation(forController controller : UIViewController) -> UIPopoverPresentationController { - controller.modalPresentationStyle = .popover - let presentationController = controller.presentationController as! UIPopoverPresentationController - presentationController.delegate = AlwaysPresentAsPopover.sharedInstance - return presentationController - } - -} diff --git a/Caps/View Components/CropView.swift b/Caps/View Components/CropView.swift deleted file mode 100644 index 27525e9..0000000 --- a/Caps/View Components/CropView.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// CropView.swift -// CapFinder -// -// Created by User on 31.01.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - -//@IBDesignable -class CropView: UIView { - - @IBInspectable var lineColor: UIColor = UIColor.black - - @IBInspectable var lineWidth: Float = 2 - - @IBInspectable var relativeSize: Float = 0.6 { - didSet { size = CGFloat(relativeSize) / 2 } - } - - private var size: CGFloat = 0.3 - - override init(frame: CGRect) { - super.init(frame: frame) - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - override func draw(_ rect: CGRect) { - let height = rect.height - let width = rect.width - - let length = height > width ? width : height - - let center = CGPoint(x: width / 2, y: height / 2) - let path = UIBezierPath() - - path.lineWidth = CGFloat(self.lineWidth) - lineColor.setStroke() - - path.addArc( - withCenter: center, - radius: length * size, - startAngle: 0, - endAngle: .pi * 2, - clockwise: true) - - path.stroke() - } -} diff --git a/Caps/View Components/RoundedButton.swift b/Caps/View Components/RoundedButton.swift deleted file mode 100644 index 240c3bd..0000000 --- a/Caps/View Components/RoundedButton.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// RoundedButton.swift -// CapFinder -// -// Created by User on 01.02.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import UIKit - -@IBDesignable -class RoundedButton: UIButton { - - @IBInspectable var borderColor: UIColor = UIColor.black { - didSet { - layer.borderColor = borderColor.cgColor - } - } - - @IBInspectable var borderWidth: CGFloat = 0 { - didSet { - layer.borderWidth = borderWidth - } - } - //Normal state bg and border - @IBInspectable var normalBorderColor: UIColor? { - didSet { - layer.borderColor = normalBorderColor?.cgColor - } - } - - @IBInspectable var normalBackgroundColor: UIColor? { - didSet { - setBgColorForState(color: normalBackgroundColor, forState: []) - } - } - - - //Highlighted state bg and border - @IBInspectable var highlightedBorderColor: UIColor? - - @IBInspectable var highlightedBackgroundColor: UIColor? { - didSet { - setBgColorForState(color: highlightedBackgroundColor, forState: .highlighted) - } - } - - - private func setBgColorForState(color: UIColor?, forState: UIControl.State){ - self.backgroundColor = color -// if color != nil { -// self.backgroundColor = color! -// setBackgroundImage(UIImage.imageWithColor(color: color!), for: forState) -// } else { -// setBackgroundImage(nil, for: forState) -// } - } - - override func layoutSubviews() { - super.layoutSubviews() - - layer.cornerRadius = layer.frame.height / 2 - // layer.cornerRadius = min(layer.frame.height, layer.frame.width) / 2 - clipsToBounds = true - - if borderWidth > 0 { - if state == [] && layer.borderColor == normalBorderColor?.cgColor { - layer.borderColor = normalBorderColor?.cgColor - } else if state == .highlighted && highlightedBorderColor != nil { - layer.borderColor = highlightedBorderColor!.cgColor - } - } - } - -} - -extension RoundedButton { - - func set(template: String, with tint: UIColor) { - self.setImage(UIImage.templateImage(named: template), for: .normal) - self.tintColor = tint - self.borderColor = tint - } -} - -//Extension Required by RoundedButton to create UIImage from UIColor -extension UIImage { - class func imageWithColor(color: UIColor) -> UIImage { - let rect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1) - UIGraphicsBeginImageContextWithOptions(CGSize(width: 1, height: 1), false, 1.0) - color.setFill() - UIRectFill(rect) - let image: UIImage = UIGraphicsGetImageFromCurrentImageContext()! - UIGraphicsEndImageContext() - return image - } -} - diff --git a/Caps/View Components/RoundedImageView.swift b/Caps/View Components/RoundedImageView.swift deleted file mode 100644 index 4d70fbd..0000000 --- a/Caps/View Components/RoundedImageView.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// RoundedImageView.swift -// CapFinder -// -// Created by User on 22.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import UIKit - -class RoundedImageView: UIImageView { - - @IBInspectable var borderColor: UIColor = UIColor.black { - didSet { - layer.borderColor = borderColor.cgColor - } - } - - @IBInspectable var borderWidth: CGFloat = 0 { - didSet { - layer.borderWidth = borderWidth - } - } - - override func layoutSubviews() { - super.layoutSubviews() - - layer.cornerRadius = layer.frame.height / 2 - clipsToBounds = true - } -} diff --git a/Caps/Views/CapNameEntryView.swift b/Caps/Views/CapNameEntryView.swift new file mode 100644 index 0000000..4cd35a3 --- /dev/null +++ b/Caps/Views/CapNameEntryView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import SFSafeSymbols + +struct CapNameEntryView: View { + + @Binding + var name: String + + var body: some View { + TextField("Name", text: $name, prompt: Text("Enter name...")) + .padding(7) + .padding(.horizontal, 25) + .background(Color(.systemGray5)) + .cornerRadius(8) + .overlay( + HStack { + Image(systemSymbol: .squareAndPencil) + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + } + ) + } +} + +struct CapNameEntryView_Previews: PreviewProvider { + static var previews: some View { + CapNameEntryView(name: .constant("")) + .previewLayout(.fixed(width: 375, height: 50)) + } +} diff --git a/Caps/Views/CapRowView.swift b/Caps/Views/CapRowView.swift new file mode 100644 index 0000000..3e66131 --- /dev/null +++ b/Caps/Views/CapRowView.swift @@ -0,0 +1,77 @@ +import SwiftUI +import CachedAsyncImage + +struct CapRowView: View { + + private let imageSize: CGFloat = 70 + + private let sufficientImageCount = 10 + + let cap: Cap + + let match: Float? + + @EnvironmentObject + var database: Database + + var imageUrl: URL { + database.serverUrl.appendingPathComponent(cap.mainImagePath) + } + + var imageCountText: String { + guard cap.imageCount != 1 else { + return "\(cap.id) (1 image)" + } + return "\(cap.id) (\(cap.imageCount) images)" + } + + var body: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 0) { + + HStack(spacing: 8) { + Text(imageCountText) + .font(.footnote) + if !cap.classifiable(by: database.classifierVersion) { + Text("📵") + } + if cap.imageCount < sufficientImageCount { + Text("⚠️") + } + if database.hasPendingUpdates(for: cap.id) { + Text("⇅") + } + if database.hasPendingOperations(for: cap.id) { + ProgressView() + } + } + .padding(.top, 0) + .font(.footnote) + Text(cap.name) + .font(.headline) + .padding(.bottom, 3) + if let match = match { + Text("\(Int((match * 100).rounded())) % match") + .font(.footnote) + } + }//.padding(.vertical) + Spacer() + CachedAsyncImage(url: imageUrl, urlCache: database.imageCache) { image in + image.resizable() + } placeholder: { + ProgressView() + } + .frame(width: imageSize, height: imageSize) + .cornerRadius(imageSize / 2) + } + } +} + +struct CapRowView_Previews: PreviewProvider { + static var previews: some View { + CapRowView(cap: Cap(id: 123, name: "My new cap"), + match: 0.13) + .previewLayout(.fixed(width: 375, height: 80)) + .environmentObject(Database.mock) + } +} diff --git a/Caps/Views/GridView.swift b/Caps/Views/GridView.swift new file mode 100644 index 0000000..043e6bf --- /dev/null +++ b/Caps/Views/GridView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct GridView: View { + var body: some View { + Text("Grid view") + } +} + +struct GridView_Previews: PreviewProvider { + static var previews: some View { + GridView() + } +} diff --git a/Caps/Views/SearchField.swift b/Caps/Views/SearchField.swift new file mode 100644 index 0000000..03e716c --- /dev/null +++ b/Caps/Views/SearchField.swift @@ -0,0 +1,40 @@ +import SwiftUI +import SFSafeSymbols + +struct SearchField: View { + + @Binding + var searchString: String + + var body: some View { + TextField("Search", text: $searchString, prompt: Text("Search...")) + .padding(7) + .padding(.horizontal, 25) + .background(Color(.systemGray5)) + .cornerRadius(8) + .overlay( + HStack { + Image(systemSymbol: .magnifyingglass) + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + if searchString != "" { + Button(action: { + self.searchString = "" + }) { + Image(systemSymbol: .multiplyCircleFill) + .foregroundColor(.gray) + .padding(.trailing, 8) + } + } + } + ) + } +} + +struct SearchField_Previews: PreviewProvider { + static var previews: some View { + SearchField(searchString: .constant("")) + .previewLayout(.fixed(width: 375, height: 50)) + } +} diff --git a/Caps/Views/SettingsStatisticRow.swift b/Caps/Views/SettingsStatisticRow.swift new file mode 100644 index 0000000..07e5981 --- /dev/null +++ b/Caps/Views/SettingsStatisticRow.swift @@ -0,0 +1,30 @@ +// +// SettingsStatisticRow.swift +// Caps +// +// Created by CH on 26.05.22. +// + +import SwiftUI + +struct SettingsStatisticRow: View { + + let label: String + + let value: String + + var body: some View { + HStack { + Text(label) + Spacer() + Text(value) + } + } +} + +struct SettingsStatisticRow_Previews: PreviewProvider { + static var previews: some View { + SettingsStatisticRow(label: "Label", value: "Value") + .previewLayout(.fixed(width: 375, height: 40)) + } +} diff --git a/Caps/Views/SettingsView.swift b/Caps/Views/SettingsView.swift new file mode 100644 index 0000000..f0aec12 --- /dev/null +++ b/Caps/Views/SettingsView.swift @@ -0,0 +1,74 @@ +import SwiftUI + +struct SettingsView: View { + + @EnvironmentObject + var database: Database + + @Binding + var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text("Settings") + .font(.title2) + .bold() + Spacer() + Button(action: hide) { + Image(systemSymbol: .xmarkCircleFill) + .foregroundColor(.gray) + .font(.system(size: 26)) + } + } + Text("Statistics") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + SettingsStatisticRow(label: "Caps", value: "\(database.numberOfCaps)") + SettingsStatisticRow(label: "Total images", value: "\(database.numberOfImages)") + SettingsStatisticRow(label: "Images per cap", value: String(format: "%.1f", database.averageImageCount)) + }.padding(.horizontal) + Text("Classifier") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + SettingsStatisticRow(label: "Version", value: "\(database.classifierVersion)") + SettingsStatisticRow(label: "Recognized caps", value: "\(database.classifierClassCount)") + }.padding(.horizontal) + Text("Storage") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + SettingsStatisticRow(label: "Image cache", value: byteString(database.imageCacheSize)) + SettingsStatisticRow(label: "Database", value: byteString(database.databaseSize)) + SettingsStatisticRow(label: "Classifier", value: byteString(database.classifierSize)) + }.padding(.horizontal) + + Spacer() + } + .padding(.horizontal) + } + + private func hide() { + isPresented = false + } + + private func byteString(_ count: Int) -> String { + ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file) + } +} + +struct SettingsView_Previews: PreviewProvider { + static var previews: some View { + SettingsView(isPresented: .constant(true)) + .environmentObject(Database.mock) + .previewLayout(.fixed(width: 375, height: 330)) + } +} diff --git a/Caps/Views/SortCaseRowView.swift b/Caps/Views/SortCaseRowView.swift new file mode 100644 index 0000000..67dd879 --- /dev/null +++ b/Caps/Views/SortCaseRowView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct SortCaseRowView: View { + + @Binding + var selectedType: SortCriteria + + let type: SortCriteria + + var body: some View { + Button(action: { selectedType = type}) { + HStack { + Text(type.text) + .foregroundColor(.primary) + Spacer() + if selectedType == type { + Image(systemSymbol: .checkmark) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(UIColor.systemGroupedBackground)) + .cornerRadius(8) + } + } +} + +struct SortCaseRowView_Previews: PreviewProvider { + static var previews: some View { + SortCaseRowView(selectedType: .constant(.id), type: .id) + .previewLayout(.fixed(width: 375, height: 50)) + } +} diff --git a/Caps/Views/SortSelectionView.swift b/Caps/Views/SortSelectionView.swift new file mode 100644 index 0000000..b4ac5dd --- /dev/null +++ b/Caps/Views/SortSelectionView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +private extension Binding where Value == SortCriteria { + + func value() -> Binding { + return Binding(get:{ self.wrappedValue.rawValue }, + set: { self.wrappedValue = .init(rawValue: $0)!}) + } +} + + +struct SortSelectionView: View { + + let hasMatches: Bool + + @Binding + var isPresented: Bool + + @Binding + var sortType: SortCriteria + + @Binding + var sortAscending: Bool + + @Binding + var showGridView: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text("List Settings").font(.title2).bold() + Spacer() + Button(action: { isPresented = false }) { + Image(systemSymbol: .xmarkCircleFill) + .foregroundColor(.gray) + .font(.system(size: 26)) + } + } + .padding(.bottom) + Text("Sort by") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + Picker("Sort type", selection: $sortType.value()) { + Text(SortCriteria.id.text).tag(SortCriteria.id.rawValue) + Text(SortCriteria.name.text).tag(SortCriteria.name.rawValue) + Text(SortCriteria.count.text).tag(SortCriteria.count.rawValue) + if hasMatches { + Text(SortCriteria.match.text).tag(SortCriteria.match.rawValue) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.bottom) + Text("Sort order") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + Picker("Sort order", selection: $sortAscending) { + Text("Ascending").tag(true) + Text("Descending").tag(false) + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.bottom) + HStack { + Spacer() + Button(action: showGrid) { + HStack { + Image(systemSymbol: .circleHexagongrid) + Text("Show grid") + } + }.padding() + Spacer() + } + Spacer() + } + .padding(.horizontal) + } + + private func showGrid() { + showGridView = true + isPresented = false + } +} + +struct SortSelectionView_Previews: PreviewProvider { + static var previews: some View { + SortSelectionView( + hasMatches: true, + isPresented: .constant(true), + sortType: .constant(.id), + sortAscending: .constant(false), + showGridView: .constant(false)) + .previewLayout(.fixed(width: 375, height: 250)) + } +}