diff --git a/CapCollector.xcodeproj/project.pbxproj b/CapCollector.xcodeproj/project.pbxproj index 0d75af1..b322ab5 100644 --- a/CapCollector.xcodeproj/project.pbxproj +++ b/CapCollector.xcodeproj/project.pbxproj @@ -3,59 +3,60 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ - 043EC7C35065DD26F6BB496F /* Pods_CapCollector.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 86546C4DAB5E47A540F6E8DD /* Pods_CapCollector.framework */; }; 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 */; }; 5970380D225737F800D21B55 /* LogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5970380C225737F800D21B55 /* LogViewController.swift */; }; - 599BC9DA22CBBDA90061BCDB /* Squeezenet.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 599BC9D922CBBDA90061BCDB /* Squeezenet.mlmodel */; }; - 599BC9DC22CDE2640061BCDB /* MobileNet.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 599BC9DB22CDE2640061BCDB /* MobileNet.mlmodel */; }; - 59C1BBA92174CBB800EC84BB /* SettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59C1BBA82174CBB800EC84BB /* SettingsController.swift */; }; - 59C1BBAB21762D9600EC84BB /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59C1BBAA21762D9600EC84BB /* UserDefaults.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 */; }; - CE56CEFD209D83B800932C01 /* NameFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE5209D83B300932C01 /* NameFile.swift */; }; CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE6209D83B300932C01 /* RoundedButton.swift */; }; CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE7209D83B300932C01 /* CameraController.swift */; }; - CE56CF00209D83B800932C01 /* UIAlertControllerExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE8209D83B300932C01 /* UIAlertControllerExtensions.swift */; }; - CE56CF01209D83B800932C01 /* DropBoxController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEE9209D83B400932C01 /* DropBoxController.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 /* DiskManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF0209D83B500932C01 /* DiskManager.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 /* UIImageExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF7209D83B700932C01 /* UIImageExtensions.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 */; }; + CE5B7D0024574CCA002E5C06 /* ServerDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFF24574CCA002E5C06 /* ServerDatabase.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 */; }; + CEC7F815245A2B1200B896B1 /* JGProgressHUD in Frameworks */ = {isa = PBXBuildFile; productRef = CEC7F814245A2B1200B896B1 /* JGProgressHUD */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 342A23CD7996DA1E7039C097 /* Pods-CapCollector.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CapCollector.release.xcconfig"; path = "Pods/Target Support Files/Pods-CapCollector/Pods-CapCollector.release.xcconfig"; sourceTree = ""; }; 5904C3392199C9FA0046A573 /* SortController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortController.swift; sourceTree = ""; }; 5904C33B2199D0260046A573 /* AlwaysShowPopup.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AlwaysShowPopup.swift; sourceTree = ""; }; 59158B1521E37B0200D90CB0 /* GridViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridViewController.swift; sourceTree = ""; }; 59158B1721E4C9AC00D90CB0 /* NavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationController.swift; sourceTree = ""; }; 591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = ""; }; + 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = ""; }; + 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = ""; }; 5970380C225737F800D21B55 /* LogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewController.swift; sourceTree = ""; }; - 599BC9D922CBBDA90061BCDB /* Squeezenet.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; name = Squeezenet.mlmodel; path = ../../../../Dropbox/Models/Squeezenet.mlmodel; sourceTree = ""; }; - 599BC9DB22CDE2640061BCDB /* MobileNet.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; name = MobileNet.mlmodel; path = ../../../../Dropbox/Models/MobileNet.mlmodel; sourceTree = ""; }; - 59C1BBA82174CBB800EC84BB /* SettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsController.swift; sourceTree = ""; }; - 59C1BBAA21762D9600EC84BB /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; - 86546C4DAB5E47A540F6E8DD /* Pods_CapCollector.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CapCollector.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CE56CECA209D81DD00932C01 /* CapCollector.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CapCollector.app; sourceTree = BUILT_PRODUCTS_DIR; }; CE56CECD209D81DE00932C01 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; CE56CED2209D81DE00932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; @@ -63,25 +64,30 @@ CE56CED7209D81E000932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; CE56CED9209D81E000932C01 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; CE56CEE0209D83B200932C01 /* CapCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CapCell.swift; sourceTree = ""; }; - CE56CEE5209D83B300932C01 /* NameFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NameFile.swift; sourceTree = ""; }; CE56CEE6209D83B300932C01 /* RoundedButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedButton.swift; sourceTree = ""; }; CE56CEE7209D83B300932C01 /* CameraController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraController.swift; sourceTree = ""; }; CE56CEE8209D83B300932C01 /* UIAlertControllerExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIAlertControllerExtensions.swift; sourceTree = ""; }; - CE56CEE9209D83B400932C01 /* DropBoxController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DropBoxController.swift; sourceTree = ""; }; CE56CEEA209D83B400932C01 /* RoundedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoundedImageView.swift; sourceTree = ""; }; CE56CEEB209D83B400932C01 /* TableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TableView.swift; path = ../TableView.swift; sourceTree = ""; }; CE56CEEC209D83B400932C01 /* UIViewExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIViewExtensions.swift; sourceTree = ""; }; CE56CEED209D83B400932C01 /* ViewControllerExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewControllerExtensions.swift; sourceTree = ""; }; CE56CEEE209D83B500932C01 /* CameraView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraView.swift; sourceTree = ""; }; CE56CEEF209D83B500932C01 /* ImageCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageCell.swift; sourceTree = ""; }; - CE56CEF0209D83B500932C01 /* DiskManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiskManager.swift; sourceTree = ""; }; + CE56CEF0209D83B500932C01 /* Storage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = ""; }; CE56CEF1209D83B500932C01 /* Classifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Classifier.swift; sourceTree = ""; }; CE56CEF2209D83B600932C01 /* CropView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CropView.swift; sourceTree = ""; }; CE56CEF3209D83B600932C01 /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; CE56CEF5209D83B600932C01 /* ImageSelector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageSelector.swift; sourceTree = ""; }; CE56CEF6209D83B700932C01 /* PhotoCaptureHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureHandler.swift; sourceTree = ""; }; - CE56CEF7209D83B700932C01 /* UIImageExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIImageExtensions.swift; sourceTree = ""; }; - DBD72193E502C23E06DA913D /* Pods-CapCollector.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CapCollector.debug.xcconfig"; path = "Pods/Target Support Files/Pods-CapCollector/Pods-CapCollector.debug.xcconfig"; sourceTree = ""; }; + CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = ""; }; + CE5B7CFB24562673002E5C06 /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = ""; }; + CE5B7CFD245626D3002E5C06 /* Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Upload.swift; sourceTree = ""; }; + CE5B7CFF24574CCA002E5C06 /* ServerDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDatabase.swift; sourceTree = ""; }; + CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = ""; }; + CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extensions.swift"; sourceTree = ""; }; + CE85AA17246B012B002D1074 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = ""; }; + CEB269582445DB72004B74B3 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; + CEB2695A2445E54E004B74B3 /* UIColor+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -89,37 +95,20 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 043EC7C35065DD26F6BB496F /* Pods_CapCollector.framework in Frameworks */, + CEB269572445DB56004B74B3 /* SQLite in Frameworks */, + CEC7F815245A2B1200B896B1 /* JGProgressHUD in Frameworks */, + CE5B7D032458C921002E5C06 /* Reachability in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0FE92EFB7AA01ED92CDE6BF3 /* Pods */ = { - isa = PBXGroup; - children = ( - DBD72193E502C23E06DA913D /* Pods-CapCollector.debug.xcconfig */, - 342A23CD7996DA1E7039C097 /* Pods-CapCollector.release.xcconfig */, - ); - name = Pods; - sourceTree = ""; - }; - 9EAE4B3CEE704AF443897B44 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 86546C4DAB5E47A540F6E8DD /* Pods_CapCollector.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; CE56CEC1209D81DD00932C01 = { isa = PBXGroup; children = ( CE56CECC209D81DD00932C01 /* CapCollector */, CE56CECB209D81DD00932C01 /* Products */, - 0FE92EFB7AA01ED92CDE6BF3 /* Pods */, - 9EAE4B3CEE704AF443897B44 /* Frameworks */, ); sourceTree = ""; }; @@ -136,11 +125,7 @@ children = ( CE56CECD209D81DE00932C01 /* AppDelegate.swift */, CE56CED1209D81DE00932C01 /* Main.storyboard */, - CE56CEF1209D83B500932C01 /* Classifier.swift */, - 599BC9D922CBBDA90061BCDB /* Squeezenet.mlmodel */, - 599BC9DB22CDE2640061BCDB /* MobileNet.mlmodel */, CEF3874D209D9378001C8D3C /* Capture */, - CEF3874E209D9390001C8D3C /* Sync */, CEF38750209D93D1001C8D3C /* Data */, CEF3874B209D932E001C8D3C /* View Components */, CEF3874F209D93A6001C8D3C /* Presentation */, @@ -176,7 +161,11 @@ isa = PBXGroup; children = ( CE56CEE8209D83B300932C01 /* UIAlertControllerExtensions.swift */, - CE56CEF7209D83B700932C01 /* UIImageExtensions.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 */, ); @@ -193,23 +182,16 @@ path = Capture; sourceTree = ""; }; - CEF3874E209D9390001C8D3C /* Sync */ = { - isa = PBXGroup; - children = ( - CE56CEE9209D83B400932C01 /* DropBoxController.swift */, - ); - path = Sync; - sourceTree = ""; - }; CEF3874F209D93A6001C8D3C /* Presentation */ = { isa = PBXGroup; children = ( CE56CEEB209D83B400932C01 /* TableView.swift */, + 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */, + 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */, 59158B1721E4C9AC00D90CB0 /* NavigationController.swift */, CE56CEE0209D83B200932C01 /* CapCell.swift */, CE56CEF5209D83B600932C01 /* ImageSelector.swift */, CE56CEEF209D83B500932C01 /* ImageCell.swift */, - 59C1BBA82174CBB800EC84BB /* SettingsController.swift */, 5904C3392199C9FA0046A573 /* SortController.swift */, 59158B1521E37B0200D90CB0 /* GridViewController.swift */, 5970380C225737F800D21B55 /* LogViewController.swift */, @@ -220,10 +202,13 @@ CEF38750209D93D1001C8D3C /* Data */ = { isa = PBXGroup; children = ( - 59C1BBAA21762D9600EC84BB /* UserDefaults.swift */, + CE56CEF1209D83B500932C01 /* Classifier.swift */, 591832CD21A2A97E00E5987D /* Cap.swift */, - CE56CEF0209D83B500932C01 /* DiskManager.swift */, - CE56CEE5209D83B300932C01 /* NameFile.swift */, + CE56CEF0209D83B500932C01 /* Storage.swift */, + CE5B7CFB24562673002E5C06 /* Download.swift */, + CE5B7CFD245626D3002E5C06 /* Upload.swift */, + CEB269582445DB72004B74B3 /* Database.swift */, + CE5B7CFF24574CCA002E5C06 /* ServerDatabase.swift */, ); path = Data; sourceTree = ""; @@ -235,17 +220,20 @@ isa = PBXNativeTarget; buildConfigurationList = CE56CEDC209D81E000932C01 /* Build configuration list for PBXNativeTarget "CapCollector" */; buildPhases = ( - 3E8A16B5B7B80A2451075442 /* [CP] Check Pods Manifest.lock */, CE56CEC6209D81DD00932C01 /* Sources */, CE56CEC7209D81DD00932C01 /* Frameworks */, CE56CEC8209D81DD00932C01 /* Resources */, - 135644FD94500ABE4DA09082 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = CapCollector; + packageProductDependencies = ( + CEB269562445DB56004B74B3 /* SQLite */, + CE5B7D022458C921002E5C06 /* Reachability */, + CEC7F814245A2B1200B896B1 /* JGProgressHUD */, + ); productName = CapCollector; productReference = CE56CECA209D81DD00932C01 /* CapCollector.app */; productType = "com.apple.product-type.application"; @@ -262,7 +250,7 @@ TargetAttributes = { CE56CEC9209D81DD00932C01 = { CreatedOnToolsVersion = 9.4; - LastSwiftMigration = 1020; + LastSwiftMigration = 1100; SystemCapabilities = { com.apple.BackgroundModes = { enabled = 0; @@ -280,6 +268,11 @@ Base, ); mainGroup = CE56CEC1209D81DD00932C01; + packageReferences = ( + CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */, + CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */, + CEC7F813245A2B1200B896B1 /* XCRemoteSwiftPackageReference "JGProgressHUD" */, + ); productRefGroup = CE56CECB209D81DD00932C01 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -295,6 +288,7 @@ buildActionMask = 2147483647; files = ( CE56CED8209D81E000932C01 /* LaunchScreen.storyboard in Resources */, + 591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */, CE56CED5209D81E000932C01 /* Assets.xcassets in Resources */, CE56CED3209D81DE00932C01 /* Main.storyboard in Resources */, ); @@ -302,82 +296,43 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 135644FD94500ABE4DA09082 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${SRCROOT}/Pods/Target Support Files/Pods-CapCollector/Pods-CapCollector-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/Alamofire/Alamofire.framework", - "${BUILT_PRODUCTS_DIR}/SwiftyDropbox/SwiftyDropbox.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Alamofire.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyDropbox.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-CapCollector/Pods-CapCollector-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 3E8A16B5B7B80A2451075442 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-CapCollector-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ CE56CEC6209D81DD00932C01 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 59C1BBA92174CBB800EC84BB /* SettingsController.swift in Sources */, CE56CF09209D83B800932C01 /* Classifier.swift in Sources */, 5904C33A2199C9FA0046A573 /* SortController.swift in Sources */, CE56CF0B209D83B800932C01 /* Logger.swift in Sources */, - CE56CF01209D83B800932C01 /* DropBoxController.swift in Sources */, CE56CF04209D83B800932C01 /* UIViewExtensions.swift in Sources */, 59158B1821E4C9AC00D90CB0 /* NavigationController.swift in Sources */, 591832CE21A2A97E00E5987D /* Cap.swift in Sources */, - CE56CF08209D83B800932C01 /* DiskManager.swift in Sources */, - CE56CF0F209D83B800932C01 /* UIImageExtensions.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 */, - CE56CEFD209D83B800932C01 /* NameFile.swift in Sources */, - 59C1BBAB21762D9600EC84BB /* UserDefaults.swift in Sources */, + CE5B7CFE245626D3002E5C06 /* Upload.swift in Sources */, CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */, CE56CF0D209D83B800932C01 /* ImageSelector.swift in Sources */, CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */, + CE5B7D0024574CCA002E5C06 /* ServerDatabase.swift in Sources */, CE56CF05209D83B800932C01 /* ViewControllerExtensions.swift in Sources */, 5970380D225737F800D21B55 /* LogViewController.swift in Sources */, CE56CF0E209D83B800932C01 /* PhotoCaptureHandler.swift in Sources */, CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */, CE56CF07209D83B800932C01 /* ImageCell.swift in Sources */, - CE56CF00209D83B800932C01 /* UIAlertControllerExtensions.swift in Sources */, CE56CF06209D83B800932C01 /* CameraView.swift in Sources */, CE56CF0A209D83B800932C01 /* CropView.swift in Sources */, 5904C33C2199D0260046A573 /* AlwaysShowPopup.swift in Sources */, - 599BC9DC22CDE2640061BCDB /* MobileNet.mlmodel in Sources */, - 599BC9DA22CBBDA90061BCDB /* Squeezenet.mlmodel in Sources */, + CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */, + CEB269592445DB72004B74B3 /* Database.swift in Sources */, CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */, CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */, + CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -519,44 +474,46 @@ }; CE56CEDD209D81E000932C01 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DBD72193E502C23E06DA913D /* Pods-CapCollector.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = H8WR4M6QQ4; INFOPLIST_FILE = CapCollector/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = christophhagen.CapCollector; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; CE56CEDE209D81E000932C01 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 342A23CD7996DA1E7039C097 /* Pods-CapCollector.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = H8WR4M6QQ4; INFOPLIST_FILE = CapCollector/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = christophhagen.CapCollector; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 4.2; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; }; name = Release; @@ -583,6 +540,51 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ashleymills/Reachability.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.0.0; + }; + }; + CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stephencelis/SQLite.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.12.2; + }; + }; + CEC7F813245A2B1200B896B1 /* XCRemoteSwiftPackageReference "JGProgressHUD" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/JonasGessner/JGProgressHUD"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.1.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE5B7D022458C921002E5C06 /* Reachability */ = { + isa = XCSwiftPackageProductDependency; + package = CE5B7D012458C921002E5C06 /* XCRemoteSwiftPackageReference "Reachability" */; + productName = Reachability; + }; + CEB269562445DB56004B74B3 /* SQLite */ = { + isa = XCSwiftPackageProductDependency; + package = CEB269552445DB56004B74B3 /* XCRemoteSwiftPackageReference "SQLite" */; + productName = SQLite; + }; + CEC7F814245A2B1200B896B1 /* JGProgressHUD */ = { + isa = XCSwiftPackageProductDependency; + package = CEC7F813245A2B1200B896B1 /* XCRemoteSwiftPackageReference "JGProgressHUD" */; + productName = JGProgressHUD; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = CE56CEC2209D81DD00932C01 /* Project object */; } diff --git a/CapCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/CapCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata index 5b92964..919434a 100644 --- a/CapCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ b/CapCollector.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..906827f --- /dev/null +++ b/CapCollector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,34 @@ +{ + "object": { + "pins": [ + { + "package": "JGProgressHUD", + "repositoryURL": "https://github.com/JonasGessner/JGProgressHUD", + "state": { + "branch": null, + "revision": "08d130dd614a743f813286f096804c43a6ffa3f6", + "version": "2.1.0" + } + }, + { + "package": "Reachability", + "repositoryURL": "https://github.com/ashleymills/Reachability.swift", + "state": { + "branch": null, + "revision": "98e968e7b6c1318fb61df23e347bc319761e8acb", + "version": "5.0.0" + } + }, + { + "package": "SQLite.swift", + "repositoryURL": "https://github.com/stephencelis/SQLite.swift", + "state": { + "branch": null, + "revision": "0a9893ec030501a3956bee572d6b4fdd3ae158a1", + "version": "0.12.2" + } + } + ] + }, + "version": 1 +} diff --git a/CapCollector.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist b/CapCollector.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist index 0dc0810..6ac819e 100644 --- a/CapCollector.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/CapCollector.xcodeproj/xcuserdata/User.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + CapCollector.xcscheme_^#shared#^_ + + orderHint + 0 + diff --git a/CapCollector.xcworkspace/contents.xcworkspacedata b/CapCollector.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 2407818..0000000 --- a/CapCollector.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/CapCollector.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/CapCollector.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/CapCollector.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/CapCollector.xcworkspace/xcuserdata/christoph.xcuserdatad/IDEFindNavigatorScopes.plist b/CapCollector.xcworkspace/xcuserdata/christoph.xcuserdatad/IDEFindNavigatorScopes.plist deleted file mode 100644 index 5dd5da8..0000000 --- a/CapCollector.xcworkspace/xcuserdata/christoph.xcuserdatad/IDEFindNavigatorScopes.plist +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/CapCollector.xcworkspace/xcuserdata/christoph.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/CapCollector.xcworkspace/xcuserdata/christoph.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist deleted file mode 100644 index ed9a9b4..0000000 --- a/CapCollector.xcworkspace/xcuserdata/christoph.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/CapCollector/AppDelegate.swift b/CapCollector/AppDelegate.swift index 300f36e..082a9f2 100644 --- a/CapCollector/AppDelegate.swift +++ b/CapCollector/AppDelegate.swift @@ -8,70 +8,93 @@ import UIKit import CoreData -import SwiftyDropbox -/** - TODO: - - Mosaic: Prevent swap of tiles when tapping the free space at the right edge - - Show banner with number of unmatched caps when using camera comparison - - Feature: Create mosaic from image - - Feature: Delete cap - - Feature: Delete image of cap - */ +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("GridController: Load and save current mosaic") +#warning("GridController: Add option to specify image width") +#warning("TableView: Fix blur background of search bar after transition") +#warning("TableView: Add banner to jump down to unmatched caps / bottom") var shouldLaunchCamera = false +var app: AppDelegate! + @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - enum ShortcutIdentifier: String { - case first - - // MARK: - Initializers - - init?(fullType: String) { - guard let last = fullType.components(separatedBy: ".").last else { return nil } - self.init(rawValue: last) - } - - // MARK: - Properties - - var type: String { - return Bundle.main.bundleIdentifier! + ".\(self.rawValue)" - } - } - - // MARK: - Static Properties + // 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 { + UIStoryboard(name: "Main", bundle: nil) + } + + let documentsFolder = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + + var database: Database! + + var storage: Storage! + + var reachability: Reachability! + + var needsDownload = false + + var dbUrl: URL { + documentsFolder.appendingPathComponent("db.sqlite3") + } + + let serverUrl = URL(string: "https://cc.ssl443.org")! func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + app = self + + storage = Storage(in: documentsFolder) + reachability = try! Reachability() + + //resetToFactoryState() + + needsDownload = !FileManager.default.fileExists(atPath: dbUrl.path) + database = Database(url: dbUrl, server: serverUrl) + + if needsDownload { + log("New database created") + } else { + let size = (try! FileManager.default.attributesOfItem(atPath: dbUrl.path) as NSDictionary).fileSize() + log("Loaded \(database.capCount) caps, database size: \(size) bytes") + + } - DropboxClientsManager.setupWithAppKey("n81tx2g638wuffl") - DiskManager.setupOnFirstLaunch() return true } - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - DropboxController.shared.handle(url: url) - 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) } private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { - event("Shortcut pressed") + log("Shortcut pressed") shouldLaunchCamera = true return true } func applicationDidBecomeActive(_ application: UIApplication) { - Cap.uploadRemainingImages() + app.database?.uploadRemainingImages() guard shouldLaunchCamera else { return } shouldLaunchCamera = false if let c = (frontmostViewController as? UINavigationController)?.topViewController as? TableView { - c.performSegue(withIdentifier: "showCamera", sender: nil) + c.showCameraView() } } @@ -97,7 +120,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } -extension AppDelegate: Logger { - static let logToken = "[AppDelegate]" - -} +extension AppDelegate: Logger { } diff --git a/CapCollector/Assets.xcassets/mosaic.imageset/Contents.json b/CapCollector/Assets.xcassets/mosaic.imageset/Contents.json new file mode 100644 index 0000000..965d24d --- /dev/null +++ b/CapCollector/Assets.xcassets/mosaic.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "picture28.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "picture56.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "picture84.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CapCollector/Assets.xcassets/mosaic.imageset/picture28.png b/CapCollector/Assets.xcassets/mosaic.imageset/picture28.png new file mode 100644 index 0000000..4ecaeb3 Binary files /dev/null and b/CapCollector/Assets.xcassets/mosaic.imageset/picture28.png differ diff --git a/CapCollector/Assets.xcassets/mosaic.imageset/picture56.png b/CapCollector/Assets.xcassets/mosaic.imageset/picture56.png new file mode 100644 index 0000000..7adcab3 Binary files /dev/null and b/CapCollector/Assets.xcassets/mosaic.imageset/picture56.png differ diff --git a/CapCollector/Assets.xcassets/mosaic.imageset/picture84.png b/CapCollector/Assets.xcassets/mosaic.imageset/picture84.png new file mode 100644 index 0000000..a696425 Binary files /dev/null and b/CapCollector/Assets.xcassets/mosaic.imageset/picture84.png differ diff --git a/CapCollector/Assets.xcassets/settings.imageset/Contents.json b/CapCollector/Assets.xcassets/settings.imageset/Contents.json new file mode 100644 index 0000000..bb670f1 --- /dev/null +++ b/CapCollector/Assets.xcassets/settings.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "button_settings_white@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "button_settings_white@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "button_settings_white@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@1x.png b/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@1x.png new file mode 100644 index 0000000..f8ca6af Binary files /dev/null and b/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@1x.png differ diff --git a/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@2x.png b/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@2x.png new file mode 100644 index 0000000..4b3fd71 Binary files /dev/null and b/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@2x.png differ diff --git a/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@3x.png b/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@3x.png new file mode 100644 index 0000000..06394bd Binary files /dev/null and b/CapCollector/Assets.xcassets/settings.imageset/button_settings_white@3x.png differ diff --git a/CapCollector/Base.lproj/Main.storyboard b/CapCollector/Base.lproj/Main.storyboard index fa3fb58..ee3cbde 100644 --- a/CapCollector/Base.lproj/Main.storyboard +++ b/CapCollector/Base.lproj/Main.storyboard @@ -1,291 +1,27 @@ - - - - + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - + + @@ -318,13 +54,12 @@ - + - @@ -346,25 +81,119 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + @@ -382,7 +211,7 @@ - + @@ -399,73 +228,53 @@ - + + + - - + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + - - - - + + + @@ -474,15 +283,16 @@ + - + - + @@ -493,10 +303,10 @@ - + - + @@ -512,46 +322,14 @@ + - - - - - - - - - - - - - Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda. - - - - - - - - - - - - - - - - - - - - - + @@ -560,8 +338,7 @@ - - + @@ -569,19 +346,19 @@ - + - + @@ -591,73 +368,73 @@ - + - + - + - + - + - + - + - + @@ -673,252 +450,30 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Title - Title - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + + - - - - + - + + - - + - diff --git a/CapCollector/Capture/CameraController.swift b/CapCollector/Capture/CameraController.swift index c2f1066..a17d720 100644 --- a/CapCollector/Capture/CameraController.swift +++ b/CapCollector/Capture/CameraController.swift @@ -17,16 +17,16 @@ protocol CameraControllerDelegate { class CameraController: UIViewController { - private let imageSize = 299 // New for XCode models, 227/229 for turicreate - // MARK: Outlets - @IBOutlet weak var imageButton: RoundedButton! + @IBOutlet weak var imageButton: UIButton! @IBOutlet weak var cropView: CropView! - @IBOutlet weak var cancelButton: RoundedButton! + @IBOutlet weak var cancelButton: UIButton! + @IBOutlet weak var bottomBar: UIView! + @IBOutlet weak var cameraView: CameraView! { didSet { cameraView.configure() @@ -89,12 +89,24 @@ class CameraController: UIViewController { } private func setTintColor() { - let tint = AppDelegate.tintColor - cropView.lineColor = tint - imageButton.borderColor = tint - imageButton.imageView?.tintColor = tint - cancelButton.borderColor = tint - cancelButton.set(template: "cancel", with: tint) + let blur = UIBlurEffect(style: .systemThinMaterial) + let a = UIVisualEffectView(effect: blur) + a.translatesAutoresizingMaskIntoConstraints = false + bottomBar.backgroundColor = nil + bottomBar.insertSubview(a, at: 0) + //bottomBar.addSubview(a) + let t = bottomBar.topAnchor.constraint(equalTo: a.topAnchor) + let b = bottomBar.bottomAnchor.constraint(equalTo: a.bottomAnchor) + let l = bottomBar.leadingAnchor.constraint(equalTo: a.leadingAnchor) + let r = bottomBar.trailingAnchor.constraint(equalTo: a.trailingAnchor) + bottomBar.addConstraints([t,b,l,r]) + //let tint = AppDelegate.tintColor + //cropView.lineColor = tint + //imageButton.borderColor = tint + imageButton.imageView?.tintColor = .systemBlue + + //cancelButton.borderColor = tint + //cancelButton.set(template: "cancel", with: tint) } private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { @@ -107,8 +119,8 @@ class CameraController: UIViewController { 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) + preferredStyle: .alert)//, + //blurStyle: .dark) let okAction = UIAlertAction(title: "OK", style: .cancel, handler: nil) alert.addAction(okAction) @@ -132,10 +144,9 @@ class CameraController: UIViewController { extension CameraController: PhotoCaptureHandlerDelegate { func didCapture(_ image: UIImage?) { - event("Image captured") + log("Image captured") let factor = CGFloat(cropView.relativeSize) self.dismiss(animated: true) - let size = CGSize(width: imageSize, height: imageSize) guard let img = image else { self.error("No image captured") return @@ -151,14 +162,10 @@ extension CameraController: PhotoCaptureHandlerDelegate { return } - // Only use 227 x 227 image - let scaled = masked.resize(to: size) + let scaled = masked.resize(to: Cap.imageSize) delegate!.didCapture(image: scaled) } } -extension CameraController: Logger { - - static var logToken = "[Camera]" -} +extension CameraController: Logger { } diff --git a/CapCollector/Capture/CameraView.swift b/CapCollector/Capture/CameraView.swift index f5fc943..92e46e7 100644 --- a/CapCollector/Capture/CameraView.swift +++ b/CapCollector/Capture/CameraView.swift @@ -191,7 +191,7 @@ class CameraView: UIView { photoOutput.isHighResolutionCaptureEnabled = true photoOutput.isDepthDataDeliveryEnabled = false - photoOutput.isDualCameraDualPhotoDeliveryEnabled = false + //photoOutput.isDualCameraDualPhotoDeliveryEnabled = false photoOutput.isLivePhotoCaptureEnabled = false session.commitConfiguration() } @@ -200,7 +200,7 @@ class CameraView: UIView { 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) - event("Focusing on point (\(focusPoint.x),\(focusPoint.y))") + log("Focusing on point (\(focusPoint.x),\(focusPoint.y))") if let device = cameraDevice { do { try device.lockForConfiguration() @@ -221,7 +221,4 @@ class CameraView: UIView { } } -extension CameraView: Logger { - - static let logToken = "CameraView" -} +extension CameraView: Logger { } diff --git a/CapCollector/Capture/PhotoCaptureHandler.swift b/CapCollector/Capture/PhotoCaptureHandler.swift index b854fd8..ee666d9 100644 --- a/CapCollector/Capture/PhotoCaptureHandler.swift +++ b/CapCollector/Capture/PhotoCaptureHandler.swift @@ -7,6 +7,7 @@ Photo capture delegate. import AVFoundation import Photos +import UIKit protocol PhotoCaptureHandlerDelegate { @@ -32,13 +33,13 @@ extension PhotoCaptureHandler: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { guard error == nil else { - print("PhotoCaptureHandler: \(error!)") + log("PhotoCaptureHandler: \(error!)") delegate?.didCapture(nil) return } guard let cgImage = photo.cgImageRepresentation()?.takeUnretainedValue() else { - print("PhotoCaptureHandler: No image captured") + log("PhotoCaptureHandler: No image captured") delegate?.didCapture(nil) return } @@ -49,3 +50,7 @@ extension PhotoCaptureHandler: AVCapturePhotoCaptureDelegate { } } } + +extension PhotoCaptureHandler: Logger { + +} diff --git a/CapCollector/Classifier.swift b/CapCollector/Classifier.swift deleted file mode 100644 index f318d09..0000000 --- a/CapCollector/Classifier.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// VisionHandler.swift -// CapFinder -// -// Created by User on 12.02.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import Vision -import CoreML -import UIKit - -/// Notify the delegate about -protocol ClassifierDelegate { - - /// Features found - func classifier(finished image: UIImage?) - - /// Error handler - func classifier(error: String) -} - -/// Recognise categories in images -class Classifier: Logger { - - static let logToken = "[Classifier]" - - static var shared = Classifier() - - /// Handles errors and recognised features - var delegate: ClassifierDelegate? - - // MARK: Stored predictions - - private var notify = false - - private var image: UIImage? - - /** - Classify an image - - parameter image: The image to classify - - parameter reportingImage: Set to true, if the delegate should receive the image - */ - func recognise(image: UIImage, reportingImage: Bool = true) { - self.image = image - notify = reportingImage - if Persistence.useMobileNet { - performClassifications(model: MobileNet().model) - } else { - performClassifications(model: Squeezenet().model) - } - } - - private func performClassifications(model: MLModel) { - let orientation = CGImagePropertyOrientation(image!.imageOrientation) - guard let ciImage = CIImage(image: image!) else { - report(error: "Unable to create CIImage") - return - } - - DispatchQueue.global(qos: .userInitiated).async { - let handler = VNImageRequestHandler(ciImage: ciImage, orientation: orientation) - let model = try! VNCoreMLModel(for: model) - - let request = VNCoreMLRequest(model: model, completionHandler: { [weak self] request, error in - guard self != nil else { - Classifier.event("Self not captured, instance deallocated?") - return - } - self!.process(request: request, error: error) - }) - request.imageCropAndScaleOption = .centerCrop - do { - try handler.perform([request]) - } catch { - DispatchQueue.main.async { - self.report(error: "Classification failed: \(error.localizedDescription)") - } - } - } - } - - private func process(request: VNRequest, error: Error?) { - if let e = error { - report(error: "Unable to classify image: \(e.localizedDescription)") - return - } - if let result = request.results as? [VNClassificationObservation] { - let classification = dict(from: result) - process(classification: classification) - return - } - if let result = (request.results as? [VNCoreMLFeatureValueObservation])?.first?.featureValue.multiArrayValue { - let classification = dict(from: result) - process(classification: classification) - return - } - report(error: "Invalid classifier result: \(String(describing: request.results))") - - - } - - private func process(classification: [Int : Float]) { - Cap.unsortedCaps.forEach { cap in - cap.match = classification[cap.id] ?? 0 - } - Cap.hasMatches = true - - // Update the count of recognized counts - Persistence.recognizedCapCount = classification.count - - report() - } - - /// Create a dictionary from a vision prediction - private func dict(from results: [VNClassificationObservation]) -> [Int : Float] { - let array = results.map { item -> (Int, Float) in - return (Int(item.identifier) ?? 0, item.confidence) - } - return [Int : Float](uniqueKeysWithValues: array) - } - - private func dict(from results: MLMultiArray) -> [Int : Float] { - let length = results.count - let doublePtr = results.dataPointer.bindMemory(to: Double.self, capacity: length) - let doubleBuffer = UnsafeBufferPointer(start: doublePtr, count: length) - let output = Array(doubleBuffer).enumerated().map { - ($0.offset + 1, Float($0.element)) - } - return [Int : Float](uniqueKeysWithValues: output) - } - - // MARK: Callbacks - - private func report(error message: String) { - guard delegate != nil else { - error("No delegate: " + message) - return - } - DispatchQueue.main.async { - self.image = nil - self.delegate?.classifier(error: message) - } - } - - private func report() { - guard delegate != nil else { - error("No delegate") - return - } - DispatchQueue.main.async { - let img = self.notify ? self.image : nil - self.image = nil - self.delegate?.classifier(finished: img) - } - } -} - -extension CGImagePropertyOrientation { - /** - Converts a `UIImageOrientation` to a corresponding - `CGImagePropertyOrientation`. The cases for each - orientation are represented by different raw values. - - - Tag: ConvertOrientation - */ - init(_ orientation: UIImage.Orientation) { - switch orientation { - case .up: self = .up - case .upMirrored: self = .upMirrored - case .down: self = .down - case .downMirrored: self = .downMirrored - case .left: self = .left - case .leftMirrored: self = .leftMirrored - case .right: self = .right - case .rightMirrored: self = .rightMirrored - } - } -} diff --git a/CapCollector/Data/Cap.swift b/CapCollector/Data/Cap.swift index 8d081e4..bf5d101 100644 --- a/CapCollector/Data/Cap.swift +++ b/CapCollector/Data/Cap.swift @@ -10,19 +10,18 @@ import Foundation import UIKit import CoreImage -import SwiftyDropbox +import SQLite -protocol CapsDelegate: class { - - func capHasUpdates(_ cap: Cap) - - func capsLoaded() -} - -final class Cap { +struct Cap { // MARK: - Static variables + 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 @@ -33,657 +32,202 @@ final class Cap { private static let mosaicMargin = mosaicCellSize - mosaicRowHeight - static var delegate: CapsDelegate? - - static var shouldSave = true { - didSet { - save() - } - } - - static var hasMatches = false { - didSet { - guard !hasMatches else { return } - all.forEach { _, cap in - cap.match = nil - } - } - } - - static var nextUnusedId: Int { - return (all.keys.max() ?? 0) + 1 - } - - /// The number of caps currently in the database - static var totalCapCount: Int { - return all.count - } - - /// The total number of images for all caps - static var imageCount: Int { - return all.reduce(0) { sum, cap in - return sum + cap.value.count - } - } - - /** - Match all cap names against the given string and return matches. - - note: Each space-separated part of the string is matched individually - */ - static func caps(matching text: String) -> [Cap] { - let cleaned = text.clean - let found = all.compactMap { (_,cap) -> Cap? in - // For each part of text, check if name contains it - for textItem in cleaned.components(separatedBy: " ") { - if textItem != "" && !cap.name.contains(textItem) { return nil } - } - return cap - } - return found - } - // MARK: - Variables /// The unique number of the cap let id: Int /// The tile position of the cap - var tile: Int + let tile: Int /// The name of the cap - var name: String { - didSet { - cleanName = name.clean - Cap.save() - event("Updated name for cap \(id) to \(name)") - Cap.delegate?.capHasUpdates(self) - } - } + let name: String - /// The name of the cap wothout special characters - private(set) var cleanName: String + /// The name of the cap without special characters + let cleanName: String /// The number of images existing for the cap - private(set) var count: Int { - didSet { - Cap.save() - event("Updated count for cap \(id) to \(count)") - Cap.delegate?.capHasUpdates(self) - } - } + let count: Int /// The average color of the cap - var color: UIColor? + let color: UIColor - /// The similarity of the cap to the currently processed image - var match: Float? = nil + /// Indicate if the cap can be found by the recognition model + let matched: Bool - // MARK: - All caps + /// Indicate if the cap is present on the server + let uploaded: Bool - /// A dictionary of all known caps - static var all = [Int : Cap]() + // MARK: Init - // MARK: - Tile information - - /// A dictionary of the caps for the tiles - static var tiles = [Int : Cap]() - - /** - Get the cap image for a tile. - */ - static func tileImage(tile: Int) -> UIImage? { - return tiles[tile]?.thumbnail - } - - static func tileColor(tile: Int) -> UIColor? { - return tiles[tile]?.averageColor - } - - /** - Switch two tiles. - */ - static func switchTiles(_ lhs: Int, _ rhs: Int) { - let l = tiles[lhs]! - let r = tiles[rhs]! - l.tile = rhs - r.tile = lhs - tiles[rhs] = l - tiles[lhs] = r - } - - // MARK: - Initialization - - /** - Create a new cap with an image - - parameter image: The main image of the cap - - parameter name: The name of the cap - */ - init?(image: UIImage, name: String) { - self.id = Cap.nextUnusedId - self.tile = id - 1 + init(name: String, id: Int, color: UIColor) { + self.id = id + self.count = 1 self.name = name - self.count = 0 - self.cleanName = name.clean - guard save(mainImage: image) else { - return nil - } - Cap.all[self.id] = self - Cap.tiles[self.id] = self - Cap.shouldCreateFolderForCap(self.id) - Cap.save() - Cap.delegate?.capHasUpdates(self) - DispatchQueue.global(qos: .userInitiated).async { - self.add(image: image) { _ in - - } - } + self.cleanName = "" + self.tile = id + self.color = color + self.matched = false + self.uploaded = false } - /** - Create a cap from a line in the cap list file - */ - init?(line: String) { - guard line != "" else { - return nil + // MARK: SQLite + + static let table = Table("data") + + static let createQuery: String = { + table.create(ifNotExists: true) { t in + t.column(rowId, primaryKey: true) + t.column(rowName) + t.column(rowCount) + t.column(rowTile) + t.column(rowRed) + t.column(rowGreen) + t.column(rowBlue) + t.column(rowMatched) + t.column(rowUploaded) } - let parts = line.components(separatedBy: ";") - guard parts.count == 4 || parts.count == 8 else { - Cap.error("Cap names: Invalid line \(line)") - return nil - } - - guard let nr = Int(parts[0]) else { - Cap.error("Invalid id in line \(line)") - return nil - } - guard let count = Int(parts[2]) else { - Cap.error("Invalid count in line \(line)") - return nil - } - guard let tile = Int(parts[3]) else { - Cap.error("Invalid tile in line \(line)") - return nil - } - if parts.count == 8 { - guard let r = Int(parts[4]), let g = Int(parts[5]), let b = Int(parts[6]), let a = Int(parts[7]) else { - Cap.error("Invalid color in line \(line)") - return nil - } - self.color = UIColor(red: CGFloat(r)/255, green: CGFloat(g)/255, blue: CGFloat(b)/255, alpha: CGFloat(a)/255) - } - - self.id = nr - self.name = parts[1] - self.count = count + }() + + static let rowId = Expression("id") + + static let rowName = Expression("name") + + static let rowCount = Expression("count") + + static let rowTile = Expression("tile") + + static let rowRed = Expression("red") + static let rowGreen = Expression("green") + static let rowBlue = Expression("blue") + + static let rowMatched = Expression("matched") + + static let rowUploaded = Expression("uploaded") + + init(row: Row) { + self.id = row[Cap.rowId] + self.name = row[Cap.rowName] + self.count = row[Cap.rowCount] + self.tile = row[Cap.rowTile] self.cleanName = name.clean - self.tile = tile - Cap.tiles[tile] = self - Cap.all[id] = self + self.matched = row[Cap.rowMatched] + self.uploaded = row[Cap.rowUploaded] + + let r = CGFloat(row[Cap.rowRed]) / 255 + let g = CGFloat(row[Cap.rowGreen]) / 255 + let b = CGFloat(row[Cap.rowBlue]) / 255 + self.color = UIColor(red: r, green: g, blue: b, alpha: 1.0) + } + + init(id: Int, name: String, count: Int) { + self.id = id + self.name = name + self.count = count + self.tile = id - 1 + self.cleanName = name.clean + self.matched = false + self.color = UIColor.gray + self.uploaded = false + } + + var insertQuery: Insert { + let colors = color.rgb + return Cap.table.insert( + Cap.rowId <- id, + Cap.rowName <- name, + Cap.rowCount <- count, + Cap.rowTile <- tile, + Cap.rowRed <- colors.red, + Cap.rowGreen <- colors.green, + Cap.rowBlue <- colors.blue, + Cap.rowMatched <- matched, + Cap.rowUploaded <- uploaded) + } + + // MARK: Text + + func matchDescription(match: Float?) -> String { + guard let match = match else { + return hasSufficientImages ? "" : "⚠️" + } + let percent = Int((match * 100).rounded()) + return String(format: "%d %%", arguments: [percent]) + } + + /// The cap id and the number of images + var subtitle: String { + guard count != 1 else { + return "\(id) (1 image)" + } + return "\(id) (\(count) images)" } // MARK: - Images + + var hasSufficientImages: Bool { + count > Cap.sufficientImageCount + } + + var hasImage: Bool { + app.storage.hasImage(for: id) + } /// The main image of the cap var image: UIImage? { - guard let data = DiskManager.image(for: id) else { - self.downloadImage { _ in - Cap.delegate?.capHasUpdates(self) - } - return nil - } - return UIImage(data: data) + app.storage.image(for: id) } /// The main image of the cap var thumbnail: UIImage? { - if let data = DiskManager.thumbnail(for: id) { - return UIImage(data: data) - } - return makeThumbnail() + app.storage.thumbnail(for: id) } - var averageColor: UIColor? { - if let c = color { - return c - } - return makeAverageColor() - } - - @discardableResult - func makeThumbnail() -> UIImage? { - guard let img = image else { - return nil - } + static func thumbnail(for image: UIImage) -> UIImage { let len = GridViewController.len * 2 - let thumb = img.resize(to: CGSize.init(width: len, height: len)) - guard let data = thumb.pngData() else { - error("Failed to get PNG data from thumbnail for cap \(id)") - return nil + return image.resize(to: CGSize.init(width: len, height: len)) + } + + func updateLocalThumbnail() { + guard let img = image else { + return } - _ = DiskManager.save(thumbnailData: data, for: id) - event("Created thumbnail for cap \(id)") - return thumb + let thumbnail = Cap.thumbnail(for: img) + guard app.storage.save(thumbnail: thumbnail, for: id) else { + error("Failed to save thumbnail") + return + } + log("Created thumbnail for cap \(id)") + } + + func updateLocalColor() { + guard let color = image?.averageColor else { + return + } + app.database.update(color: color, for: id) + } + + /** + Download the main image of the cap. + - Note: The downloaded image is automatically saved to disk + - returns: `true`, if the image will be downloaded, `false`, if the image is already being downloaded. + */ + @discardableResult + func downloadMainImage(completion: @escaping (_ image: UIImage?) -> Void) -> Bool { + app.database.downloadMainImage(for: id, completion: completion) } /** Download a specified image of the cap. - - Note: If the downloaded image is the main image, it is automatically saved to disk - - Note: If the main image is requested and already downloaded, it is returned directly - parameter number: The number of the image - parameter completion: The completion handler, called with the image if successful - parameter image: The image, if the download was successful, or nil on error + - returns: `true`, if the image will be downloaded, `false`, if the image is already being downloaded. */ - func downloadImage(_ number: Int = 0, completion: @escaping (_ image: UIImage?) -> Void) { - let path = imageFolderPath + "/\(id)-\(number).jpg" - DropboxController.client.files.download(path: path).response { data, dbError in - if let error = dbError { - self.error("Failed to download image data (\(number)) for cap \(self.id): \(error)") - completion(nil) - return - } - - guard let d = data?.1 else { - self.error("Failed to download image data (\(number)) for cap \(self.id)") - completion(nil) - return - } - guard let image = UIImage(data: d) else { - self.error("Corrupted image data (\(number)) for cap \(self.id)") - completion(nil) - return - } - if number == 0 { - guard self.save(mainImage: image) else { - completion(nil) - return - } - } - self.event("Downloaded image data (\(number)) for cap \(self.id)") - completion(image) - } + func downloadImage(_ number: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { + app.database.downloadImage(for: id, version: number, completion: completion) } - func save(mainImage: UIImage) -> Bool { - guard let data = mainImage.jpegData(compressionQuality: Cap.jpgQuality) else { - error("Failed to convert main image to data for cap \(id)") - return false - } - guard DiskManager.save(imageData: data, for: id) else { - error("Failed to save main image for cap \(id)") - return false - } - event("Saved main image for cap \(id) to disk") - makeThumbnail() - - Cap.delegate?.capHasUpdates(self) - return true - } - - func add(image: UIImage, completion: @escaping (Bool) -> Void) { - self.upload(image: image) { saved in - guard saved else { - completion(false) - return - } - // Increment cap count - self.count += 1 - completion(true) - } - } - - private var imageFolderPath: String { - return String(format: "/Images/%04d", id) - } - - private static func imageFolderPath(for cap: Int) -> String { - return String(format: "/Images/%04d", cap) - } - - private func imageFilePath(imageId: Int) -> String { - return imageFolderPath + "/\(id)-\(imageId).jpg" - } - - // MARK: - Image upload - - private func folderExists(completion: @escaping (_ exists: Bool?) -> Void) { - let path = "/Images" - DropboxController.client.files.listFolder(path: path).response { response, error in - if let e = error { - self.error("Failed to get image folder list: \(e)") - completion(nil) - return - } - guard let result = response else { - self.error("Failed to get image folder list") - completion(nil) - return - } - - let exists = result.entries.contains { $0.name == "\(self.id)" } - completion(exists) - } - } - - private static func createFolder(forCap cap: Int, completion: @escaping (_ success: Bool) -> Void) { - guard shouldCreateFolder(forCap: cap) else { - completion(true) - return - } - // Create folder for cap - let path = imageFolderPath(for: cap) - DropboxController.client.files.createFolderV2(path: path).response { _, error in - if let err = error { - self.event("Could not create folder for cap \(cap): \(err)") - completion(false) - } else { - self.event("Created folder for cap \(cap)") - didCreateFolder(forCap: cap) - completion(true) - } - } - } - - private func upload(image: UIImage, savedCallback: @escaping (Bool) -> Void) { - upload(image: image, number: count, savedCallback: savedCallback) - } - - private func upload(image: UIImage, number: Int, savedCallback: @escaping (Bool) -> Void) { - // Convert to data - guard let data = image.jpegData(compressionQuality: Cap.jpgQuality) else { - error("Failed to convert image to data") - return - } - let fileName = "\(id)-\(number).jpg" - // Save image to upload folder - guard let url = DiskManager.saveForUpload(imageData: data, name: fileName) else { - error("Could not save image for cap \(id) to upload folder") - savedCallback(false) - return - } - event("Saved image \(number) for cap \(id) for upload") - savedCallback(true) - - Cap.uploadCapImage(at: url, forCap: id) { success in - - } - } - - private static func uploadCapImage(at url: URL, forCap cap: Int, completion: @escaping (Bool) -> Void) { - createFolder(forCap: cap) { created in - guard created else { return } - uploadCapImage(at: url, forCapWithExistingFolder: cap, completion: completion) - } - } - - private static func uploadCapImage(at url: URL, forCapWithExistingFolder cap: Int, completion: @escaping (Bool) -> Void) { - let path = imageFolderPath(for: cap) + "/" + url.lastPathComponent - - let data: Data - do { - data = try Data(contentsOf: url) - } catch { - self.error("Could not read data from url \(url.path): \(error)") - completion(false) - return - } - - DropboxController.client.files.upload(path: path, input: data).response { response, error in - if let err = error { - self.error("Failed to upload file at url: \(url): \(err)") - completion(false) - return - } - Cap.event("Uploaded image \(path)") - guard DiskManager.removeFromUpload(url: url) else { - self.error("Could not delete uploaded image for cap \(cap) at url \(url)") - completion(false) - return - } - completion(true) - } - } - - static func uploadRemainingImages() { - guard let list = DiskManager.pendingUploads else { - return - } - guard list.count != 0 else { - event("No pending uploads") - return - } - event("\(list.count) image uploads pending") - - for url in list { - let cap = Int(url.lastPathComponent.components(separatedBy: "-").first!)! - uploadCapImage(at: url, forCap: cap) { didUpload in - // Delete image from disk if uploaded - guard didUpload else { - self.error("Could not upload image at url \(url)") - return - } - guard DiskManager.removeFromUpload(url: url) else { - self.error("Could not delete uploaded image at url \(url)") - return - } - } - } - } - - func setMainImage(to imageId: Int, image: UIImage) { - guard imageId != 0 else { - self.event("No need to switch main image with itself") - return - } - let tempFile = imageFilePath(imageId: count) - let oldFile = imageFilePath(imageId: 0) - let newFile = imageFilePath(imageId: imageId) - DropboxController.shared.move(file: oldFile, to: tempFile) { success in - guard success else { - self.error("Could not move \(oldFile) to \(tempFile)") - return - } - DropboxController.shared.move(file: newFile, to: oldFile) { success in - guard success else { - self.error("Could not move \(newFile) to \(oldFile)") - return - } - DropboxController.shared.move(file: tempFile, to: newFile) { success in - if !success { - self.error("Could not move \(tempFile) to \(newFile)") - } - guard self.save(mainImage: image) else { - return - } - self.event("Successfully set image \(imageId) to main image for cap \(self.id)") - } - } - } - } - - // MARK: - Counts - - func updateCount(completion: @escaping (Bool) -> Void) { - getImageCount { response in - guard let count = response else { - self.error("Could not update count for cap \(self.id)") - completion(false) - return - } - self.count = count - completion(true) - } - } - - private func getImageCount(completion: @escaping (Int?) -> Void) { - let path = imageFolderPath - DropboxController.client.files.listFolder(path: path).response { response, error in - if let err = error { - self.error("Error getting folder content of cap \(self.id): \(err)") - completion(nil) - return - } - guard let files = response?.entries else { - self.error("No content for folder of cap \(self.id)") - completion(nil) - return - } - completion(files.count) - } - } - - // MARK: - Sorted caps - - static var unsortedCaps: Set { - return Set(all.values) - } - - static func capList(sortedBy criteria: SortCriteria, ascending: Bool) -> [Cap] { - if ascending { - return sorted([Cap](all.values), ascendingBy: criteria) - } else { - return sorted([Cap](all.values), descendingBy: criteria) - } - } - - private static func sorted(_ list: [Cap], ascendingBy parameter: SortCriteria) -> [Cap] { - switch parameter { - case .id: return list.sorted { $0.id < $1.id } - case .count: return list.sorted { $0.count < $1.count } - case .name: return list.sorted { $0.name < $1.name } - case .match: return list.sorted { $0.match ?? 0 < $1.match ?? 0 } - } - } - - private static func sorted(_ list: [Cap], descendingBy parameter: SortCriteria) -> [Cap] { - switch parameter { - case .id: return list.sorted { $0.id > $1.id } - case .count: return list.sorted { $0.count > $1.count } - case .name: return list.sorted { $0.name > $1.name } - case .match: return list.sorted { $0.match ?? 0 > $1.match ?? 0 } - } - } - - // MARK: - Loading, Saving & Uploading cap list - - /** - Either load the names from disk or download them from dropbox. - - parameter completion: The handler that is called with true on success, false on failure - */ - static func load() { - NameFile.makeAvailable { content in - guard let lines = content else { - return - } - self.readNames(from: lines) - } - } - - /// Read all caps from the content of a file - private static func readNames(from fileContent: String) { - let parts = fileContent.components(separatedBy: "\n") - for line in parts { - _ = Cap(line: line) - } - event("Loaded \(totalCapCount) caps from file") - delegate?.capsLoaded() - } - - static func getCapStatistics() -> [Int] { - let counts = all.values.map { $0.count } - var c = [Int](repeating: 0, count: counts.max()! + 1) - counts.forEach { c[$0] += 1 } - return c - } - - static func save() { - guard shouldSave else { return } - let content = namesAsString() - NameFile.save(names: content) - } - - static func saveAndUpload(completion: @escaping (Bool) -> Void) { - let content = namesAsString() - NameFile.saveAndUpload(names: content) { success in - guard success else { - completion(false) - return - } - Persistence.lastUploadedCapCount = totalCapCount - Persistence.lastUploadedImageCount = imageCount - completion(true) - } - } - - private static func namesAsString() -> String { - return capList(sortedBy: .id, ascending: true).reduce("") { $0 + $1.description } - } - - // MARK: - Folders to upload - - static func shouldCreateFolderForCap(_ cap: Int) { - let oldCaps = Persistence.folderNotCreated - guard !oldCaps.contains(cap) else { - return - } - let newCaps = oldCaps + [cap] - Persistence.folderNotCreated = newCaps - } - - static func shouldCreateFolder(forCap cap: Int) -> Bool { - return Persistence.folderNotCreated.contains(cap) - } - - static func didCreateFolder(forCap cap: Int) { - let oldCaps = Persistence.folderNotCreated - guard oldCaps.contains(cap) else { - return - } - Persistence.folderNotCreated = oldCaps.filter { $0 != cap } - } - - // MARK: - Average color - - @discardableResult - func makeAverageColor() -> UIColor? { - guard let url = DiskManager.imageUrlForCap(id) else { - event("No main image for cap \(id), no average color calculated") - return nil - } - - guard let inputImage = CIImage(contentsOf: url) else { - error("Failed to read CIImage for main image of cap \(id)") - return nil - } - let extentVector = CIVector(x: inputImage.extent.origin.x, y: inputImage.extent.origin.y, z: inputImage.extent.size.width, w: inputImage.extent.size.height) - - guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: inputImage, kCIInputExtentKey: extentVector]) else { - error("Failed to create filter to calculate average for cap \(id)") - return nil - } - guard let outputImage = filter.outputImage else { - error("Failed get filter output for image of cap \(id)") - return nil - } - - var bitmap = [UInt8](repeating: 0, count: 4) - let context = CIContext(options: [.workingColorSpace: kCFNull]) - context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) - - color = UIColor( - red: saturate(bitmap[0]), - green: saturate(bitmap[1]), - blue: saturate(bitmap[2]), - alpha: CGFloat(bitmap[3]) / 255) - - event("Average color updated for cap \(id)") - Cap.save() - return color - } -} - -/// Map expected range 75-200 to 0-255 -private func saturate(_ component: UInt8) -> CGFloat { - return max(min(CGFloat(component) * 2 - 150, 255), 0) / 255 } // MARK: - Protocol Hashable @@ -705,31 +249,14 @@ extension Cap: Hashable { extension Cap: CustomStringConvertible { var description: String { - guard let c = color else { - return String(format: "%04d", id) + ";\(name);\(count);\(tile)\n" - } - - var fRed: CGFloat = 0 - var fGreen: CGFloat = 0 - var fBlue: CGFloat = 0 - var fAlpha: CGFloat = 0 - guard c.getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha) else { - return String(format: "%04d", id) + ";\(name);\(count);\(tile)\n" - } - let r = Int(fRed * 255.0) - let g = Int(fGreen * 255.0) - let b = Int(fBlue * 255.0) - let a = Int(fAlpha * 255.0) - return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(r);\(g);\(b);\(a)\n" + let rgb = color.rgb + return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(rgb.red);\(rgb.green);\(rgb.blue)\n" } } // MARK: - Protocol Logger -extension Cap: Logger { - - static let logToken = "[CAP]" -} +extension Cap: Logger { } // MARK: - String extension diff --git a/CapCollector/Data/Classifier.swift b/CapCollector/Data/Classifier.swift new file mode 100644 index 0000000..b13d072 --- /dev/null +++ b/CapCollector/Data/Classifier.swift @@ -0,0 +1,68 @@ +// +// VisionHandler.swift +// CapFinder +// +// Created by User on 12.02.18. +// Copyright © 2018 User. All rights reserved. +// + +import Foundation +import Vision +import CoreML +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 + - 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) + let request = VNCoreMLRequest(model: model) { request, error in + let matches = self.process(request: request, error: error) + completion(matches) + } + request.imageCropAndScaleOption = .centerCrop + do { + try handler.perform([request]) + } catch { + 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)") + return nil + } + guard let result = request.results as? [VNClassificationObservation] else { + self.error("Invalid classifier result: \(String(describing: request.results))") + return nil + } + let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence } + + log("Classifed image with \(matches.count) classes") + DispatchQueue.global(qos: .background).async { + app.database.update(recognizedCaps: Set(matches.keys)) + } + + return matches + } +} diff --git a/CapCollector/Data/Database.swift b/CapCollector/Data/Database.swift new file mode 100644 index 0000000..0ed55b1 --- /dev/null +++ b/CapCollector/Data/Database.swift @@ -0,0 +1,621 @@ +// +// Database.swift +// CapCollector +// +// Created by Christoph on 14.04.20. +// Copyright © 2020 CH. All rights reserved. +// + +import Foundation +import UIKit +import CoreML +import SQLite + +protocol DatabaseDelegate: class { + + func database(didChangeCap cap: Int) + + func database(didAddCap cap: Cap) + + func databaseRequiresFullRefresh() +} + +struct Weak { + + weak var value : DatabaseDelegate? + + init (_ value: DatabaseDelegate) { + self.value = value + } +} + +extension Array where Element == Weak { + + mutating func reap () { + self = self.filter { $0.value != nil } + } +} + +final class Database { + + // MARK: Variables + + let db: Connection + + let upload: Upload + + let download: Download + + private var listeners = [Weak]() + + // MARK: Listeners + + func add(listener: DatabaseDelegate) { + listeners.append(Weak(listener)) + } + + init?(url: URL, server: URL) { + guard let db = try? Connection(url.path) else { + return nil + } + + let upload = Upload(server: server) + let download = Download(server: server) + + do { + try db.run(Cap.createQuery) + try db.run(upload.createQuery) + } catch { + return nil + } + + self.db = db + self.upload = upload + self.download = download + log("Database loaded with \(capCount) caps") + } + + // MARK: Computed properties + + /// All caps currently in the database + var caps: [Cap] { + (try? db.prepare(Cap.table))?.map(Cap.init) ?? [] + } + + /// The ids of the caps which weren't included in the last classification + var unmatchedCaps: [Int] { + let query = Cap.table.select(Cap.rowId).filter(Cap.rowMatched == false) + return (try? db.prepare(query).map { $0[Cap.rowId] }) ?? [] + } + + /// The number of caps which could be recognized during the last classification + var recognizedCapCount: Int { + (try? db.scalar(Cap.table.filter(Cap.rowMatched == true).count)) ?? 0 + } + + /// The number of caps currently in the database + var capCount: Int { + (try? db.scalar(Cap.table.count)) ?? 0 + } + + /// The total number of images for all caps + var imageCount: Int { + (try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.rowCount] }) ?? 0 + } + + /// The number of caps without a downloaded image + var capsWithoutImages: Int { + caps.filter({ !$0.hasImage }).count + } + + + + var pendingUploads: [(cap: Int, version: Int)] { + do { + return try db.prepare(upload.table).map { row in + (cap: row[upload.rowCapId], version: row[upload.rowCapVersion]) + } + } catch { + log("Failed to get pending uploads") + return [] + } + } + + /// Indicate if there are any unfinished uploads + var hasPendingUploads: Bool { + ((try? db.scalar(upload.table.count)) ?? 0) > 0 + } + + var classifierVersion: Int { + set { + UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey) + log("Classifier version set to \(newValue)") + } + get { + UserDefaults.standard.integer(forKey: Classifier.userDefaultsKey) + } + } + + // MARK: Data updates + + /** + Create a new cap with an image. + + The cap is inserted into the database, and the name and image will be uploaded to the server. + + - parameter image: The main image of the cap + - parameter name: The name of the cap + - note: Must be called on the main queue. + - note: The registered delegates will be informed about the added cap through `database(didAddCap:)` + - returns: `true`, if the cap was created. + */ + func createCap(image: UIImage, name: String) -> Bool { + guard let color = image.averageColor else { + return false + } + let cap = Cap(name: name, id: capCount, color: color) + guard insert(cap: cap) else { + return false + } + guard app.storage.save(image: image, for: cap.id) else { + return false + } + listeners.forEach { $0.value?.database(didAddCap: cap) } + upload.upload(name: name, for: cap.id) { success in + guard success else { + return + } + self.update(uploaded: true, for: cap.id) + self.upload.uploadImage(for: cap.id, version: 0) { count in + guard let count = count else { + self.log("Failed to upload first image for cap \(cap.id)") + return + } + self.log("Uploaded first image for cap \(cap.id)") + self.update(count: count, for: cap.id) + } + } + return true + } + + /** + Insert a new cap. + + Only inserts the cap into the database, and optionally notifies the delegates. + - note: When a new cap is created, use `createCap(image:name:)` instead + */ + @discardableResult + private func insert(cap: Cap, notifyDelegate: Bool = true) -> Bool { + do { + try db.run(cap.insertQuery) + if notifyDelegate { + listeners.forEach { $0.value?.database(didAddCap: cap) } + } + return true + } catch { + log("Failed to insert cap \(cap.id): \(error)") + return false + } + } + + func add(image: UIImage, for cap: Int) -> Bool { + guard let version = count(for: cap) else { + log("Failed to get count for cap \(cap)") + return false + } + guard app.storage.save(image: image, for: cap, version: version) else { + log("Failed to save image \(version) for cap \(cap) to disk") + return false + } + guard update(count: version + 1, for: cap) else { + log("Failed update count \(version) for cap \(cap)") + return false + } + listeners.forEach { $0.value?.database(didChangeCap: cap) } + + guard addPendingUpload(for: cap, version: version) else { + log("Failed to add cap \(cap) version \(version) to upload queue") + return false + } + upload.uploadImage(for: cap, version: version) { count in + guard let _ = count else { + self.log("Failed to upload image \(version) for cap \(cap)") + return + } + guard self.removePendingUpload(of: cap, version: version) else { + self.log("Failed to remove version \(version) for cap \(cap) from upload queue") + return + } + self.log("Uploaded version \(version) for cap \(cap)") + } + return true + } + + // MARK: Updating cap properties + + private func update(_ property: String, for cap: Int, setter: Setter...) -> Bool { + do { + let query = updateQuery(for: cap).update(setter) + try db.run(query) + listeners.forEach { $0.value?.database(didChangeCap: cap) } + return true + } catch { + log("Failed to update \(property) for cap \(cap): \(error)") + return false + } + } + + @discardableResult + private func update(uploaded: Bool, for cap: Int) -> Bool { + update("uploaded", for: cap, setter: Cap.rowUploaded <- uploaded) + } + + @discardableResult + func update(name: String, for cap: Int) -> Bool { + update("name", for: cap, setter: Cap.rowName <- name) + } + + @discardableResult + func update(color: UIColor, for cap: Int) -> Bool { + let (red, green, blue) = color.rgb + return update("color", for: cap, setter: Cap.rowRed <- red, Cap.rowGreen <- green, Cap.rowBlue <- blue) + } + + @discardableResult + func update(tile: Int, for cap: Int) -> Bool { + update("tile", for: cap, setter: Cap.rowTile <- tile) + } + + @discardableResult + func update(count: Int, for cap: Int) -> Bool { + update("count", for: cap, setter: Cap.rowCount <- count) + } + + @discardableResult + func update(matched: Bool, for cap: Int) -> Bool { + update("matched", for: cap, setter: Cap.rowMatched <- matched) + } + + func update(recognizedCaps: Set) { + let unrecognized = self.unmatchedCaps + // Update caps which haven't been recognized before + let newlyRecognized = recognizedCaps.intersection(unrecognized) + let logIndividualMessages = newlyRecognized.count < 10 + if !logIndividualMessages { + log("Marking \(newlyRecognized.count) caps as matched") + } + for cap in newlyRecognized { + if logIndividualMessages { + log("Marking cap \(cap) as matched") + } + update(matched: true, for: cap) + } + // Update caps which are no longer recognized + let missing = Set(1...capCount).subtracting(recognizedCaps).subtracting(unrecognized) + for cap in missing { + log("Marking cap \(cap) as not matched") + update(matched: false, for: cap) + } + } + + func addPendingUpload(for cap: Int, version: Int) -> Bool { + do { + try db.run(upload.insertQuery(for: cap, version: version)) + return true + } catch { + log("Failed to add pending upload of cap \(cap) version \(version): \(error)") + return false + } + } + + func removePendingUpload(for cap: Int, version: Int) -> Bool { + do { + try db.run(upload.deleteQuery(for: cap, version: version)) + return true + } catch { + log("Failed to remove pending upload of cap \(cap) version \(version): \(error)") + return false + } + } + + // MARK: Information retrieval + + func cap(for id: Int) -> Cap? { + do { + guard let row = try db.pluck(updateQuery(for: id)) else { + log("No cap with id \(id) in database") + return nil + } + return Cap(row: row) + } catch { + log("Failed to get cap \(id): \(error)") + return nil + } + } + + private func count(for cap: Int) -> Int? { + do { + let row = try db.pluck(updateQuery(for: cap).select(Cap.rowCount)) + return row?[Cap.rowCount] + } catch { + log("Failed to get count for cap \(cap)") + return nil + } + } + + func countOfCaps(withImageCountLessThan limit: Int) -> Int { + do { + return try db.scalar(Cap.table.filter(Cap.rowCount < limit).count) + } catch { + log("Failed to get caps with less than \(limit) images") + return 0 + } + } + + func lowestImageCountForCaps(startingAt start: Int) -> (count: Int, numberOfCaps: Int) { + do { + var currentCount = start - 1 + var capsFound = 0 + repeat { + currentCount += 1 + capsFound = try db.scalar(Cap.table.filter(Cap.rowCount == currentCount).count) + } while capsFound == 0 + + return (currentCount, capsFound) + } catch { + return (0,0) + } + } + + func updateQuery(for cap: Int) -> Table { + Cap.table.filter(Cap.rowId == cap) + } + + // MARK: Downloads + + @discardableResult + func downloadMainImage(for cap: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { + return download.image(for: cap, version: 0) { image in + // Guaranteed to be on the main queue + guard let image = image else { + completion(nil) + return + } + + defer { + completion(image) + } + if !app.storage.save(thumbnail: Cap.thumbnail(for: image), for: cap) { + self.log("Failed to save thumbnail for cap \(cap)") + } + guard let color = image.averageColor else { + self.log("Failed to calculate color for cap \(cap)") + return + } + self.update(color: color, for: cap) + } + } + + @discardableResult + func downloadImage(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { + return download.image(for: cap, version: version, completion: completion) + } + + func getServerDatabaseSize(completion: @escaping (_ size: Int64?) -> Void) { + download.databaseSize(completion: completion) + } + + func downloadServerDatabase(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void, processed: (() -> Void)? = nil) { + download.database(progress: progress) { tempUrl in + guard let url = tempUrl else { + self.log("Failed to download database") + completion(false) + return + } + completion(true) + self.processServerDatabase(at: url) + processed?() + } + } + + func downloadMainCapImages(progress: @escaping (_ current: Int, _ total: Int) -> Void) { + let caps = self.caps.filter { !$0.hasImage }.map { $0.id } + + var downloaded = 0 + let total = caps.count + + func update() { + DispatchQueue.main.async { + progress(downloaded, total) + } + } + update() + + guard total > 0 else { + log("No images to download") + return + } + log("Starting to download \(total) images") + + let group = DispatchGroup() + let split = 50 + DispatchQueue.global(qos: .userInitiated).async { + for part in caps.split(intoPartsOf: split) { + for id in part { + let downloading = self.downloadMainImage(for: id) { _ in + group.leave() + } + if downloading { + group.enter() + } + } + if group.wait(timeout: .now() + .seconds(30)) != .success { + self.log("Timed out waiting for images to be downloaded") + } + downloaded += part.count + self.log("Finished \(downloaded) of \(total) image downloads") + update() + } + self.log("Finished all image downloads") + } + + } + + func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) { + download.classifierVersion { version in + guard let version = version else { + self.log("Failed to download server model version") + completion(nil, nil) + return + } + let ownVersion = self.classifierVersion + guard ownVersion < version else { + self.log("Not updating classifier: Own version \(ownVersion), server version \(version)") + completion(nil, nil) + return + } + self.log("Getting classifier size: Own version \(ownVersion), server version \(version)") + self.download.classifierSize { size in + completion(version, size) + } + } + } + + func downloadClassifier(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void) { + download.classifier(progress: progress) { url in + guard let url = url else { + self.log("Failed to download classifier") + completion(false) + return + } + let compiledUrl: URL + do { + compiledUrl = try MLModel.compileModel(at: url) + } catch { + self.log("Failed to compile downloaded classifier: \(error)") + completion(false) + return + } + + guard app.storage.save(recognitionModelAt: compiledUrl) else { + self.log("Failed to save classifier") + completion(false) + return + } + completion(true) + self.download.classifierVersion { version in + guard let version = version else { + self.log("Failed to download classifier version") + return + } + self.classifierVersion = version + } + } + } + + func downloadImageCounts() { + guard !hasPendingUploads else { + log("Waiting to refresh server image counts (uploads pending)") + return + } + log("Refreshing server image counts") + app.database.download.imageCounts { counts in + guard let counts = counts else { + self.log("Failed to download server image counts") + return + } + self.didDownload(imageCounts: counts) + + } + } + + private func didDownload(imageCounts newCounts: [(cap: Int, count: Int)]) { + let capsCounts = self.caps.reduce(into: [:]) { $0[$1.id] = $1.count } + if newCounts.count != capsCounts.count { + log("Downloaded \(newCounts.count) image counts, but \(app.database.capCount) caps stored locally") + return + } + let changed = newCounts.compactMap { id, newCount -> Int? in + guard let oldCount = capsCounts[id] else { + log("Received count \(newCount) for unknown cap \(id)") + return nil + } + guard oldCount != newCount else { + return nil + } + app.database.update(count: newCount, for: id) + return id + } + switch changed.count { + case 0: + log("Refreshed image counts for all caps without changes") + case 1: + log("Refreshed image counts for caps, changed cap \(changed[0])") + case 2...10: + log("Refreshed image counts for caps \(changed.map(String.init).joined(separator: ", ")).") + default: + log("Refreshed image counts for all caps (\(changed.count) changed)") + } + } + + private func processServerDatabase(at url: URL) { + guard let db = ServerDatabase(downloadedTo: url) else { + log("Failed to open downloaded server database") + return + } + for (id, count, name) in db.caps { + let cap = Cap(id: id, name: name, count: count) + insert(cap: cap, notifyDelegate: false) + } + listeners.forEach { $0.value?.databaseRequiresFullRefresh() } + } + + func uploadRemainingImages() { + guard pendingUploads.count > 0 else { + log("No pending uploads") + return + } + log("\(pendingUploads.count) image uploads pending") + + for (cap, version) in pendingUploads { + upload.uploadImage(for: cap, version: version) { count in + guard let _ = count else { + self.log("Failed to upload version \(version) of cap \(cap)") + return + } + self.log("Uploaded version \(version) of cap \(cap)") + self.removePendingUpload(of: cap, version: version) + } + } + } + + @discardableResult + func removePendingUpload(of cap: Int, version: Int) -> Bool { + do { + let query = upload.table.filter(upload.rowCapId == cap && upload.rowCapVersion == version).delete() + try db.run(query) + log("Deleted pending upload of cap \(cap) version \(version)") + return true + } catch { + log("Failed to delete pending upload of cap \(cap) version \(version)") + return false + } + } + + func setMainImage(of cap: Int, to version: Int) { + guard version != 0 else { + log("No need to switch main image with itself for cap \(cap)") + return + } + upload.setMainImage(for: cap, to: version) { color in + guard let color = color else { + self.log("Could not make \(version) the main image for cap \(cap)") + return + } + self.update(color: color, for: cap) + self.listeners.forEach { $0.value?.database(didChangeCap: cap) } + } + } +} + +extension Database: Logger { } diff --git a/CapCollector/Data/DiskManager.swift b/CapCollector/Data/DiskManager.swift deleted file mode 100644 index b12f1f5..0000000 --- a/CapCollector/Data/DiskManager.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// DiskManager.swift -// CapFinder -// -// Created by User on 23.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import UIKit - -final class DiskManager { - - enum LocalDirectory: String { - /// Folder for new images to upload - case upload = "Upload" - - /// Folder for downloaded images - case images = "Images" - - /// Folder for downloaded images - case thumbnails = "Thumbnails" - - /// Directory for name file - case files = "Files" - - private static let fm = FileManager.default - - /// The url to the file sstem - var url: URL { - return URL(fileURLWithPath: self.rawValue, isDirectory: true, relativeTo: DiskManager.documentsDirectory) - } - - fileprivate func create() -> Bool { - return DiskManager.create(directory: url) - } - - var content: [URL]? { - do { - return try LocalDirectory.fm.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: []) - } catch { - print("[LocalDirectory] Could not read directory \(self.rawValue): \(error)") - return nil - } - } - } - - /// The folder where images and name list are stored - static let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - - private static let fm = FileManager.default - - // MARK: - First launch - - @discardableResult static func setupOnFirstLaunch() -> Bool { - return LocalDirectory.files.create() && - LocalDirectory.images.create() && - LocalDirectory.thumbnails.create() && - LocalDirectory.upload.create() - } - - private static func create(directory: URL) -> Bool { - do { - if !fm.fileExists(atPath: directory.path) { - try fm.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - } - return true - } catch { - event("Could not create \(directory): \(error)") - return false - } - } - - // MARK: - Image retrieval - - /** - Check if an image exists for a cap - - parameter cap: The id of the cap - - returns: True, if an image exists - */ - static func hasImage(for cap: Int) -> Bool { - let url = localUrl(for: cap) - return fm.fileExists(atPath: url.path) - } - - static func imageUrlForCap(_ id: Int) -> URL? { - let url = localUrl(for: id) - guard fm.fileExists(atPath: url.path) else { - return nil - } - return url - } - - private static func localUrl(for cap: Int) -> URL { - return URL(fileURLWithPath: "\(cap).jpg", isDirectory: true, relativeTo: LocalDirectory.images.url) - } - - private static func thumbnailUrl(for cap: Int) -> URL { - return URL(fileURLWithPath: "\(cap).jpg", isDirectory: true, relativeTo: LocalDirectory.thumbnails.url) - } - - /** - 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 - - returns: The image data, or `nil` - */ - static func image(for cap: Int) -> Data? { - // If the image exists on disk, get it - let url = localUrl(for: cap) - return readData(from: url) - } - - /** - 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 data, or `nil` - */ - static func thumbnail(for cap: Int) -> Data? { - // If the image exists on disk, get it - let url = thumbnailUrl(for: cap) - return readData(from: url) - } - - private static 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 - } - } - - /** - Save an image to the download folder - - parameter imageData: The data of the image - - parameter cap: The cap id - - returns: True, if the image was saved - */ - static func save(imageData: Data, for cap: Int) -> Bool { - let url = localUrl(for: cap) - return write(imageData, to: url) - } - - /** - 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 - */ - static func save(thumbnailData: Data, for cap: Int) -> Bool { - let url = thumbnailUrl(for: cap) - return write(thumbnailData, to: url) - } - - private static 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 - } - - static func removeFromUpload(url: URL) -> Bool { - guard fm.fileExists(atPath: url.path) else { - return true - } - do { - try fm.removeItem(at: url) - return true - } catch { - self.error("Could not delete file \(url.path)") - return false - } - } - - static var pendingUploads: [URL]? { - return LocalDirectory.upload.content - } - - static var availableImages: [Int]? { - return LocalDirectory.images.content?.compactMap { - Int($0.lastPathComponent.components(separatedBy: ".").first ?? "") - } - } - - /** - Save an image to the uploads folder for later - */ - static func saveForUpload(imageData: Data, name: String) -> URL? { - let url = LocalDirectory.upload.url.appendingPathComponent(name) - return write(imageData, to: url) ? url : nil - } -} - -extension DiskManager: Logger { - - static let logToken = "[DiskManager]" -} diff --git a/CapCollector/Data/Download.swift b/CapCollector/Data/Download.swift new file mode 100644 index 0000000..a169813 --- /dev/null +++ b/CapCollector/Data/Download.swift @@ -0,0 +1,328 @@ +// +// Download.swift +// CapCollector +// +// Created by Christoph on 26.04.20. +// Copyright © 2020 CH. All rights reserved. +// + +import Foundation +import UIKit + +final class Download { + + let serverUrl: URL + + let session: URLSession + + let delegate: Delegate + + private var downloadingMainImages = Set() + + init(server: URL) { + let delegate = Delegate() + + self.serverUrl = server + self.session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + self.delegate = delegate + } + + // MARK: Paths + + private static func serverDatabaseUrl(server: URL) -> URL { + server.appendingPathComponent("db.sqlite3") + } + + var serverDatabaseUrl: URL { + Download.serverDatabaseUrl(server: serverUrl) + } + + 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 serverImageCountUrl(for cap: Int) -> URL { + serverUrl.appendingPathComponent("count/\(cap)") + } + + private var serverClassifierVersionUrl: URL { + serverUrl.appendingPathComponent("classifier.version") + } + + private var serverAllCountsUrl: URL { + serverUrl.appendingPathComponent("count/all") + } + + var serverRecognitionModelUrl: URL { + serverUrl.appendingPathComponent("classifier.mlmodel") + } + + // 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 + + /** + 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 + - Note: The closure will be called from the main queue. + - Returns: `true`, of the file download was started, `false`, if the image is already downloading. + */ + @discardableResult + func mainImage(for cap: Int, completion: ((_ image: UIImage?) -> Void)?) -> Bool { + let url = serverImageUrl(for: cap) + let query = "Main image of cap \(cap)" + guard !downloadingMainImages.contains(cap) else { + return false + } + downloadingMainImages.insert(cap) + + let task = session.downloadTask(with: url) { fileUrl, response, error in + self.downloadingMainImages.remove(cap) + guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { + DispatchQueue.main.async { + completion?(nil) + } + return + } + guard app.storage.saveImage(at: fileUrl, for: cap) else { + self.log("Request '\(query)' could not move downloaded file") + DispatchQueue.main.async { + completion?(nil) + } + return + } + DispatchQueue.main.async { + guard let image = app.storage.image(for: cap) else { + self.log("Request '\(query)' received an invalid image") + completion?(nil) + return + } + completion?(image) + } + } + task.resume() + return true + } + + /** + 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 + - Note: The closure will be called from the main queue. + - Returns: `true`, of the file download was started, `false`, if the image is already downloading. + */ + @discardableResult + func image(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { + let url = serverImageUrl(for: cap, version: version) + let query = "Image of cap \(cap) version \(version)" + let task = session.downloadTask(with: url) { fileUrl, response, error in + guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { + DispatchQueue.main.async { + completion(nil) + } + return + } + guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else { + self.log("Request '\(query)' could not move downloaded file") + DispatchQueue.main.async { + completion(nil) + } + return + } + DispatchQueue.main.async { + guard let image = app.storage.image(for: cap, version: version) else { + self.log("Request '\(query)' received an invalid image") + completion(nil) + return + } + completion(image) + } + } + 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)" + let task = session.dataTask(with: url) { data, response, error in + let int = self.convertIntResponse(to: query, data, response, error) + completion(int) + } + task.resume() + } + + func imageCounts(completion: @escaping ([(cap: Int, count: Int)]?) -> Void) { + let url = serverAllCountsUrl + let query = "Image count of all caps" + let task = session.dataTask(with: url) { data, response, error in + guard let string = self.convertStringResponse(to: query, data, response, error) else { + completion(nil) + return + } + + // Convert the encoded string into (id, count) pairs + let parts = string.components(separatedBy: ";") + let array: [(cap: Int, count: Int)] = parts.compactMap { s in + let p = s.components(separatedBy: "#") + guard p.count == 2, let cap = Int(p[0]), let count = Int(p[1]) else { + return nil + } + return (cap, count) + } + completion(array) + } + task.resume() + } + + func databaseSize(completion: @escaping (_ size: Int64?) -> Void) { + size(of: "database size", to: serverDatabaseUrl, completion: completion) + } + func database(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) { + //let query = "Download of server database" + let task = session.downloadTask(with: serverDatabaseUrl) + delegate.registerForProgress(task, callback: progress) {url in + self.log("Database download complete") + completion(url) + } + task.resume() + } + + + func classifierVersion(completion: @escaping (Int?) -> Void) { + let query = "Server classifier version" + let task = session.dataTask(with: serverClassifierVersionUrl) { data, response, error in + let int = self.convertIntResponse(to: query, data, response, error) + completion(int) + } + task.resume() + } + + func classifierSize(completion: @escaping (Int64?) -> Void) { + size(of: "classifier size", to: serverRecognitionModelUrl, completion: completion) + } + + func classifier(progress: Delegate.ProgressHandler? = nil, completion: @escaping (URL?) -> Void) { + let task = session.downloadTask(with: serverRecognitionModelUrl) + delegate.registerForProgress(task, callback: progress) { url in + self.log("Classifier download complete") + completion(url) + } + task.resume() + } + + // MARK: Requests + + private func size(of query: String, to url: URL, completion: @escaping (_ size: Int64?) -> Void) { + var request = URLRequest(url: url) + request.httpMethod = "HEAD" + let task = session.dataTask(with: request) { _, response, _ in + guard let r = response else { + self.log("Request '\(query)' received no response") + completion(nil) + return + } + completion(r.expectedContentLength) + } + task.resume() + } + + private func convertIntResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> Int? { + guard let string = self.convertStringResponse(to: query, data, response, error) else { + return nil + } + guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else { + self.log("Request '\(query)' received an invalid value '\(string)'") + return nil + } + return int + } + + private func convertStringResponse(to query: String, _ data: Data?, _ response: URLResponse?, _ error: Error?) -> String? { + guard let data = self.convertResponse(to: query, data, response, error) else { + return nil + } + guard let string = String(data: data, encoding: .utf8) else { + self.log("Request '\(query)' received invalid data (not a string)") + return nil + } + return string + } + + private func convertResponse(to query: String, _ result: T?, _ response: URLResponse?, _ error: Error?) -> T? { + if let error = error { + log("Request '\(query)' produced an error: \(error)") + return nil + } + guard let response = response else { + log("Request '\(query)' received no response") + return nil + } + guard let urlResponse = response as? HTTPURLResponse else { + log("Request '\(query)' received an invalid response: \(response)") + return nil + } + guard urlResponse.statusCode == 200 else { + log("Request '\(query)' failed with status code \(urlResponse.statusCode)") + return nil + } + guard let r = result else { + log("Request '\(query)' received no item") + return nil + } + return r + } + + +} + +extension Download: Logger { } diff --git a/CapCollector/Data/NameFile.swift b/CapCollector/Data/NameFile.swift deleted file mode 100644 index 0ab2664..0000000 --- a/CapCollector/Data/NameFile.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// NameFile.swift -// CapFinder -// -// Created by User on 23.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import SwiftyDropbox - -final class NameFile: Logger { - - static let logToken = "[NameFile]" - - /// The name of the file - private static let fileName = "names.txt" - - private static let path = "/" + fileName - - /// The url of the file on disk - private static let url = DiskManager.documentsDirectory.appendingPathComponent(fileName) - - private static let fm = FileManager.default - - // MARK: - Reading from disk - - /// Indicates if the name list was written to disk - private static var nameFileExistsOnDisk: Bool { - return fm.fileExists(atPath: url.path) - } - - // MARK: - Public API - - @discardableResult static func save(names: String) -> Bool { - let data = names.data(using: .utf8)! - return save(names: data) - } - - static func saveAndUpload(names: String, completion: @escaping (Bool) -> Void) { - let data = names.data(using: .utf8)! - guard save(names: data) else { - return - } - - let client = DropboxController.client - client.files.upload(path: path, mode: .overwrite, input: data).response { _ , error in - if let error = error { - self.error("Error uploading name list: \(error)") - completion(false) - } else { - self.event("Uploaded name list") - completion(true) - } - } - } - - // MARK: - Private - - /// The content of the name file as a String - private static var content: String? { - do { - return try String(contentsOf: url, encoding: .utf8) - } catch { - self.error("Error reading \(url): \(error)") - return nil - } - } - - /** - Save the name file to disk - - parameter names: The new name file content - - returns: True, if the data was written to disk - */ - @discardableResult private static func save(names: Data) -> Bool { - do { - try names.write(to: url, options: .atomic) - event("Name file saved to disk") - return true - } catch { - self.error("Could not save names to file: \(error)") - return false - } - } - - - static func makeAvailable(completion: ((String?) -> Void)? = nil) { - if nameFileExistsOnDisk { - completion?(self.content) - } else { - download() { success in - guard success else { - completion?(nil) - return - } - completion?(self.content) - } - } - } - - /// The data of the name list - private static var data: Data? { - guard nameFileExistsOnDisk else { return nil } - do { - return try Data(contentsOf: url) - } catch { - self.error("Could not read data from \(url): \(error)") - return nil - } - } - - /** - Delete the file on disk - - returns: True, if the file no longer exists on disk - */ - @discardableResult private static func delete() -> Bool { - guard nameFileExistsOnDisk else { - event("No name file to delete") - return true - } - - do { - try fm.removeItem(at: url) - } catch { - self.error("Could not delete name file: \(error)") - return false - } - event("Deleted name file on disk") - return true - } - - private static func download(completion: ((Bool) -> Void)? = nil) { - let client = DropboxController.client - event("Downloading names from Dropbox") - client.files.download(path: path).response { response, error in - guard let data = response?.1 else { - self.error("Error downloading file: \(error!)") - completion?(false) - return - } - self.event("Downloaded name file") - completion?(NameFile.save(names: data)) - } - } -} diff --git a/CapCollector/Data/ServerDatabase.swift b/CapCollector/Data/ServerDatabase.swift new file mode 100644 index 0000000..0cab762 --- /dev/null +++ b/CapCollector/Data/ServerDatabase.swift @@ -0,0 +1,47 @@ +// +// ServerDatabase.swift +// CapCollector +// +// Created by Christoph on 27.04.20. +// Copyright © 2020 CH. All rights reserved. +// + +import Foundation +import SQLite + +final class ServerDatabase { + + let db: Connection + + var table: Table { + Table("caps") + } + + let rowId = Expression("id") + + let rowName = Expression("name") + + let rowCount = Expression("count") + + init?(downloadedTo url: URL) { + guard let db = try? Connection(url.path) else { + return nil + } + self.db = db + log("Server database loaded with \(capCount) caps") + } + + /// The number of caps currently in the database + var capCount: Int { + (try? db.scalar(table.count)) ?? 0 + } + + var caps: [(id: Int, count: Int, name: String)] { + guard let rows = try? db.prepare(table) else { + return [] + } + return rows.map { ($0[rowId], $0[rowCount], $0[rowName]) } + } +} + +extension ServerDatabase: Logger { } diff --git a/CapCollector/Data/Storage.swift b/CapCollector/Data/Storage.swift new file mode 100644 index 0000000..24386a4 --- /dev/null +++ b/CapCollector/Data/Storage.swift @@ -0,0 +1,321 @@ +// +// DiskManager.swift +// CapFinder +// +// Created by User on 23.04.18. +// Copyright © 2018 User. All rights reserved. +// + +import Foundation +import UIKit +import CoreML +import Vision + +final class Storage { + + // 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") + } + + private func localImageUrl(for cap: Int, version: Int) -> URL { + baseUrl.appendingPathComponent("\(cap)-\(version).jpg") + } + + private func thumbnailUrl(for cap: Int) -> URL { + baseUrl.appendingPathComponent("\(cap)-thumb.jpg") + } + + // 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) -> Bool { + 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 true + } catch { + log("Failed to delete or move image \(version) for cap \(cap)") + return false + } + } + + /** + 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 + */ + 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: 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) + } + + 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 = 0) -> 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 + } + + /** + 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` + */ + func ciImage(for cap: Int, version: Int = 0) -> CIImage? { + guard let url = existingImageUrl(for: cap, version: version) else { + return nil + } + return CIImage(contentsOf: url) + } + + /** + 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 averageColor(for cap: Int, version: Int = 0) -> UIColor? { + guard let inputImage = ciImage(for: cap, version: version) else { + error("Failed to read CIImage for main image of cap \(cap)") + return nil + } + return inputImage.averageColor + } + + + 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 { + guard let url = existingImageUrl(for: cap, version: version) else { + return true + } + do { + try fm.removeItem(at: url) + return true + } catch { + log("Failed to delete image \(version) for cap \(cap): \(error)") + return false + } + } +} + +extension Storage: Logger { } + diff --git a/CapCollector/Data/Upload.swift b/CapCollector/Data/Upload.swift new file mode 100644 index 0000000..9953129 --- /dev/null +++ b/CapCollector/Data/Upload.swift @@ -0,0 +1,180 @@ +// +// Upload.swift +// CapCollector +// +// Created by Christoph on 26.04.20. +// Copyright © 2020 CH. All rights reserved. +// + +import Foundation +import UIKit +import SQLite + +struct Upload { + + let serverUrl: URL + + let table = Table("uploads") + + let rowCapId = Expression("cap") + + let rowCapVersion = Expression("version") + + init(server: URL) { + self.serverUrl = server + } + + // MARK: Paths + + var serverImageUrl: URL { + serverUrl.appendingPathComponent("images") + } + + private func serverImageUrl(for cap: Int, version: Int = 0) -> URL { + serverImageUrl.appendingPathComponent("\(cap)/\(cap)-\(version).jpg") + } + + private func serverImageUploadUrl(for cap: Int) -> URL { + serverImageUrl.appendingPathComponent("\(cap)") + } + + private func serverNameUploadUrl(for cap: Int) -> URL { + serverUrl.appendingPathComponent("name/\(cap)") + } + + private func serverChangeMainImageUrl(for cap: Int, to newValue: Int) -> URL { + serverUrl.appendingPathComponent("switch/\(cap)/\(newValue)") + } + + // MARK: SQLite + + var createQuery: String { + table.create(ifNotExists: true) { t in + t.column(rowCapId) + t.column(rowCapVersion) + } + } + + func 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 + } + } + task.resume() + } + + func uploadImage(for cap: Int, version: Int, completion: @escaping (_ count: Int?) -> Void) { + guard let url = app.storage.existingImageUrl(for: cap, version: version) else { + completion(nil) + return + } + 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 \(version) of cap \(cap): \(error)") + completion(nil) + return + } + guard let response = response else { + self.log("Failed to upload image \(version) of cap \(cap): No response") + completion(nil) + return + } + guard let urlResponse = response as? HTTPURLResponse else { + self.log("Failed to upload image \(version) of cap \(cap): \(response)") + completion(nil) + return + } + guard urlResponse.statusCode == 200 else { + self.log("Failed to upload image \(version) 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 \(version) of cap \(cap): Invalid response") + completion(nil) + return + } + completion(int) + } + task.resume() + } + + /** + 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 new average color on completion. + */ + func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ averageColor: UIColor?) -> Void) { + guard let averageColor = app.storage.averageColor(for: cap, version: version) else { + completion(nil) + return + } + let url = serverChangeMainImageUrl(for: cap, to: version) + var request = URLRequest(url: url) + let averageRGB = averageColor.rgb + request.addValue("\(averageRGB.red)", forHTTPHeaderField: "r") + request.addValue("\(averageRGB.green)", forHTTPHeaderField: "g") + request.addValue("\(averageRGB.blue)", forHTTPHeaderField: "b") + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + self.log("Failed to set main image of cap \(cap) to \(version): \(error)") + completion(nil) + return + } + guard let response = response else { + self.log("Failed to set main image of cap \(cap) to \(version): No response") + completion(nil) + return + } + guard let urlResponse = response as? HTTPURLResponse else { + self.log("Failed to set main image of cap \(cap) to \(version): \(response)") + completion(nil) + return + } + guard urlResponse.statusCode == 200 else { + self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)") + completion(nil) + return + } + + completion(averageColor) + } + task.resume() + } +} + +extension Upload: Logger { } diff --git a/CapCollector/Data/UserDefaults.swift b/CapCollector/Data/UserDefaults.swift deleted file mode 100644 index d82e73d..0000000 --- a/CapCollector/Data/UserDefaults.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// UserDefaults.swift -// CapCollector -// -// Created by Christoph on 16.10.18. -// Copyright © 2018 CH. All rights reserved. -// - -import Foundation - -final class Persistence { - - static var recognizedCapCount: Int { - get { - return UserDefaults.standard.integer(forKey: "recognizedCaps") - } - set { - UserDefaults.standard.set(newValue, forKey: "recognizedCaps") - } - } - - static var newImageCount: Int { - get { - return UserDefaults.standard.integer(forKey: "newImages") - } - set { - UserDefaults.standard.set(newValue, forKey: "newImages") - } - } - - static var lastUploadedCapCount: Int { - get { - return UserDefaults.standard.integer(forKey: "lastUploadedCaps") - } - set { - UserDefaults.standard.set(newValue, forKey: "lastUploadedCaps") - } - } - - static var lastUploadedImageCount: Int { - get { - return UserDefaults.standard.integer(forKey: "lastUploadedImages") - } - set { - UserDefaults.standard.set(newValue, forKey: "lastUploadedImages") - } - } - - static var useMobileNet: Bool { - get { - return UserDefaults.standard.bool(forKey: "mobileNet") - } - - set { - UserDefaults.standard.set(newValue, forKey: "mobileNet") - } - } - - static var folderNotCreated: [Int] { - get { - return UserDefaults.standard.array(forKey: "folders") as? [Int] ?? [] - } - set { - UserDefaults.standard.set(newValue, forKey: "folders") - } - } -} diff --git a/CapCollector/Extensions/Array+Extensions.swift b/CapCollector/Extensions/Array+Extensions.swift new file mode 100644 index 0000000..4514c4e --- /dev/null +++ b/CapCollector/Extensions/Array+Extensions.swift @@ -0,0 +1,30 @@ +// +// Array+Extensions.swift +// CapCollector +// +// Created by Christoph on 12.05.20. +// Copyright © 2020 CH. All rights reserved. +// + +import Foundation + +extension Array { + + func split(intoPartsOf maxElements: Int) -> [ArraySlice] { + guard !isEmpty, maxElements > 0 else { + return [] + } + var result = [ArraySlice]() + var currentIndex = 0 + while true { + let nextIndex = currentIndex + maxElements + if nextIndex >= count { + result.append(self[currentIndex.. CGFloat { + return max(min(CGFloat(component) * 2 - 150, 255), 0) / 255 +} + +extension CIImage: Logger { } diff --git a/CapCollector/Extensions/UINavigationItem+Extensions.swift b/CapCollector/Extensions/UINavigationItem+Extensions.swift new file mode 100644 index 0000000..7ac106f --- /dev/null +++ b/CapCollector/Extensions/UINavigationItem+Extensions.swift @@ -0,0 +1,31 @@ +// +// UINavigationItem+Extensions.swift +// CapCollector +// +// Created by Christoph on 12.05.20. +// Copyright © 2020 CH. All rights reserved. +// + +import UIKit + +extension UINavigationItem { + + func setTitle(_ title: String, subtitle: String) { + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 17.0) + titleLabel.textColor = .black + + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = .systemFont(ofSize: 12.0) + subtitleLabel.textColor = .gray + + let stackView = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + stackView.distribution = .equalCentering + stackView.alignment = .center + stackView.axis = .vertical + + self.titleView = stackView + } +} diff --git a/CapCollector/Extensions/UIViewExtensions.swift b/CapCollector/Extensions/UIViewExtensions.swift index 84dc911..4619a0a 100644 --- a/CapCollector/Extensions/UIViewExtensions.swift +++ b/CapCollector/Extensions/UIViewExtensions.swift @@ -16,4 +16,8 @@ extension UIView { subviews.forEach { subviews.append(contentsOf: $0.recursiveSubviews) } return subviews } + + func fromNib() -> T { // 2 + return Bundle(for: type(of: self)).loadNibNamed(String(describing: type(of: self)), owner: self, options: nil)!.first! as! T + } } diff --git a/CapCollector/Extensions/ViewControllerExtensions.swift b/CapCollector/Extensions/ViewControllerExtensions.swift index 0f86723..a0fb98e 100644 --- a/CapCollector/Extensions/ViewControllerExtensions.swift +++ b/CapCollector/Extensions/ViewControllerExtensions.swift @@ -18,8 +18,8 @@ extension UIViewController { let alertController = UIAlertController( title: title, message: message, - preferredStyle: .alert, - blurStyle: .dark) + preferredStyle: .alert)//, + //blurStyle: .dark) alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) diff --git a/CapCollector/Info.plist b/CapCollector/Info.plist index 3d2aa0c..36be64f 100644 --- a/CapCollector/Info.plist +++ b/CapCollector/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3 + $(MARKETING_VERSION) CFBundleURLTypes @@ -67,7 +67,7 @@ armv7 UIStatusBarStyle - UIStatusBarStyleLightContent + UIStatusBarStyleDefault UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/CapCollector/Logger.swift b/CapCollector/Logger.swift index ae7baa0..3e6c8d3 100644 --- a/CapCollector/Logger.swift +++ b/CapCollector/Logger.swift @@ -10,26 +10,28 @@ import Foundation protocol Logger { - static var logToken: String { get } - } extension Logger { - - func error(_ message: String) { - Self.addToFile(Self.logToken + " ERROR: " + message) + + static var logToken: String { + "[" + String(describing: self) + "] " } - func event(_ message: String) { - Self.addToFile(Self.logToken + " " + message) + func error(_ message: String) { + Self.addToFile(Self.logToken + "ERROR: " + message) + } + + func log(_ message: String) { + Self.addToFile(Self.logToken + message) } static func error(_ message: String) { - addToFile(logToken + " ERROR: " + message) + addToFile(logToken + "ERROR: " + message) } - static func event(_ message: String) { - addToFile(logToken + " " + message) + static func log(_ message: String) { + addToFile(logToken + message) } private static func addToFile(_ message: String) { diff --git a/CapCollector/Presentation/CapCell.swift b/CapCollector/Presentation/CapCell.swift index 8e64968..55af3ac 100644 --- a/CapCollector/Presentation/CapCell.swift +++ b/CapCollector/Presentation/CapCell.swift @@ -18,29 +18,31 @@ class CapCell: UITableViewCell { @IBOutlet private weak var nameLabel: UILabel! @IBOutlet weak var countLabel: UILabel! - - var id = 0 - - var cap: Cap! { - didSet { - updateCell() - } - } - func updateCell() { - capImage.image = cap.image + var id: Int = 0 + + func set(image: UIImage?) { + capImage.image = image ?? UIImage(named: "launch") + } + + func set(cap: Cap, match: Float?) { + id = cap.id + if let image = cap.image { + set(image: image) + + } else { + capImage.image = UIImage(named: "launch") + cap.downloadMainImage() { image in + self.set(image: image) + } + } + //capImage.borderColor = AppDelegate.tintColor - matchLabel.text = text(for: cap.match) + matchLabel.text = cap.matchDescription(match: match) nameLabel.text = cap.name - countLabel.text = "\(cap.id) (\(cap.count) image" + (cap.count > 1 ? "s)" : ")") + countLabel.text = cap.subtitle } - private func text(for value: Float?) -> String? { - guard let nr = value else { - return nil - } - let percent = Int((nr * 100).rounded()) - return String(format: "%d %%", arguments: [percent]) - } + } diff --git a/CapCollector/Presentation/GridViewController.swift b/CapCollector/Presentation/GridViewController.swift index 1f86bfb..da65b4e 100644 --- a/CapCollector/Presentation/GridViewController.swift +++ b/CapCollector/Presentation/GridViewController.swift @@ -10,8 +10,10 @@ import UIKit class GridViewController: UIViewController { + /// The number of caps horizontally. private let columns = 40 + /// The number of hroizontal pixels for each cap. static let len: CGFloat = 60 private lazy var rowHeight = GridViewController.len * 0.866 @@ -24,6 +26,13 @@ class GridViewController: UIViewController { @IBOutlet weak var scrollView: UIScrollView! + /// A dictionary of the caps for the tiles + private var tiles = [Cap]() + + private var installedTiles = [Int : RoundedImageView]() + + private var changedTiles = Set() + private var selectedTile: Int? = nil private weak var selectionView: RoundedButton! @@ -38,13 +47,21 @@ class GridViewController: UIViewController { private var isShowingColors = false + private var capCount = 0 + @IBAction func toggleAverageColor(_ sender: Any) { isShowingColors = !isShowingColors for (tile, view) in installedTiles { if isShowingColors { view.image = nil } else { - view.image = Cap.tileImage(tile: tile) + if let image = tiles[tile].thumbnail { + view.image = image + continue + } + tiles[tile].downloadMainImage() { image in + view.image = image + } } } } @@ -52,12 +69,15 @@ class GridViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + app.database.add(listener: self) + capCount = app.database.capCount + tiles = app.database.caps.sorted { $0.tile < $1.tile } + let width = CGFloat(columns) * GridViewController.len + GridViewController.len / 2 - let height = (CGFloat(Cap.totalCapCount) / CGFloat(columns)).rounded(.up) * rowHeight + margin + let height = (CGFloat(capCount) / 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 @@ -82,10 +102,36 @@ class GridViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - - Cap.save() + + saveChangedTiles() } + // MARK: Tiles + + private func tileColor(tile: Int) -> UIColor { + return tiles[tile].color + } + + private func saveChangedTiles() { + for tile in changedTiles { + let cap = tiles[tile] + app.database.update(tile: tile, for: cap.id) + } + } + + /** + Switch two tiles. + */ + private func switchTiles(_ lhs: Int, _ rhs: Int) -> Bool { + let temp = tiles[rhs] + tiles[rhs] = tiles[lhs] + tiles[lhs] = temp + changedTiles.insert(lhs) + changedTiles.insert(rhs) + return true + } + + private func setZoomRange() { let size = scrollView.frame.size let a = size.width / canvasSize.width @@ -106,11 +152,16 @@ class GridViewController: UIViewController { guard s > margin else { return } - let column: CGFloat + let column: Int if row.isEven { - column = loc.x / GridViewController.len + column = Int(loc.x / GridViewController.len) + // Abort, if user tapped outside of the grid + if column >= columns { + clearTileSelection() + return + } } else { - column = (loc.x - GridViewController.len / 2) / GridViewController.len + column = Int((loc.x - GridViewController.len / 2) / GridViewController.len) } handleTileTapped(tile: row * columns + Int(column)) } @@ -123,8 +174,6 @@ class GridViewController: UIViewController { } } - private var installedTiles = [Int : RoundedImageView]() - private func showSelection(tile: Int) { clearTileSelection() @@ -144,13 +193,23 @@ class GridViewController: UIViewController { private func makeTile(_ tile: Int) { let view = RoundedImageView(frame: frame(for: tile)) myView.addSubview(view) - view.backgroundColor = Cap.tileColor(tile: tile) + view.backgroundColor = tileColor(tile: tile) + defer { + installedTiles[tile] = view + } // Only set image if images are shown - if !isShowingColors { - view.image = Cap.tileImage(tile: tile) + guard !isShowingColors else { + return + + } + if let image = tiles[tile].thumbnail { + view.image = image + return } - installedTiles[tile] = view + tiles[tile].downloadMainImage() { image in + view.image = image + } } private func frame(for tile: Int) -> CGRect { @@ -166,13 +225,18 @@ class GridViewController: UIViewController { clearTileSelection() return } - Cap.switchTiles(oldTile, newTile) + guard switchTiles(oldTile, newTile) else { + clearTileSelection() + return + } // Switch cap colors - installedTiles[oldTile]?.backgroundColor = Cap.tileColor(tile: oldTile) - installedTiles[newTile]?.backgroundColor = Cap.tileColor(tile: newTile) + let temp = installedTiles[oldTile]?.backgroundColor + installedTiles[oldTile]?.backgroundColor = installedTiles[newTile]?.backgroundColor + installedTiles[newTile]?.backgroundColor = temp if !isShowingColors { - installedTiles[oldTile]?.image = Cap.tileImage(tile: oldTile) - installedTiles[newTile]?.image = Cap.tileImage(tile: newTile) + let temp = installedTiles[oldTile]?.image + installedTiles[oldTile]?.image = installedTiles[newTile]?.image + installedTiles[newTile]?.image = temp } clearTileSelection() @@ -187,32 +251,46 @@ class GridViewController: UIViewController { } private func showTiles(in rect: CGRect) { - for i in 0.. UIView? { + let view = super.hitTest(point, with: event) + return view == self ? nil : view + } +} + +protocol CapAccessoryDelegate: class { + + func capSearchWasDismissed() + + func capSearch(didChange text: String) + + func capAccessoryDidDiscardImage() + + func capAccessory(shouldSave image: UIImage) + + func capAccessoryCameraButtonPressed() +} + +class SearchAndDisplayAccessory: PassthroughView { + + // MARK: - Outlets + + @IBOutlet weak var newImageView: PassthroughView! + + @IBOutlet weak var capImage: RoundedImageView! + + @IBOutlet weak var saveButton: UIButton! + + @IBOutlet weak var deleteButton: UIButton! + + @IBOutlet weak var cameraButton: UIButton! + + @IBOutlet weak var searchBar: UISearchBar! + + // MARK: - Actions + + @IBAction func cameraButtonPressed() { + delegate?.capAccessoryCameraButtonPressed() + } + + @IBAction func saveButtonPressed() { + if let image = capImage.image { + delegate?.capAccessory(shouldSave: image) + } + } + + @IBAction func cancelButtonPressed() { + discardImage() + } + + // MARK: - Variables + + var view: UIView? + + weak var blurView: UIVisualEffectView? + + weak var currentBlurContraint: NSLayoutConstraint? + + weak var delegate: CapAccessoryDelegate? + + var currentImage: UIImage? { + capImage.image + } + + // 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!) + + let blur = UIBlurEffect(style: .systemThinMaterial) + let blurView = UIVisualEffectView(effect: blur) + self.blurView = blurView + blurView.translatesAutoresizingMaskIntoConstraints = false + blurView.isUserInteractionEnabled = false + insertSubview(blurView, at: 0) + + let t = searchBar.topAnchor.constraint(equalTo: blurView.topAnchor) + let b = searchBar.bottomAnchor.constraint(equalTo: blurView.bottomAnchor) + let l = leadingAnchor.constraint(equalTo: blurView.leadingAnchor) + let r = trailingAnchor.constraint(equalTo: blurView.trailingAnchor) + addConstraints([t, b, l, r]) + + currentBlurContraint = t + + self.newImageView.alpha = 0 + self.newImageView.isHidden = true + + searchBar.text = nil + searchBar.setShowsCancelButton(false, animated: false) + searchBar.delegate = self + + cameraButton.setImage(UIImage.templateImage(named: "camera_square"), for: .normal) + } + + // MARK: Search bar + + func dismissAndClearSearchBar() { + searchBar.resignFirstResponder() + searchBar.text = nil + } + + // MARK: Cap image + + func showImageView(with image: UIImage) { + capImage.image = image + showImageView() + } + + func discardImage() { + dismissAndClearSearchBar() + hideImageView() + delegate?.capAccessoryDidDiscardImage() + } + + func hideImageView() { + currentBlurContraint?.isActive = false + let t = searchBar.topAnchor.constraint(equalTo: blurView!.topAnchor) + addConstraint(t) + currentBlurContraint = t + + self.newImageView.alpha = 0 + self.newImageView.isHidden = true + self.capImage.image = nil + } + + private func showImageView() { + currentBlurContraint?.isActive = false + let t = blurView!.topAnchor.constraint(equalTo: saveButton.topAnchor, constant: -8) + addConstraint(t) + currentBlurContraint = t + + self.newImageView.isHidden = false + self.newImageView.alpha = 1 + } +} + +// MARK: - UISearchBarDelegate + +extension SearchAndDisplayAccessory: UISearchBarDelegate { + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + searchBar.text = nil + delegate?.capSearchWasDismissed() + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } + + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + delegate?.capSearch(didChange: searchText) + } +} diff --git a/CapCollector/Presentation/SearchAndDisplayAccessory.xib b/CapCollector/Presentation/SearchAndDisplayAccessory.xib new file mode 100644 index 0000000..464ef77 --- /dev/null +++ b/CapCollector/Presentation/SearchAndDisplayAccessory.xib @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Title + Title + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CapCollector/Presentation/SettingsController.swift b/CapCollector/Presentation/SettingsController.swift deleted file mode 100644 index da1f5cc..0000000 --- a/CapCollector/Presentation/SettingsController.swift +++ /dev/null @@ -1,272 +0,0 @@ -// -// SettingsController.swift -// CapCollector -// -// Created by Christoph on 15.10.18. -// Copyright © 2018 CH. All rights reserved. -// - -import UIKit - -class SettingsController: UITableViewController { - - @IBOutlet weak var totalCapsLabel: UILabel! - - @IBOutlet weak var totalImagesLabel: UILabel! - - @IBOutlet weak var recognizedCapsLabel: UILabel! - - @IBOutlet weak var imagesStatsLabel: UILabel! - - @IBOutlet weak var databaseUpdatesLabel: UILabel! - - @IBOutlet weak var dropboxAccountLabel: UILabel! - - @IBOutlet weak var countsLabel: UILabel! - - private var isUpdatingCounts = false - - private var isUploadingNameFile = false - - private var isUpdatingThumbnails = false - - private var isUpdatingColors = false - - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait - } - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewWillAppear(_ animated: Bool) { - updateDropboxStatus() - updateNameFileStats() - updateDatabaseStats() - - (navigationController as! NavigationController).allowLandscape = false - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - setClassifierChoice(Persistence.useMobileNet) - } - - private func updateThumbnails() { - isUpdatingThumbnails = true - for cap in Cap.all.values { - cap.makeThumbnail() - } - isUpdatingThumbnails = false - } - - private func updateColors() { - isUpdatingColors = true - Cap.shouldSave = false - for cap in Cap.all.values { - cap.makeAverageColor() - } - Cap.shouldSave = true - isUpdatingColors = false - } - - private func updateDatabaseStats() { - let totalCaps = Cap.totalCapCount - totalCapsLabel.text = "\(totalCaps) caps" - totalImagesLabel.text = "\(Cap.imageCount) images" - let recognizedCaps = Persistence.recognizedCapCount - let newCapCount = totalCaps - recognizedCaps - recognizedCapsLabel.text = "\(newCapCount) new caps" - let ratio = Float(Cap.imageCount)/Float(Cap.totalCapCount) - let (images, count) = Cap.getCapStatistics().enumerated().first(where: { $0.element != 0 })! - imagesStatsLabel.text = String(format: "%.2f images per cap, lowest count: %d (%dx)", ratio, images, count) - } - - private func updateNameFileStats() { - let capCount = Cap.totalCapCount - Persistence.lastUploadedCapCount - let imageCount = Cap.imageCount - Persistence.lastUploadedImageCount - databaseUpdatesLabel.text = "\(capCount) new caps and \(imageCount) new images" - } - - private func updateDropboxStatus() { - dropboxAccountLabel.text = DropboxController.shared.isEnabled ? "Sign out" : "Sign in" - } - - private func setClassifierChoice(_ useMobileNet: Bool) { - tableView.cellForRow(at: IndexPath(row: 0, section: 0))?.accessoryType = useMobileNet ? .checkmark : .none - } - - private func toggleClassifier() { - let newValue = !Persistence.useMobileNet - Persistence.useMobileNet = newValue - setClassifierChoice(newValue) - } - - override func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool { - switch indexPath.section { - case 0: // Choose models - return true - case 1: // Mosaic - return true - case 2: // Database - return indexPath.row == 2 && !isUploadingNameFile - case 3: // Refresh - switch indexPath.row { - case 0: - return !isUpdatingCounts - case 1: - return !isUpdatingThumbnails - case 2: - return !isUpdatingColors - default: - return false - } - case 4: // Dropbox account - return true - case 5: // Log file - return true - default: return false - } - } - - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - switch indexPath.section { - case 0: // Choose models - return indexPath - case 1: // Mosaic - return indexPath - case 2: // Database - return (indexPath.row == 2 && !isUploadingNameFile) ? indexPath : nil - case 3: // Refresh count - switch indexPath.row { - case 0: - return isUpdatingCounts ? nil : indexPath - case 1: - return isUpdatingThumbnails ? nil : indexPath - case 2: - return isUpdatingColors ? nil : indexPath - default: - return nil - } - case 4: // Dropbox account - return indexPath - case 5: // Log file - return indexPath - default: return nil - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: true) - switch indexPath.section { - case 0: // Choose models - toggleClassifier() - case 2: // Upload - if indexPath.row == 2 && !isUploadingNameFile { - uploadNameFile() - } - case 3: // Refresh count - switch indexPath.row { - case 0: - updateCounts() - case 1: - updateThumbnails() - case 2: - updateColors() - default: - break - } - default: - break - } - } - - private func updateCounts() { - isUpdatingCounts = true - Cap.shouldSave = false - // TODO: Don't make all requests at the same time - DispatchQueue.global(qos: .userInitiated).async { - let list = Cap.all.keys.sorted() - let total = list.count - var finished = 0 - let chunks = list.chunked(into: 10) - for chunk in chunks { - self.updateCounts(ids: chunk) - finished += 10 - DispatchQueue.main.async { - self.countsLabel.text = "\(finished) / \(total) finished" - } - } - self.isUpdatingCounts = false - Cap.shouldSave = true - DispatchQueue.main.async { - self.countsLabel.text = "Refresh image counts" - self.updateDatabaseStats() - } - } - } - - private func updateCounts(ids: [Int]) { - var count = ids.count - let s = DispatchSemaphore(value: 0) - for cap in ids { - Cap.all[cap]!.updateCount { _ in - count -= 1 - if count == 0 { - s.signal() - } - } - } - _ = s.wait(timeout: .now() + .seconds(30)) - event("Finished updating ids \(ids.first!) to \(ids.last!)") - } - - private func uploadNameFile() { - event("Uploading name file") - isUploadingNameFile = true - Cap.saveAndUpload() { _ in - self.isUploadingNameFile = false - self.updateNameFileStats() - } - } - - private func toggleDropbox() { - guard !DropboxController.shared.isEnabled else { - DropboxController.shared.signOut() - updateDropboxStatus() - return - } - - DropboxController.shared.setup(in: self) - updateDropboxStatus() - } - - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - guard let id = segue.identifier else { - return - } - switch id { - case "showMosaic": - (navigationController as! NavigationController).allowLandscape = true - case "showLog": - return - default: - return - } - - } -} - -extension SettingsController: Logger { - - static let logToken = "[Settings]" -} - -private extension Array { - func chunked(into size: Int) -> [[Element]] { - return stride(from: 0, to: count, by: size).map { - Array(self[$0 ..< Swift.min($0 + size, count)]) - } - } -} diff --git a/CapCollector/Presentation/SortController.swift b/CapCollector/Presentation/SortController.swift index 1985b2d..d589793 100644 --- a/CapCollector/Presentation/SortController.swift +++ b/CapCollector/Presentation/SortController.swift @@ -15,7 +15,9 @@ enum SortCriteria: Int { case match = 3 } -protocol SortControllerDelegate { +protocol SortControllerDelegate: class { + + var sortControllerShouldIncludeMatchOption: Bool { get } func sortController(didSelect sortType: SortCriteria, ascending: Bool) } @@ -26,32 +28,19 @@ class SortController: UITableViewController { var ascending: Bool = true - var delegate: SortControllerDelegate? + private var includeMatches: Bool { + delegate?.sortControllerShouldIncludeMatchOption ?? false + } + + weak var delegate: SortControllerDelegate? override func viewDidLoad() { super.viewDidLoad() - let height = Cap.hasMatches ? 310 : 270 + let height = includeMatches ? 298 : 258 preferredContentSize = CGSize(width: 200, height: height) } - private func setCell() { - for i in 0..<4 { - let index = IndexPath(row: i, section: 1) - let cell = tableView.cellForRow(at: index) - cell?.accessoryType = i == selected.rawValue ? .checkmark : .none - } - let index = IndexPath(row: 0, section: 0) - let cell = tableView.cellForRow(at: index) - cell?.accessoryType = ascending ? .checkmark : .none - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - setCell() - } - private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() @@ -63,7 +52,7 @@ class SortController: UITableViewController { tableView.deselectRow(at: indexPath, animated: true) guard indexPath.section == 1 else { ascending = !ascending - setCell() + tableView.reloadData() delegate?.sortController(didSelect: selected, ascending: ascending) giveFeedback(.light) return @@ -76,17 +65,16 @@ class SortController: UITableViewController { override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { guard indexPath.row == 3 else { return indexPath } - return Cap.hasMatches ? indexPath : nil + return includeMatches ? indexPath : nil } - - /* - // MARK: - Navigation - - // In a storyboard-based application, you will often want to do a little preparation before navigation - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - // Get the new view controller using segue.destination. - // Pass the selected object to the new view controller. + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + switch indexPath.section { + case 0: + cell.accessoryType = ascending ? .checkmark : .none + default: + cell.accessoryType = indexPath.row == selected.rawValue ? .checkmark : .none + break + } } - */ - } diff --git a/CapCollector/Sync/DropBoxController.swift b/CapCollector/Sync/DropBoxController.swift deleted file mode 100644 index c74c09a..0000000 --- a/CapCollector/Sync/DropBoxController.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// DropboxController.swift -// CapFinder -// -// Created by User on 08.04.18. -// Copyright © 2018 User. All rights reserved. -// - -import Foundation -import SwiftyDropbox -import UIKit - -class DropboxController: Logger { - - static var logToken = "[Dropbox]" - - static var shared = DropboxController() - - // MARK: Dropbox API - - static var client: DropboxClient { - return DropboxClientsManager.authorizedClient! - } - - var isEnabled: Bool { - return DropboxClientsManager.authorizedClient != nil - } - - // MARK: - Setup - - init() { - - } - - /** Register dropbox, load names and images. - - parameter viewController: The controller launching the request - */ - func setup(in viewController: UIViewController) { - guard isEnabled == false else { - event("Enabled") - return - } - event("Requesting access") - DropboxClientsManager.authorizeFromController( - UIApplication.shared, - controller: viewController) { - UIApplication.shared.open($0, options: [:]) - } - } - - func signOut() { - DropboxClientsManager.unlinkClients() - } - - /// Process the response of the dropbox registration handler - func handle(url: URL) { - guard let authResult = DropboxClientsManager.handleRedirectURL(url) else { - return - } - switch authResult { - case .success: - event("enabled") - case .cancel: - error("Authorization flow canceled") - case .error(_, let description): - error("Error on authentification: \(description)") - } - } - - /// Download name list and cap images - func initializeDatabase() { - guard isEnabled else { - event("Dropbox not enabled") - return - } - Cap.load() - } - - func move(file: String, to: String, completion: @escaping (Bool) -> Void) { - DropboxController.client.files.moveV2(fromPath: file, toPath: to).response { _, error in - if let err = error { - self.error("Failed to move file: \(err)") - completion(false) - return - } - completion(true) - } - } -} diff --git a/CapCollector/TableView.swift b/CapCollector/TableView.swift index addc284..5953c8b 100644 --- a/CapCollector/TableView.swift +++ b/CapCollector/TableView.swift @@ -8,281 +8,96 @@ import UIKit -class TableView: UIViewController { +import JGProgressHUD - @IBOutlet weak var table: UITableView! +class TableView: UITableViewController { - @IBOutlet weak var searchBar: UISearchBar! + private var classifier: Classifier? - @IBOutlet weak var searchBarConstraint: NSLayoutConstraint! + private var accessory: SearchAndDisplayAccessory? - @IBOutlet weak var imageButtonConstraint: NSLayoutConstraint! + private var titleLabel: UILabel! - private let imageViewDistance: CGFloat = 90 - - override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent - } - - // MARK: - Life cycle - - override func viewDidLoad() { - super.viewDidLoad() - - Classifier.shared.delegate = self - Cap.delegate = self - tableViewSetup() - imageButtonSetup() - searchBar.delegate = self - searchBarSetup() - self.imageViewContraint.constant = imageViewDistance - DropboxController.shared.initializeDatabase() - } - + 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: Cap? + private var capToAddImageTo: Int? - // MARK: - Sort - - /** - 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() { - caps = Cap.capList(sortedBy: sortType, ascending: sortAscending) - shownCaps = caps - table.reloadData() - tableViewScrollToTop() + // 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)" + } } - // MARK: - TableView - - private func tableViewSetup() { - table.dataSource = self - table.delegate = self - table.rowHeight = 100 - } - - /** - Scroll the table view to the top - */ - private func tableViewScrollToTop() { - guard shownCaps.count > 0 else { return } - let path = IndexPath(row: 0, section: 0) - table.scrollToRow(at: path, at: .top, animated: true) - } - - private func rename(cap: Cap, at indexPath: IndexPath) { - - let alertController = UIAlertController( - title: "Enter name", - message: "Choose a name for the image", - preferredStyle: .alert, - blurStyle: .dark) - - alertController.addTextField { textField in - textField.text = cap.name - textField.placeholder = "Cap name" - textField.keyboardType = .default + private var subtitleText: String { + let capCount = app.database.capCount + guard capCount > 0 else { + return "" } - - let action = UIAlertAction(title: "Save", style: .default) { _ in - guard let name = alertController.textFields?.first?.text else { - return - } - cap.name = name - self.table.reloadRows(at: [indexPath], with: .none) - } - alertController.addAction(action) + let allImages = app.database.imageCount - let cancel = UIAlertAction(title: "Cancel", style: .cancel) - alertController.addAction(cancel) - - self.present(alertController, animated: true) - alertController.view.tintColor = AppDelegate.tintColor + let ratio = Float(allImages) / Float(capCount) + return String(format: "%d images (%.2f per cap)", allImages, ratio) } - // MARK: - Search bar - - private func searchBarSetup() { - searchBar.text = nil - searchBar.setShowsCancelButton(false, animated: false) - registerKeyboardNotifications() - } - - private func setSearchBarTextColor() { - for subView in searchBar.subviews { - for view in subView.subviews { - if let textView = view as? UITextField { - textView.textColor = AppDelegate.tintColor - return - } - } - } - } - - private func registerKeyboardNotifications() { - NotificationCenter.default.addObserver( - self, selector: #selector(TableView.animateWithKeyboard), - name: UIResponder.keyboardWillShowNotification, object: nil) - - NotificationCenter.default.addObserver( - self, selector: #selector(TableView.animateWithKeyboard), - name: UIResponder.keyboardWillHideNotification, object: nil) - } - - @objc func animateWithKeyboard(notification: NSNotification) { - let userInfo = notification.userInfo! - let keyboardHeight = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue.height - let duration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! Double - let curve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! UInt - let moveUp = (notification.name == UIResponder.keyboardWillShowNotification) - - searchBarConstraint.constant = moveUp ? keyboardHeight + 1 : 0 - searchBar.setShowsCancelButton(moveUp, animated: false) - imageButtonConstraint.constant = moveUp ? -56 : 0 - - let options = UIView.AnimationOptions(rawValue: curve << 16) - - UIView.animate(withDuration: duration, delay: 0, options: options, - animations: { self.view.layoutIfNeeded() }) - } - - // MARK: - Image/clear button - - @IBOutlet weak var imageClearButton: UIButton! - - private func imageButtonSetup() { - let tint = AppDelegate.tintColor - imageClearButton.setImage(UIImage.templateImage(named: "camera_square"), for: .normal) - imageClearButton.tintColor = tint - } - - @IBAction func imageClearButtonPressed() { - discardImage() - } - - // MARK: - Image view - - @IBOutlet weak var imageView: UIView! - - @IBOutlet weak var imageViewContraint: NSLayoutConstraint! - - private func discardImage() { - searchBar.resignFirstResponder() - searchBar.text = nil - Cap.hasMatches = false - showAllCapsByDescendingId() - imageView(shouldBeVisible: false) - } - - private func showImageView(with image: UIImage) { - newImage.image = image - showImageView() - } - - private func imageView(shouldBeVisible: Bool) { - shouldBeVisible ? showImageView() : dismissImageView() - } - - private func showImageView() { - UIView.animate(withDuration: 0.5) { - self.imageViewContraint.constant = 0 - } - } - - private func dismissImageView() { - UIView.animate(withDuration: 0.5, animations: { - self.imageViewContraint.constant = self.imageViewDistance - }) { _ in self.newImage.image = nil } - } - - @IBOutlet weak var newImage: RoundedImageView! - - @IBOutlet weak var saveButton: RoundedButton! - - @IBAction func saveButtonPressed() { - if let image = newImage.image { - saveNewCap(for: image) - } - } - - private func saveNewCap(for image: UIImage) { - - let alertController = UIAlertController( - title: "Enter name", - message: "Choose a name for the image", - preferredStyle: .alert, - blurStyle: .dark) - - alertController.addTextField { textField in - //textField.backgroundColor = UIColor.darkGray - textField.textColor = AppDelegate.tintColor - textField.placeholder = "Cap name" - textField.keyboardType = .default - } - - let action = UIAlertAction(title: "Save", style: .default) { _ in - guard let name = alertController.textFields?.first?.text else { - self.showAlert("No name for cap") - return - } - - let _ = Cap(image: image, name: name) - self.discardImage() - } - - let cancel = UIAlertAction(title: "Cancel", style: .cancel) - - alertController.addAction(action) - alertController.addAction(cancel) - self.present(alertController, animated: true) - alertController.view.tintColor = AppDelegate.tintColor - } - - @IBOutlet weak var deleteButton: RoundedButton! - - @IBAction func deleteButtonPressed() { - discardImage() + // MARK: Overrides + + override var inputAccessoryView: UIView? { + get { return accessory } } - // MARK: - Navigation + override var canBecomeFirstResponder: Bool { + return true + } + + // MARK: - Actions - override func prepare(for segue: UIStoryboardSegue, sender: Any?) { - self.giveFeedback(.medium) - guard let id = segue.identifier else { - error("No identifier for segue") + @IBAction func updateInfo(_ sender: UIBarButtonItem) { + downloadNewestClassifierIfNeeded() + downloadImageCounts() + checkIfCapImagesNeedDownload() + } + + @IBAction func showMosaic(_ sender: UIBarButtonItem) { + let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController + guard let nav = navigationController as? NavigationController else { return } - switch id { - case "showCamera": - (segue.destination as! CameraController).delegate = self - case "showSettings": - break - default: - error("Invalid segue identifier \(id)") - } + nav.pushViewController(vc, animated: true) + nav.allowLandscape = true } - @IBAction func showSortOptions(_ sender: UIBarButtonItem) { + 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 @@ -291,10 +106,436 @@ class TableView: UIViewController { let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller) - presentationController.barButtonItem = sender + presentationController.sourceView = navigationItem.titleView! presentationController.permittedArrowDirections = [.up] self.present(controller, animated: true) } + + // MARK: - Life cycle + + override func viewDidLoad() { + super.viewDidLoad() + + app.database.add(listener: self) + tableView.rowHeight = 100 + + accessory = SearchAndDisplayAccessory(width: self.view.frame.width) + accessory?.delegate = self + + reloadCapsFromDatabase() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + (navigationController as? NavigationController)?.allowLandscape = false + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + checkDatabaseIsDownloaded() + checkClassifierIsDownloaded() + } + + 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 + + let recognizer = UITapGestureRecognizer(target: self, action: #selector(titleWasTapped)) + stackView.isUserInteractionEnabled = true + stackView.addGestureRecognizer(recognizer) + } + + private func updateNavigationItemTitleView() { + DispatchQueue.main.async { + self.titleLabel.text = self.titleText + self.subtitleLabel.text = self.subtitleText + } + } + + // MARK: Starting updates + + private func downloadImageCounts() { + app.database.downloadImageCounts() + } + + private func downloadNewestClassifierIfNeeded() { + app.database.hasNewClassifier { version, size in + guard let version = version else { + return + } + DispatchQueue.main.async { + self.askUserToDownload(classifier: version, size: size) + } + } + } + + 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 { + return + } + guard let newCap = app.database.cap(for: cap.id) else { + return + } + self.shownCaps[indexPath.row] = newCap + self.tableView.reloadRows(at: [indexPath], with: .none) + } + } + + 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 + guard app.database.createCap(image: image, name: text) else { + self.showAlert("Cap not added") + return + } + self.accessory!.discardImage() + } + } + + + // MARK: User interaction + + private func checkClassifierIsDownloaded() { + guard let model = app.storage.recognitionModel else { + downloadNewestClassifierIfNeeded() + return + } + classifier = Classifier(model: model) + } + + private func checkDatabaseIsDownloaded() { + guard app.needsDownload else { + return + } + log("Server database not available, getting database size") + app.database.getServerDatabaseSize { size in + DispatchQueue.main.async { + self.askUserToDownloadServerDatabase(size: size) + } + } + } + + private func askUserToDownloadServerDatabase(size: Int64?) { + let detail = "The server database needs to be downloaded for the app to function properly. Would you like to download it now?" + let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" + presentUserBinaryChoice("Download server database", detail: detail + sizeText, yesText: "Download", noText: "Later") { + self.downloadServerDatabase() + } + } + + private func checkIfCapImagesNeedDownload() { + let count = app.database.capsWithoutImages + guard count > 0 else { + log("No cap images to download") + return + } + DispatchQueue.main.async { + self.askUserToDownload(capImages: count) + } + } + + private func askUserToDownload(capImages: Int) { + let detail = "\(capImages) caps have no image. Would you like to download them now? (\(ByteCountFormatter.string(fromByteCount: Int64(capImages * 10000), countStyle: .file)))" + presentUserBinaryChoice("New classifier", detail: detail, yesText: "Download", noText: "Later") { + self.downloadAllCapImages() + } + } + + private func askUserToDownload(classifier version: Int, size: Int64?) { + let detail = "Version \(version) of the classifier is available for download (You have version \(app.database.classifierVersion)). Would you like to download it now?" + let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" + presentUserBinaryChoice("New classifier", detail: detail + sizeText, yesText: "Download") { + self.downloadClassifier() + } + } + + 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.sync { + 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", 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) + alert.addAction(confirm) + alert.addAction(cancel) + self.present(alert, animated: true) + + } + + // MARK: Starting downloads + + private func downloadClassifier() { + let hud = JGProgressHUD(style: .dark) + hud.vibrancyEnabled = true + hud.indicatorView = JGProgressHUDPieIndicatorView() + hud.detailTextLabel.text = "0 % complete" + hud.textLabel.text = "Downloading classifier" + hud.show(in: self.view) + + app.database.downloadClassifier(progress: { progress, received, total in + DispatchQueue.main.async { + hud.progress = progress + let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) + let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) + hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % complete (\(r) / \(t))" + } + }) { success in + DispatchQueue.main.async { + hud.dismiss() + self.didDownloadClassifier(successfully: success) + } + } + } + + private func downloadServerDatabase() { + let hud = JGProgressHUD(style: .dark) + hud.vibrancyEnabled = true + hud.indicatorView = JGProgressHUDPieIndicatorView() + hud.detailTextLabel.text = "0 % complete" + hud.textLabel.text = "Downloading server database" + hud.show(in: self.view) + + app.database.downloadServerDatabase(progress: { progress, received, total in + DispatchQueue.main.async { + hud.progress = progress + let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) + let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) + hud.detailTextLabel.text = String(format: "%.0f", progress) + " % complete (\(r) / \(t))" + } + }, completion: { success in + guard success else { + self.log("Failed to download server database") + hud.detailTextLabel.text = "Download failed" + hud.dismiss(afterDelay: 2.0) + return + } + DispatchQueue.main.async { + hud.textLabel.text = "Processing data" + hud.progress = 0.2 + hud.detailTextLabel.text = "Please wait..." + } + app.needsDownload = false + }) { + + DispatchQueue.main.async { + hud.dismiss() + self.checkIfCapImagesNeedDownload() + } + } + } + + private func downloadAllCapImages() { + let hud = JGProgressHUD(style: .dark) + hud.vibrancyEnabled = true + hud.indicatorView = JGProgressHUDPieIndicatorView() + hud.detailTextLabel.text = "0 % complete" + hud.textLabel.text = "Downloading cap images" + hud.show(in: self.view) + + app.database.downloadMainCapImages { (done, total) in + let progress = Float(done) / Float(total) + let percent = Int((progress * 100).rounded()) + hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(total))" + hud.progress = progress + + if done >= total { + hud.dismiss(afterDelay: 1.0) + } + } + } + + // 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() + } + } + } + + // MARK: Finishing downloads + + private func didDownloadClassifier(successfully success: Bool) { + guard success else { + self.log("Failed to download classifier") + return + } + self.log("Classifier was downloaded.") + guard let image = accessory!.currentImage else { + 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 caps.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 @@ -305,10 +546,14 @@ extension TableView: SortControllerDelegate { self.sortType = sortType self.sortAscending = ascending if sortType != .match { - Cap.hasMatches = false + clearClassifierMatches() } showAllCapsAndScrollToTop() } + + var sortControllerShouldIncludeMatchOption: Bool { + matches != nil + } } // MARK: - CameraControllerDelegate @@ -316,18 +561,18 @@ extension TableView: SortControllerDelegate { extension TableView: CameraControllerDelegate { func didCapture(image: UIImage) { - if let cap = capToAddImageTo { - cap.add(image: image) { success in - guard success else { - self.error("Could not save image") - return - } - self.capToAddImageTo = nil - } - } else { - // Hand image to classifier, delegate is ListViewController - Classifier.shared.recognise(image: image) + 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() { @@ -337,66 +582,59 @@ extension TableView: CameraControllerDelegate { // MARK: - UITableViewDataSource -extension TableView: UITableViewDataSource { +extension TableView { - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "cap") as! CapCell let cap = shownCaps[indexPath.row] - cell.cap = cap + cell.set(cap: cap, match: match(for: cap.id)) return cell } - func numberOfSections(in tableView: UITableView) -> Int { + override func numberOfSections(in tableView: UITableView) -> Int { return 1 } - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return shownCaps.count } } // MARK: - UITableViewDelegate -extension TableView: UITableViewDelegate { +extension TableView { - private func takeImage(for cap: Cap) { + private func takeImage(for cap: Int) { self.capToAddImageTo = cap - self.performSegue(withIdentifier: "showCamera", sender: nil) + showCameraView() } - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + defer { + tableView.deselectRow(at: indexPath, animated: true) + } let cap = shownCaps[indexPath.row] - if let image = newImage.image { - cap.add(image: image) { success in - guard success else { - self.error("Could not save image") - return - } - self.giveFeedback(.medium) - self.updateCell(for: cap.id) - self.discardImage() - } - } else { + guard let image = accessory?.capImage.image else { self.giveFeedback(.medium) - takeImage(for: cap) + takeImage(for: cap.id) + return } - table.deselectRow(at: indexPath, animated: true) - } - - private func updateCell(for capId: Int) { - let cell = table.visibleCells.first { cell in - let item = cell as! CapCell - return item.cap.id == capId + guard app.database.add(image: image, for: cap.id) else { + self.giveFeedback(.heavy) + self.error("Could not save image") + return } - (cell as! CapCell).updateCell() + self.giveFeedback(.medium) + // Delegate call will update cell + self.accessory?.discardImage() } - + private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() } - func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let cap = shownCaps[indexPath.row] let rename = UIContextualAction(style: .normal, title: "Rename\ncap") { (_, _, success) in @@ -419,24 +657,34 @@ extension TableView: UITableViewDelegate { return UISwipeActionsConfiguration(actions: [rename, image]) } - func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { let cap = shownCaps[indexPath.row] let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in self.giveFeedback(.medium) - cap.updateCount { result in - self.updateCell(for: cap.id) - success(result) + success(true) + DispatchQueue.global(qos: .userInitiated).async { + app.database.download.imageCount(for: cap.id) { count in + guard let count = count else { + return + } + guard app.database.update(count: count, for: cap.id) else { + return + } + // Delegate call will update the cell + } } } count.backgroundColor = .orange let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in self.giveFeedback(.medium) - self.imageView(shouldBeVisible: false) - if let image = cap.image { - Classifier.shared.recognise(image: image, reportingImage: false) + self.accessory?.hideImageView() + guard let image = cap.image else { + success(false) + return } + self.classify(image: image) success(true) } similar.backgroundColor = .blue @@ -445,86 +693,104 @@ extension TableView: UITableViewDelegate { } } -// MARK: - ClassifierDelegate - -extension TableView: ClassifierDelegate { - - func classifier(finished image: UIImage?) { - if let img = image { - showImageView(with: img) - } - sortType = .match - sortAscending = false - Cap.hasMatches = true - showAllCapsAndScrollToTop() - } - - func classifier(error: String) { - self.showAlert(error) - } -} - -// MARK: - UISearchBarDelegate - -extension TableView: UISearchBarDelegate { - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - searchBar.text = nil - showAllCapsAndScrollToTop() - } - - func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - guard searchText != "" else { - showAllCapsAndScrollToTop() - return - } - DispatchQueue.global(qos: .userInteractive).async { - let cleaned = searchText.clean - let filteredCaps = self.caps.filter { cap in - let name = cap.cleanName - // For each part of text, check if name contains it - for textItem in cleaned.components(separatedBy: " ") { - if textItem != "" && !name.contains(textItem) { return false } - } - return true - } - DispatchQueue.main.async { - self.shownCaps = filteredCaps - self.table.reloadData() - self.tableViewScrollToTop() - } - } - } -} - // MARK: - Logging -extension TableView: Logger { +extension TableView: Logger { } - static let logToken = "[TableView]" -} +// MARK: - Protocol DatabaseDelegate -// MARK: - Protocol CapsDelegate - -extension TableView: CapsDelegate { +extension TableView: DatabaseDelegate { - func capHasUpdates(_ cap: Cap) { + func database(didChangeCap id: Int) { + updateNavigationItemTitleView() + guard let cap = app.database.cap(for: id) else { + return + } + if let index = caps.firstIndex(where: { $0.id == id }) { + caps[index] = cap + } + if let index = shownCaps.firstIndex(where: { $0.id == id }) { + shownCaps[index] = cap + } + let match = self.match(for: id) DispatchQueue.main.async { - if let cell = self.table.visibleCells.first(where: { ($0 as! CapCell).cap == cap }) { - (cell as! CapCell).updateCell() - } else if !self.caps.contains(cap) { - // Reload the table when new cap is added - self.showAllCapsAndScrollToTop() + if let cell = self.tableView.visibleCells.first(where: { ($0 as! CapCell).id == id }) { + (cell as! CapCell).set(cap: cap, match: match) } } } - func capsLoaded() { - showAllCapsByDescendingId() + 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) + } + + private func updateShownCaps(_ newList: [Cap], insertedId id: Int) { + 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 + } + + DispatchQueue.main.async { + self.shownCaps = newList + self.tableView.beginUpdates() + let indexPath = IndexPath(row: index, section: 0) + self.tableView.insertRows(at: [indexPath], with: .automatic) + self.tableView.endUpdates() + } + } + + func databaseRequiresFullRefresh() { + updateNavigationItemTitleView() + reloadCapsFromDatabase() + } +} + +// 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) { + saveNewCap(for: image) + } + + func capAccessoryCameraButtonPressed() { + showCameraView() } }