Start version 2

This commit is contained in:
Christoph Hagen 2022-06-10 21:20:49 +02:00
parent c119885743
commit 093d82893b
93 changed files with 2604 additions and 6509 deletions

View File

@ -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 = "<group>"; };
5904C33B2199D0260046A573 /* AlwaysShowPopup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlwaysShowPopup.swift; sourceTree = "<group>"; };
59158B1521E37B0200D90CB0 /* GridViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridViewController.swift; sourceTree = "<group>"; };
59158B1721E4C9AC00D90CB0 /* NavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = "<group>"; };
591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = "<group>"; };
591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = "<group>"; };
591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = "<group>"; };
88A89ECD25AF420F00323B64 /* DispatchGroup+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchGroup+Extensions.swift"; sourceTree = "<group>"; };
CE0A501024752A9800A9E753 /* TileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileImage.swift; sourceTree = "<group>"; };
CE0A5012247D745200A9E753 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
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 = "<group>"; };
CE56CED2209D81DE00932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
CE56CED4209D81E000932C01 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
CE56CED7209D81E000932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
CE56CED9209D81E000932C01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CE56CEE0209D83B200932C01 /* CapCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CapCell.swift; sourceTree = "<group>"; };
CE56CEE6209D83B300932C01 /* RoundedButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedButton.swift; sourceTree = "<group>"; };
CE56CEE7209D83B300932C01 /* CameraController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraController.swift; sourceTree = "<group>"; };
CE56CEE8209D83B300932C01 /* UIAlertControllerExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIAlertControllerExtensions.swift; sourceTree = "<group>"; };
CE56CEEA209D83B400932C01 /* RoundedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedImageView.swift; sourceTree = "<group>"; };
CE56CEEB209D83B400932C01 /* TableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TableView.swift; path = ../TableView.swift; sourceTree = "<group>"; };
CE56CEEC209D83B400932C01 /* UIViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = "<group>"; };
CE56CEED209D83B400932C01 /* ViewControllerExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewControllerExtensions.swift; sourceTree = "<group>"; };
CE56CEEE209D83B500932C01 /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; };
CE56CEEF209D83B500932C01 /* ImageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = "<group>"; };
CE56CEF0209D83B500932C01 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
CE56CEF1209D83B500932C01 /* Classifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Classifier.swift; sourceTree = "<group>"; };
CE56CEF2209D83B600932C01 /* CropView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CropView.swift; sourceTree = "<group>"; };
CE56CEF3209D83B600932C01 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
CE56CEF5209D83B600932C01 /* ImageSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSelector.swift; sourceTree = "<group>"; };
CE56CEF6209D83B700932C01 /* PhotoCaptureHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureHandler.swift; sourceTree = "<group>"; };
CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = "<group>"; };
CE5B7CFB24562673002E5C06 /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = "<group>"; };
CE5B7CFD245626D3002E5C06 /* Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Upload.swift; sourceTree = "<group>"; };
CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = "<group>"; };
CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extensions.swift"; sourceTree = "<group>"; };
CE85AA17246B012B002D1074 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = "<group>"; };
CEB269582445DB72004B74B3 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
CEB2695A2445E54E004B74B3 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = "<group>"; };
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 = "<group>"; };
E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
E25AAC7F283D855F006E9E7F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
E25AAC82283D855F006E9E7F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
E25AAC8A283D868D006E9E7F /* Classifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Classifier.swift; sourceTree = "<group>"; };
E25AAC8C283D86CF006E9E7F /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
E25AAC8F283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = "<group>"; };
E25AAC91283D8808006E9E7F /* CapData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapData.swift; sourceTree = "<group>"; };
E25AAC93283D88A4006E9E7F /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = "<group>"; };
E25AAC95283E14DF006E9E7F /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = "<group>"; };
E25AAC9A283E3395006E9E7F /* CapRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapRowView.swift; sourceTree = "<group>"; };
E2EA00C4283EA72000F7B269 /* SortCriteria.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCriteria.swift; sourceTree = "<group>"; };
E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortSelectionView.swift; sourceTree = "<group>"; };
E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortCaseRowView.swift; sourceTree = "<group>"; };
E2EA00CD283EBEB600F7B269 /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = "<group>"; };
E2EA00D0283EDD6300F7B269 /* CameraManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraManager.swift; sourceTree = "<group>"; };
E2EA00D2283EDDF700F7B269 /* CameraError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraError.swift; sourceTree = "<group>"; };
E2EA00D4283EDFA200F7B269 /* FrameManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameManager.swift; sourceTree = "<group>"; };
E2EA00D8283F5BB900F7B269 /* CameraView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = "<group>"; };
E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewModel.swift; sourceTree = "<group>"; };
E2EA00DC283F5C6A00F7B269 /* FrameView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameView.swift; sourceTree = "<group>"; };
E2EA00DE283F5CA000F7B269 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
E2EA00E0283F658E00F7B269 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
E2EA00E2283F662800F7B269 /* GridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridView.swift; sourceTree = "<group>"; };
E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStatisticRow.swift; sourceTree = "<group>"; };
E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImage+Extensions.swift"; sourceTree = "<group>"; };
E2EA00EC2841170100F7B269 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = "<group>"; };
E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapNameEntryView.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
};
CE56CECB209D81DD00932C01 /* Products */ = {
E25AAC79283D855D006E9E7F /* Products */ = {
isa = PBXGroup;
children = (
CE56CECA209D81DD00932C01 /* Caps.app */,
E25AAC78283D855D006E9E7F /* Caps.app */,
);
name = Products;
sourceTree = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
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 = "<group>";
};
/* 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 = "<group>";
};
CE56CED6209D81E000932C01 /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
CE56CED7209D81E000932C01 /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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 */;
}

View File

@ -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
}

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E25AAC77283D855D006E9E7F"
BuildableName = "Caps.app"
BlueprintName = "Caps"
ReferencedContainer = "container:Caps.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E25AAC77283D855D006E9E7F"
BuildableName = "Caps.app"
BlueprintName = "Caps"
ReferencedContainer = "container:Caps.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E25AAC77283D855D006E9E7F"
BuildableName = "Caps.app"
BlueprintName = "Caps"
ReferencedContainer = "container:Caps.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>CapCollector.xcscheme</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>CapCollector.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "B1F05379-5C9B-42D8-94ED-BB89F9571BE1"
uuid = "B7EB9D95-1101-4B5E-A288-5C0C223F7E4C"
type = "1"
version = "2.0">
</Bucket>

View File

@ -4,52 +4,18 @@
<dict>
<key>SchemeUserState</key>
<dict>
<key>CapCollector.xcscheme_^#shared#^_</key>
<key>Caps.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>SQLite (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>SQLite (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>SQLite (Playground) 3.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>SQLite (Playground) 4.xcscheme</key>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>E25AAC77283D855D006E9E7F</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>5</integer>
</dict>
<key>SQLite (Playground) 5.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>6</integer>
</dict>
<key>SQLite (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>2</integer>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>CapCollector.xcscheme</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>CapCollector.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>CapCollector.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>SQLite (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>SQLite (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>SQLite (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -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 { }

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -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
}
}
}

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
}

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

View File

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,46 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14105" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14088"/>
<capability name="Aspect ratio constraints" minToolsVersion="5.1"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="launch" translatesAutoresizingMaskIntoConstraints="NO" id="SQk-Vr-WK2">
<rect key="frame" x="62" y="218.5" width="250" height="250"/>
<constraints>
<constraint firstAttribute="width" constant="250" id="PfE-No-7DY"/>
<constraint firstAttribute="width" secondItem="SQk-Vr-WK2" secondAttribute="height" multiplier="1:1" id="b99-Ji-vq3"/>
</constraints>
</imageView>
</subviews>
<color key="backgroundColor" red="0.41960784313725491" green="0.57647058823529407" blue="0.37254901960784315" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="SQk-Vr-WK2" firstAttribute="centerX" secondItem="6Tk-OE-BBY" secondAttribute="centerX" id="8lU-QA-M23"/>
<constraint firstItem="SQk-Vr-WK2" firstAttribute="centerY" secondItem="6Tk-OE-BBY" secondAttribute="centerY" id="M5A-L2-2tA"/>
</constraints>
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="launch" width="1024" height="1024"/>
</resources>
</document>

View File

@ -1,429 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="19529" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="qlf-I7-aOI">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="19519"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Main Image-->
<scene sceneID="FO1-cz-6Q6">
<objects>
<viewController storyboardIdentifier="ImageSelector" id="xXN-Zh-iCd" customClass="ImageSelector" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="7RW-nN-0XQ">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="Vx1-tM-8mD" userLabel="Status Bar View">
<rect key="frame" x="0.0" y="24" width="375" height="20"/>
<color key="backgroundColor" red="0.14168914909999999" green="0.14168914909999999" blue="0.14168914909999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="KLt-lS-0bs">
<rect key="frame" x="0.0" y="44" width="375" height="623"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="10" minimumInteritemSpacing="0.0" id="m0z-hV-bp1">
<size key="itemSize" width="125" height="125"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Image" id="a3h-Rh-cyS" customClass="ImageCell" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="125" height="125"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="125" height="125"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dUp-gB-b4C" customClass="RoundedImageView" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="2" y="2" width="121" height="121"/>
</imageView>
</subviews>
</view>
<constraints>
<constraint firstItem="dUp-gB-b4C" firstAttribute="leading" secondItem="a3h-Rh-cyS" secondAttribute="leading" constant="2" id="Gc7-h0-pzJ"/>
<constraint firstAttribute="bottom" secondItem="dUp-gB-b4C" secondAttribute="bottom" constant="2" id="dKB-wM-BWw"/>
<constraint firstAttribute="trailing" secondItem="dUp-gB-b4C" secondAttribute="trailing" constant="2" id="eLS-W2-Owa"/>
<constraint firstItem="dUp-gB-b4C" firstAttribute="top" secondItem="a3h-Rh-cyS" secondAttribute="top" constant="2" id="wkf-il-wti"/>
</constraints>
<connections>
<outlet property="capView" destination="dUp-gB-b4C" id="RKW-nR-43M"/>
</connections>
</collectionViewCell>
</cells>
</collectionView>
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="pHE-7r-I7l" userLabel="Search View">
<rect key="frame" x="-1" y="668" width="377" height="49"/>
<constraints>
<constraint firstAttribute="height" constant="49" id="75Y-Pa-gf4"/>
</constraints>
</containerView>
</subviews>
<viewLayoutGuide key="safeArea" id="lmc-ZJ-cgu"/>
<constraints>
<constraint firstItem="lmc-ZJ-cgu" firstAttribute="top" secondItem="Vx1-tM-8mD" secondAttribute="top" constant="20" id="9Ai-nd-gYO"/>
<constraint firstItem="KLt-lS-0bs" firstAttribute="top" secondItem="Vx1-tM-8mD" secondAttribute="bottom" id="DdH-ID-K5R"/>
<constraint firstItem="lmc-ZJ-cgu" firstAttribute="bottom" secondItem="KLt-lS-0bs" secondAttribute="bottom" id="LgP-eI-hGc"/>
<constraint firstItem="lmc-ZJ-cgu" firstAttribute="trailing" secondItem="KLt-lS-0bs" secondAttribute="trailing" id="MFg-gH-TZJ"/>
<constraint firstItem="KLt-lS-0bs" firstAttribute="leading" secondItem="lmc-ZJ-cgu" secondAttribute="leading" id="MNg-7M-R1B"/>
<constraint firstItem="KLt-lS-0bs" firstAttribute="top" secondItem="lmc-ZJ-cgu" secondAttribute="top" id="MgU-8N-zWy"/>
<constraint firstItem="lmc-ZJ-cgu" firstAttribute="trailing" secondItem="Vx1-tM-8mD" secondAttribute="trailing" id="PqZ-Gb-7Le"/>
<constraint firstItem="Vx1-tM-8mD" firstAttribute="leading" secondItem="lmc-ZJ-cgu" secondAttribute="leading" id="WgW-hG-WpI"/>
<constraint firstItem="pHE-7r-I7l" firstAttribute="centerX" secondItem="lmc-ZJ-cgu" secondAttribute="centerX" id="fdQ-KZ-Nj4"/>
<constraint firstItem="lmc-ZJ-cgu" firstAttribute="bottom" secondItem="pHE-7r-I7l" secondAttribute="bottom" constant="-50" id="n33-vy-jhn"/>
<constraint firstItem="lmc-ZJ-cgu" firstAttribute="trailing" secondItem="pHE-7r-I7l" secondAttribute="trailing" constant="-1" id="sGP-rj-Nsr"/>
</constraints>
</view>
<tabBarItem key="tabBarItem" systemItem="history" id="Cxk-kX-pY9">
<color key="badgeColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</tabBarItem>
<navigationItem key="navigationItem" title="Main Image" id="Pt2-ad-tUu">
<barButtonItem key="backBarButtonItem" title="Back" id="e2a-wC-2Uv"/>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<connections>
<outlet property="collection" destination="KLt-lS-0bs" id="Jfu-ZQ-v2b"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="xnW-hF-IOj" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2906" y="1150"/>
</scene>
<!--Caps-->
<scene sceneID="SL6-kV-XF2">
<objects>
<tableViewController id="2ro-5c-16N" customClass="TableView" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="h37-NP-KdK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="cap" rowHeight="100" id="B8D-Az-9Y6" customClass="CapCell" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="0.0" y="44.5" width="375" height="100"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="B8D-Az-9Y6" id="oId-SE-Vio">
<rect key="frame" x="0.0" y="0.0" width="375" height="100"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Z8g-0m-rCx" customClass="RoundedImageView" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="2" y="2" width="96" height="96"/>
<color key="tintColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="width" secondItem="Z8g-0m-rCx" secondAttribute="height" multiplier="1:1" id="kh6-aY-eca"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="borderColor">
<color key="value" systemColor="secondaryLabelColor"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="number" keyPath="borderWidth">
<real key="value" value="1"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="# images" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="jmW-Bp-iEs">
<rect key="frame" x="106" y="10" width="59" height="17"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Some brand with a long name of more than one line" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bRK-Je-2c8">
<rect key="frame" x="106" y="29" width="261" height="47"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" text="71 % match" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="0S2-Kl-IEy">
<rect key="frame" x="292" y="10" width="75" height="17"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="Z8g-0m-rCx" firstAttribute="leading" secondItem="oId-SE-Vio" secondAttribute="leading" constant="2" id="5au-Ct-dWn"/>
<constraint firstItem="0S2-Kl-IEy" firstAttribute="trailing" secondItem="bRK-Je-2c8" secondAttribute="trailing" id="A3y-cc-l1F"/>
<constraint firstItem="bRK-Je-2c8" firstAttribute="leading" secondItem="jmW-Bp-iEs" secondAttribute="leading" id="FZc-hg-lot"/>
<constraint firstItem="0S2-Kl-IEy" firstAttribute="top" secondItem="jmW-Bp-iEs" secondAttribute="top" id="LWs-3Q-FHS"/>
<constraint firstItem="bRK-Je-2c8" firstAttribute="top" secondItem="jmW-Bp-iEs" secondAttribute="bottom" constant="2" id="Pfc-yw-wPd"/>
<constraint firstAttribute="trailing" secondItem="bRK-Je-2c8" secondAttribute="trailing" constant="8" id="S76-vj-hvt"/>
<constraint firstItem="jmW-Bp-iEs" firstAttribute="leading" secondItem="Z8g-0m-rCx" secondAttribute="trailing" constant="8" id="kHN-KC-vU5"/>
<constraint firstAttribute="bottom" secondItem="Z8g-0m-rCx" secondAttribute="bottom" constant="2" id="mFR-5k-cDP"/>
<constraint firstItem="0S2-Kl-IEy" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="jmW-Bp-iEs" secondAttribute="trailing" constant="10" id="nC9-nB-S66"/>
<constraint firstItem="Z8g-0m-rCx" firstAttribute="centerY" secondItem="oId-SE-Vio" secondAttribute="centerY" id="q9Q-78-QRL"/>
<constraint firstItem="jmW-Bp-iEs" firstAttribute="top" secondItem="oId-SE-Vio" secondAttribute="top" constant="10" id="yOO-Jh-C4e"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<connections>
<outlet property="capImage" destination="Z8g-0m-rCx" id="7vi-yn-4bH"/>
<outlet property="countLabel" destination="jmW-Bp-iEs" id="EEH-6Q-T8x"/>
<outlet property="matchLabel" destination="0S2-Kl-IEy" id="q1Q-F0-rIZ"/>
<outlet property="nameLabel" destination="bRK-Je-2c8" id="bju-xf-e6F"/>
</connections>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="2ro-5c-16N" id="fx9-na-Yab"/>
<outlet property="delegate" destination="2ro-5c-16N" id="BBl-FL-SqT"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="Caps" id="qe9-JJ-Ei4">
<barButtonItem key="leftBarButtonItem" title="Item" image="arrow.clockwise.icloud" catalog="system" id="SDv-mW-eqq">
<connections>
<action selector="updateInfo:forEvent:" destination="2ro-5c-16N" id="MDf-gM-ErC"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" title="Item" image="circle.hexagongrid" catalog="system" id="Bii-kx-Exm">
<connections>
<action selector="showMosaic:" destination="2ro-5c-16N" id="TSM-H0-ljH"/>
</connections>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="infoButton" destination="SDv-mW-eqq" id="hKf-A3-chi"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="xpG-Fd-7Lc" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1654" y="1150"/>
</scene>
<!--New image-->
<scene sceneID="5iQ-Nr-LFc">
<objects>
<viewController storyboardIdentifier="NewImageController" title="New image" id="oDF-NZ-j9r" customClass="CameraController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Y3e-A9-cGq">
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="75a-Qa-tvd" userLabel="Embedding Camera View">
<rect key="frame" x="0.0" y="0.0" width="375" height="647"/>
<subviews>
<view clipsSubviews="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="JR1-Im-yOB" userLabel="PicturePreview" customClass="CameraView" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="-80" y="-33" width="535" height="713"/>
<constraints>
<constraint firstAttribute="width" secondItem="JR1-Im-yOB" secondAttribute="height" multiplier="3:4" id="LL6-Z4-ep1"/>
</constraints>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="RWS-jY-sNX" customClass="CropView" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="-80" y="56" width="535" height="535"/>
<constraints>
<constraint firstAttribute="width" secondItem="RWS-jY-sNX" secondAttribute="height" multiplier="1:1" id="Buc-aU-mqr"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="number" keyPath="relativeSize">
<real key="value" value="0.40000000000000002"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="color" keyPath="lineColor">
<color key="value" systemColor="systemBlueColor"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="number" keyPath="lineWidth">
<real key="value" value="2"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</view>
</subviews>
<constraints>
<constraint firstItem="JR1-Im-yOB" firstAttribute="centerY" secondItem="75a-Qa-tvd" secondAttribute="centerY" id="I0g-m0-LPb"/>
<constraint firstItem="JR1-Im-yOB" firstAttribute="centerX" secondItem="75a-Qa-tvd" secondAttribute="centerX" id="PWv-L2-yWL"/>
<constraint firstItem="RWS-jY-sNX" firstAttribute="centerY" secondItem="JR1-Im-yOB" secondAttribute="centerY" id="gW5-a3-fBU"/>
<constraint firstItem="RWS-jY-sNX" firstAttribute="centerX" secondItem="JR1-Im-yOB" secondAttribute="centerX" id="sxd-Vu-qCf"/>
<constraint firstItem="JR1-Im-yOB" firstAttribute="leading" secondItem="75a-Qa-tvd" secondAttribute="leading" constant="-80" id="teJ-k1-aza"/>
<constraint firstItem="RWS-jY-sNX" firstAttribute="leading" secondItem="JR1-Im-yOB" secondAttribute="leading" id="xq5-uK-Qgb"/>
</constraints>
</view>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="mGZ-Eq-3Pr" userLabel="Bottom Bar">
<rect key="frame" x="0.0" y="567" width="375" height="80"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Q93-lY-5IF">
<rect key="frame" x="0.0" y="0.0" width="375" height="80"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
<constraints>
<constraint firstAttribute="height" constant="80" id="Mw4-4x-jsf"/>
</constraints>
<blurEffect style="regular"/>
</visualEffectView>
<button opaque="NO" contentMode="scaleToFill" verticalHuggingPriority="249" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Vaw-Dn-P4X">
<rect key="frame" x="147.5" y="567" width="80" height="80"/>
<constraints>
<constraint firstAttribute="width" secondItem="Vaw-Dn-P4X" secondAttribute="height" multiplier="1:1" id="dOA-ls-qFN"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<state key="normal" image="camera" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large" weight="regular"/>
</state>
<state key="disabled">
<color key="titleColor" white="0.33333333329999998" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<state key="highlighted">
<color key="titleColor" white="0.66666666669999997" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</state>
<connections>
<action selector="imageButtonPressed" destination="oDF-NZ-j9r" eventType="touchUpInside" id="101-8q-Ys4"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="mcC-De-ygo">
<rect key="frame" x="295" y="567" width="80" height="80"/>
<constraints>
<constraint firstAttribute="width" secondItem="mcC-De-ygo" secondAttribute="height" multiplier="1:1" id="sCS-gR-a47"/>
</constraints>
<state key="normal" image="xmark" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<connections>
<action selector="backButtonPressed" destination="oDF-NZ-j9r" eventType="touchUpInside" id="ZKP-ty-JVi"/>
</connections>
</button>
</subviews>
<viewLayoutGuide key="safeArea" id="oi0-LU-PyW"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="75a-Qa-tvd" firstAttribute="leading" secondItem="oi0-LU-PyW" secondAttribute="leading" id="3sA-U9-YPf"/>
<constraint firstItem="oi0-LU-PyW" firstAttribute="bottom" secondItem="75a-Qa-tvd" secondAttribute="bottom" id="44I-wq-3q0"/>
<constraint firstItem="mGZ-Eq-3Pr" firstAttribute="leading" secondItem="oi0-LU-PyW" secondAttribute="leading" id="6KA-j9-M2J"/>
<constraint firstItem="Vaw-Dn-P4X" firstAttribute="bottom" secondItem="mGZ-Eq-3Pr" secondAttribute="bottom" id="6MT-0x-PCE"/>
<constraint firstItem="Vaw-Dn-P4X" firstAttribute="centerX" secondItem="oi0-LU-PyW" secondAttribute="centerX" id="baa-SQ-aA8"/>
<constraint firstItem="mcC-De-ygo" firstAttribute="bottom" secondItem="mGZ-Eq-3Pr" secondAttribute="bottom" id="dpX-8L-TKB"/>
<constraint firstItem="mcC-De-ygo" firstAttribute="top" secondItem="mGZ-Eq-3Pr" secondAttribute="top" id="e4q-pi-Iby"/>
<constraint firstItem="mcC-De-ygo" firstAttribute="trailing" secondItem="mGZ-Eq-3Pr" secondAttribute="trailing" id="eOt-AO-Kwc"/>
<constraint firstItem="oi0-LU-PyW" firstAttribute="trailing" secondItem="mGZ-Eq-3Pr" secondAttribute="trailing" id="fq6-bZ-kzu"/>
<constraint firstItem="Vaw-Dn-P4X" firstAttribute="top" secondItem="mGZ-Eq-3Pr" secondAttribute="top" id="nSi-IN-buo"/>
<constraint firstItem="75a-Qa-tvd" firstAttribute="top" secondItem="oi0-LU-PyW" secondAttribute="top" id="uOs-bC-Ham"/>
<constraint firstItem="oi0-LU-PyW" firstAttribute="bottom" secondItem="mGZ-Eq-3Pr" secondAttribute="bottom" id="vRz-6U-FKY"/>
<constraint firstItem="oi0-LU-PyW" firstAttribute="trailing" secondItem="75a-Qa-tvd" secondAttribute="trailing" id="wnh-xb-MEk"/>
</constraints>
</view>
<tabBarItem key="tabBarItem" systemItem="search" id="eNS-Ka-b0t"/>
<modalPageSheetSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="cameraView" destination="JR1-Im-yOB" id="qau-yi-xCB"/>
<outlet property="cancelButton" destination="mcC-De-ygo" id="Rdr-gp-sDT"/>
<outlet property="cropView" destination="RWS-jY-sNX" id="9OR-8z-a1p"/>
<outlet property="imageButton" destination="Vaw-Dn-P4X" id="2mY-wk-jWZ"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="u9k-DO-1HN" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="2281" y="1150"/>
</scene>
<!--Grid View Controller-->
<scene sceneID="Hjr-cD-s35">
<objects>
<viewController storyboardIdentifier="GridView" id="wzG-WL-mtW" customClass="GridViewController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="9sc-l8-fcK">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<scrollView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="TCx-cV-mMG">
<rect key="frame" x="0.0" y="44" width="375" height="623"/>
</scrollView>
</subviews>
<viewLayoutGuide key="safeArea" id="WAE-if-wuA"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstItem="TCx-cV-mMG" firstAttribute="top" secondItem="WAE-if-wuA" secondAttribute="top" id="C5g-UD-eI2"/>
<constraint firstItem="WAE-if-wuA" firstAttribute="bottom" secondItem="TCx-cV-mMG" secondAttribute="bottom" id="J5D-Xs-Unl"/>
<constraint firstItem="TCx-cV-mMG" firstAttribute="leading" secondItem="WAE-if-wuA" secondAttribute="leading" id="fXw-7M-B0G"/>
<constraint firstItem="WAE-if-wuA" firstAttribute="trailing" secondItem="TCx-cV-mMG" secondAttribute="trailing" id="sY8-ka-loB"/>
</constraints>
</view>
<navigationItem key="navigationItem" id="bdd-GD-zBH">
<barButtonItem key="rightBarButtonItem" title="Colors" id="0qm-Vt-S3s">
<connections>
<action selector="toggleAverageColor:" destination="wzG-WL-mtW" id="EhG-8E-FcQ"/>
</connections>
</barButtonItem>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<connections>
<outlet property="scrollView" destination="TCx-cV-mMG" id="Isn-jV-DBf"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="9Os-vj-mkT" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="3526" y="1150"/>
</scene>
<!--Sort Controller-->
<scene sceneID="fFw-OX-Mag">
<objects>
<tableViewController storyboardIdentifier="SortController" id="xVJ-JZ-U8g" customClass="SortController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="LcF-G6-Sln">
<rect key="frame" x="0.0" y="0.0" width="150" height="310"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" reuseIdentifier="SortCell" textLabel="2cU-Pz-MYZ" rowHeight="40" style="IBUITableViewCellStyleDefault" id="vYb-0s-NQp">
<rect key="frame" x="0.0" y="49.5" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="vYb-0s-NQp" id="8bD-vd-HWF">
<rect key="frame" x="0.0" y="0.0" width="113.5" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Ascending" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="2cU-Pz-MYZ">
<rect key="frame" x="16" y="0.0" width="89.5" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<color key="backgroundColor" systemColor="tableCellGroupedBackgroundColor"/>
</tableViewCell>
</prototypes>
<sections/>
<connections>
<outlet property="dataSource" destination="xVJ-JZ-U8g" id="27V-wO-lvY"/>
<outlet property="delegate" destination="xVJ-JZ-U8g" id="TdR-BV-8pz"/>
</connections>
</tableView>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<size key="freeformSize" width="150" height="310"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="l35-YK-LmX" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="1834" y="644"/>
</scene>
<!--Navigation Controller-->
<scene sceneID="cDZ-9F-oGg">
<objects>
<navigationController automaticallyAdjustsScrollViewInsets="NO" id="qlf-I7-aOI" customClass="NavigationController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<toolbarItems/>
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="tO3-6d-IEo">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<nil name="viewControllers"/>
<connections>
<segue destination="2ro-5c-16N" kind="relationship" relationship="rootViewController" id="il4-pa-RJf"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ar3-Vx-90J" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="985" y="1150"/>
</scene>
</scenes>
<resources>
<image name="arrow.clockwise.icloud" catalog="system" width="128" height="88"/>
<image name="camera" catalog="system" width="128" height="94"/>
<image name="circle.hexagongrid" catalog="system" width="128" height="112"/>
<image name="xmark" catalog="system" width="128" height="113"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="secondarySystemBackgroundColor">
<color red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="systemBlueColor">
<color red="0.0" green="0.47843137254901963" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
<systemColor name="tableCellGroupedBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View File

@ -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"
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

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

18
Caps/CapsApp.swift Normal file
View File

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

View File

@ -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<UITouch>, 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 { }

View File

@ -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 { }

View File

@ -1,56 +0,0 @@
/*
See LICENSE.txt for this samples 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 {
}

339
Caps/ContentView.swift Normal file
View File

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

View File

@ -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<Int>("id")
static let columnName = Expression<String>("name")
static let columnCount = Expression<Int>("count")
static let columnMatched = Expression<Bool>("matched")
static let columnUploaded = Expression<Bool>("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
}

32
Caps/Data/CapData.swift Normal file
View File

@ -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
}
}

View File

@ -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
}

View File

@ -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<Double>("red")
static let columnGreen = Expression<Double>("green")
static let columnBlue = Expression<Double>("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<Int> {
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<Int> {
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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<Int>()
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<T>(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)
}
}
}

View File

@ -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
}
}

View File

@ -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 { }

View File

@ -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<String>("name")
static let columnWidth = Expression<Int>("width")
static let columnCaps = Expression<Data>("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
}
}
}

View File

@ -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<Int>("cap")
let rowCapVersion = Expression<Int>("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<Int> {
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 { }

View File

@ -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<Element>] {
guard !isEmpty, maxElements > 0 else {
return []
}
var result = [ArraySlice<Element>]()
var currentIndex = 0
while true {
let nextIndex = currentIndex + maxElements
if nextIndex >= count {
result.append(self[currentIndex..<count])
return result
}
result.append(self[currentIndex..<nextIndex])
currentIndex += maxElements
}
return result
}
}

View File

@ -0,0 +1,30 @@
import CoreGraphics
import VideoToolbox
extension CGImage {
static func create(from cvPixelBuffer: CVPixelBuffer?) -> 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)
}
}

View File

@ -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 {

View File

@ -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<T>(into value: T) -> T {
withUnsafeBytes {
$0.baseAddress!.load(as: T.self)
}
}
init<T>(from value: T) {
var target = value
self = Swift.withUnsafeBytes(of: &target) {
Data($0)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 : UIView>() -> T { // 2
return Bundle(for: type(of: self)).loadNibNamed(String(describing: type(of: self)), owner: self, options: nil)!.first! as! T
}
}

View File

@ -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
}
}

View File

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

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Caps</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>dbapi-8-emm</string>
<string>dbapi-2</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Take images to identify matching caps and register new ones</string>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
<key>UIApplicationShortcutItemIconType</key>
<string>UIApplicationShortcutIconTypeCapturePhoto</string>
<key>UIApplicationShortcutItemSubtitle</key>
<string>Compare a new image</string>
<key>UIApplicationShortcutItemTitle</key>
<string>Take image</string>
<key>UIApplicationShortcutItemType</key>
<string>firstShortcut</string>
<key>UIApplicationShortcutItemUserInfo</key>
<dict>
<key>firstShortcutKey1</key>
<string>firstShortcutKeyValue1</string>
</dict>
</dict>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDefault</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@ -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?
}

View File

@ -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
}
}

View File

@ -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..<tiles.count {
refresh(tile: tile, inVisibleRect: rect)
}
}
private func refresh(tile: Int, inVisibleRect rect: CGRect) {
if tileIsVisible(tile: tile, in: rect) {
show(tile: tile)
} else if let installed = installedTiles[tile] {
installed.removeFromSuperview()
installedTiles[tile] = nil
}
}
private func show(tile: Int) {
guard installedTiles[tile] == nil else {
return
}
makeTile(tile)
}
private func remove(tile: Int) {
installedTiles[tile]?.removeFromSuperview()
installedTiles[tile] = nil
}
private var visibleRect: CGRect {
let scale = scrollView.zoomScale
let offset = scrollView.contentOffset
let size = scrollView.visibleSize
let scaledOrigin = CGPoint(x: offset.x / scale, y: offset.y / scale)
let scaledSize = CGSize(width: size.width / scale, height: size.height / scale)
return CGRect(origin: scaledOrigin, size: scaledSize)
}
private func updateTiles() {
DispatchQueue.main.async {
self.showTiles(in: self.visibleRect)
}
}
}
extension GridViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return myView
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
updateTiles()
}
}
private extension Int {
var isEven: Bool {
return self % 2 == 0
}
}
extension GridViewController: Logger { }

View File

@ -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!
}

View File

@ -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..<cap.count {
if let image = imageProvider?.image(for: cap.id, version: version) {
log("Image \(version) already downloaded")
set(image, for: version)
} else {
log("Downloading image \(version)")
app.database.downloadImage(for: cap.id, version: version) { image in
self.set(image, for: version)
if image != nil {
self.log("Downloaded version \(version)")
} else {
self.log("Failed to download image \(version)")
}
}
}
}
}
private func set(_ image: UIImage?, for version: Int) {
self.images[version] = image
DispatchQueue.main.async {
self.collection.reloadItems(at: [IndexPath(row: version, section: 0)])
}
}
// MARK: Select
private func selectedImage(nr: Int) {
app.database.setMainImage(of: cap.id, to: nr)
}
}
// MARK: - UICollectionViewDataSource
extension ImageSelector: UICollectionViewDataSource {
func numberOfSections(in collectionView: UICollectionView) -> 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 { }

View File

@ -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.
}
}

View File

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

View File

@ -1,112 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="17156" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17125"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="SearchAndDisplayAccessory" customModule="CapCollector" customModuleProvider="target">
<connections>
<outlet property="cameraButton" destination="bIA-eq-Tn5" id="r0F-0j-Ve9"/>
<outlet property="capImage" destination="vQm-nH-J8o" id="bQK-Vu-Z1U"/>
<outlet property="imageHeightContraint" destination="Phy-Uy-08W" id="gc0-1X-MIz"/>
<outlet property="saveButton" destination="dt5-LD-28a" id="IYT-eN-3lb"/>
<outlet property="searchBar" destination="bCh-7y-t0w" id="8Tt-4h-Fkg"/>
</connections>
</placeholder>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="PassthroughView" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="414" height="145"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="B0c-fn-aK3">
<rect key="frame" x="0.0" y="90" width="414" height="55"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="Arf-oz-vtV">
<rect key="frame" x="0.0" y="0.0" width="414" height="55"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
<blurEffect style="regular"/>
</visualEffectView>
<searchBar contentMode="redraw" searchBarStyle="minimal" placeholder="Search caps" translucent="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bCh-7y-t0w">
<rect key="frame" x="0.0" y="90" width="359" height="55"/>
<textInputTraits key="textInputTraits" autocorrectionType="no" spellCheckingType="no" returnKeyType="search" smartDashesType="no" smartInsertDeleteType="no" smartQuotesType="no"/>
<scopeButtonTitles>
<string>Title</string>
<string>Title</string>
</scopeButtonTitles>
</searchBar>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="vQm-nH-J8o" customClass="RoundedImageView" customModule="CapCollector" customModuleProvider="target">
<rect key="frame" x="5" y="0.0" width="90" height="90"/>
<constraints>
<constraint firstAttribute="width" secondItem="vQm-nH-J8o" secondAttribute="height" multiplier="1:1" id="WHb-tV-k4S"/>
</constraints>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="color" keyPath="borderColor">
<color key="value" systemColor="secondaryLabelColor"/>
</userDefinedRuntimeAttribute>
<userDefinedRuntimeAttribute type="number" keyPath="borderWidth">
<real key="value" value="1"/>
</userDefinedRuntimeAttribute>
</userDefinedRuntimeAttributes>
</imageView>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="dt5-LD-28a" userLabel="Save Button">
<rect key="frame" x="5" y="0.0" width="90" height="90"/>
<constraints>
<constraint firstAttribute="height" constant="90" id="Phy-Uy-08W"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<connections>
<action selector="saveButtonPressed" destination="-1" eventType="touchUpInside" id="O49-6L-mNY"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="bIA-eq-Tn5" userLabel="Camera/Clear Button">
<rect key="frame" x="359" y="90" width="55" height="55"/>
<constraints>
<constraint firstAttribute="width" secondItem="bIA-eq-Tn5" secondAttribute="height" multiplier="1:1" id="O09-ww-bHE"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle0"/>
<state key="normal" image="camera" catalog="system">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<connections>
<action selector="cameraButtonPressed" destination="-1" eventType="touchUpInside" id="ooo-b8-Atj"/>
</connections>
</button>
</subviews>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<constraints>
<constraint firstItem="dt5-LD-28a" firstAttribute="top" secondItem="vQm-nH-J8o" secondAttribute="top" id="0fL-ow-Unw"/>
<constraint firstItem="dt5-LD-28a" firstAttribute="leading" secondItem="vQm-nH-J8o" secondAttribute="leading" id="BDw-lR-hYd"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="B0c-fn-aK3" secondAttribute="bottom" id="Cbb-yb-0av"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="bIA-eq-Tn5" secondAttribute="bottom" id="Geo-F0-pdI"/>
<constraint firstItem="bIA-eq-Tn5" firstAttribute="top" secondItem="bCh-7y-t0w" secondAttribute="top" id="IiT-eG-qfb"/>
<constraint firstItem="vQm-nH-J8o" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" constant="5" id="Kf4-2d-yxI"/>
<constraint firstItem="bCh-7y-t0w" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="M9M-He-vxM"/>
<constraint firstItem="vQm-nH-J8o" firstAttribute="top" secondItem="vUN-kp-3ea" secondAttribute="top" id="PPc-Zp-Vty"/>
<constraint firstItem="B0c-fn-aK3" firstAttribute="leading" secondItem="vUN-kp-3ea" secondAttribute="leading" id="Pp8-wt-rM7"/>
<constraint firstItem="dt5-LD-28a" firstAttribute="bottom" secondItem="vQm-nH-J8o" secondAttribute="bottom" id="VD3-fH-dsS"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="B0c-fn-aK3" secondAttribute="trailing" id="bri-jC-TDo"/>
<constraint firstItem="dt5-LD-28a" firstAttribute="trailing" secondItem="vQm-nH-J8o" secondAttribute="trailing" id="eg2-RT-iVc"/>
<constraint firstItem="bIA-eq-Tn5" firstAttribute="leading" secondItem="bCh-7y-t0w" secondAttribute="trailing" id="i7w-LO-9lv"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="trailing" secondItem="bIA-eq-Tn5" secondAttribute="trailing" id="kQV-mX-EoD"/>
<constraint firstItem="B0c-fn-aK3" firstAttribute="top" secondItem="bCh-7y-t0w" secondAttribute="top" id="l7d-ZC-pNp"/>
<constraint firstItem="vUN-kp-3ea" firstAttribute="bottom" secondItem="bCh-7y-t0w" secondAttribute="bottom" id="r8c-kO-KT5"/>
<constraint firstItem="bCh-7y-t0w" firstAttribute="top" secondItem="vQm-nH-J8o" secondAttribute="bottom" id="tBl-Ig-g9a"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>
<nil key="simulatedBottomBarMetrics"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<point key="canvasLocation" x="96" y="104.7976011994003"/>
</view>
</objects>
<resources>
<image name="camera" catalog="system" width="128" height="94"/>
<systemColor name="secondaryLabelColor">
<color red="0.23529411764705882" green="0.23529411764705882" blue="0.2627450980392157" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

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

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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
}
}

View File

@ -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
}
}

View File

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

View File

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

13
Caps/Views/GridView.swift Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,95 @@
import SwiftUI
private extension Binding where Value == SortCriteria {
func value() -> Binding<Int> {
return Binding<Int>(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))
}
}