Lots of updates

- Add unlock
- Update Sorting menu
- Prepare to load multiple tile images
- New logging
- Calculate thumbnails and colors before schowing grid
This commit is contained in:
christophhagen 2020-06-18 22:55:51 +02:00
parent 7287607a60
commit 8892d04f62
22 changed files with 1484 additions and 930 deletions

View File

@ -14,7 +14,8 @@
591832CE21A2A97E00E5987D /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591832CD21A2A97E00E5987D /* Cap.swift */; }; 591832CE21A2A97E00E5987D /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591832CD21A2A97E00E5987D /* Cap.swift */; };
591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */ = {isa = PBXBuildFile; fileRef = 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */; }; 591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */ = {isa = PBXBuildFile; fileRef = 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */; };
591FDD20234E162000AA379E /* SearchAndDisplayAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */; }; 591FDD20234E162000AA379E /* SearchAndDisplayAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */; };
5970380D225737F800D21B55 /* LogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5970380C225737F800D21B55 /* LogViewController.swift */; }; CE0A501124752A9800A9E753 /* TileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A501024752A9800A9E753 /* TileImage.swift */; };
CE0A5013247D745200A9E753 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A5012247D745200A9E753 /* Colors.swift */; };
CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CECD209D81DE00932C01 /* AppDelegate.swift */; }; CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CECD209D81DE00932C01 /* AppDelegate.swift */; };
CE56CED3209D81DE00932C01 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED1209D81DE00932C01 /* Main.storyboard */; }; CE56CED3209D81DE00932C01 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED1209D81DE00932C01 /* Main.storyboard */; };
CE56CED5209D81E000932C01 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED4209D81E000932C01 /* Assets.xcassets */; }; CE56CED5209D81E000932C01 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED4209D81E000932C01 /* Assets.xcassets */; };
@ -37,7 +38,6 @@
CE56CF0F209D83B800932C01 /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF7209D83B700932C01 /* UIImage+Extensions.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 */; }; CE5B7CFC24562673002E5C06 /* Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFB24562673002E5C06 /* Download.swift */; };
CE5B7CFE245626D3002E5C06 /* Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFD245626D3002E5C06 /* Upload.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 */; }; CE5B7D032458C921002E5C06 /* Reachability in Frameworks */ = {isa = PBXBuildFile; productRef = CE5B7D022458C921002E5C06 /* Reachability */; };
CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */; }; 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 */; }; CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */; };
@ -56,7 +56,8 @@
591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = "<group>"; }; 591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = "<group>"; };
591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = "<group>"; }; 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = "<group>"; };
591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = "<group>"; }; 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = "<group>"; };
5970380C225737F800D21B55 /* LogViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViewController.swift; sourceTree = "<group>"; }; CE0A501024752A9800A9E753 /* TileImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TileImage.swift; sourceTree = "<group>"; };
CE0A5012247D745200A9E753 /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = "<group>"; };
CE56CECA209D81DD00932C01 /* CapCollector.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CapCollector.app; 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 = "<group>"; }; CE56CECD209D81DE00932C01 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
CE56CED2209D81DE00932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; }; CE56CED2209D81DE00932C01 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
@ -82,7 +83,6 @@
CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = "<group>"; }; CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = "<group>"; };
CE5B7CFB24562673002E5C06 /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = "<group>"; }; CE5B7CFB24562673002E5C06 /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = "<group>"; };
CE5B7CFD245626D3002E5C06 /* Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Upload.swift; sourceTree = "<group>"; }; CE5B7CFD245626D3002E5C06 /* Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Upload.swift; sourceTree = "<group>"; };
CE5B7CFF24574CCA002E5C06 /* ServerDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerDatabase.swift; sourceTree = "<group>"; };
CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = "<group>"; }; CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CGImagePropertyOrientation+Extensions.swift"; sourceTree = "<group>"; };
CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extensions.swift"; sourceTree = "<group>"; }; CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationItem+Extensions.swift"; sourceTree = "<group>"; };
CE85AA17246B012B002D1074 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = "<group>"; }; CE85AA17246B012B002D1074 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = "<group>"; };
@ -194,7 +194,6 @@
CE56CEEF209D83B500932C01 /* ImageCell.swift */, CE56CEEF209D83B500932C01 /* ImageCell.swift */,
5904C3392199C9FA0046A573 /* SortController.swift */, 5904C3392199C9FA0046A573 /* SortController.swift */,
59158B1521E37B0200D90CB0 /* GridViewController.swift */, 59158B1521E37B0200D90CB0 /* GridViewController.swift */,
5970380C225737F800D21B55 /* LogViewController.swift */,
); );
path = Presentation; path = Presentation;
sourceTree = "<group>"; sourceTree = "<group>";
@ -208,7 +207,8 @@
CE5B7CFB24562673002E5C06 /* Download.swift */, CE5B7CFB24562673002E5C06 /* Download.swift */,
CE5B7CFD245626D3002E5C06 /* Upload.swift */, CE5B7CFD245626D3002E5C06 /* Upload.swift */,
CEB269582445DB72004B74B3 /* Database.swift */, CEB269582445DB72004B74B3 /* Database.swift */,
CE5B7CFF24574CCA002E5C06 /* ServerDatabase.swift */, CE0A5012247D745200A9E753 /* Colors.swift */,
CE0A501024752A9800A9E753 /* TileImage.swift */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
@ -302,6 +302,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
CE56CF09209D83B800932C01 /* Classifier.swift in Sources */, CE56CF09209D83B800932C01 /* Classifier.swift in Sources */,
CE0A5013247D745200A9E753 /* Colors.swift in Sources */,
5904C33A2199C9FA0046A573 /* SortController.swift in Sources */, 5904C33A2199C9FA0046A573 /* SortController.swift in Sources */,
CE56CF0B209D83B800932C01 /* Logger.swift in Sources */, CE56CF0B209D83B800932C01 /* Logger.swift in Sources */,
CE56CF04209D83B800932C01 /* UIViewExtensions.swift in Sources */, CE56CF04209D83B800932C01 /* UIViewExtensions.swift in Sources */,
@ -319,9 +320,7 @@
CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */, CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */,
CE56CF0D209D83B800932C01 /* ImageSelector.swift in Sources */, CE56CF0D209D83B800932C01 /* ImageSelector.swift in Sources */,
CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */, CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */,
CE5B7D0024574CCA002E5C06 /* ServerDatabase.swift in Sources */,
CE56CF05209D83B800932C01 /* ViewControllerExtensions.swift in Sources */, CE56CF05209D83B800932C01 /* ViewControllerExtensions.swift in Sources */,
5970380D225737F800D21B55 /* LogViewController.swift in Sources */,
CE56CF0E209D83B800932C01 /* PhotoCaptureHandler.swift in Sources */, CE56CF0E209D83B800932C01 /* PhotoCaptureHandler.swift in Sources */,
CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */, CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */,
CE56CF07209D83B800932C01 /* ImageCell.swift in Sources */, CE56CF07209D83B800932C01 /* ImageCell.swift in Sources */,
@ -331,6 +330,7 @@
CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */, CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */,
CEB269592445DB72004B74B3 /* Database.swift in Sources */, CEB269592445DB72004B74B3 /* Database.swift in Sources */,
CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */, CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */,
CE0A501124752A9800A9E753 /* TileImage.swift in Sources */,
CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */, CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */,
CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */, CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */,
); );

View File

@ -15,15 +15,15 @@ import Reachability
#warning("ImageSelector: Show icons for failed downloads") #warning("ImageSelector: Show icons for failed downloads")
#warning("GridController: Allow sorting of caps by color") #warning("GridController: Allow sorting of caps by color")
#warning("GridController: Reorder caps by dragging") #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: Fix blur background of search bar after transition")
#warning("TableView: Add banner to jump down to unmatched caps / bottom") #warning("TableView: Add banner to jump down to unmatched caps / bottom")
#warning("Database: Calculate thumbnails and colors in the background")
var shouldLaunchCamera = false var shouldLaunchCamera = false
var app: AppDelegate! var app: AppDelegate!
private let unlockCode = 3849
@UIApplicationMain @UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { class AppDelegate: UIResponder, UIApplicationDelegate {
@ -46,12 +46,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
var reachability: Reachability! var reachability: Reachability!
var needsDownload = false
var dbUrl: URL { var dbUrl: URL {
documentsFolder.appendingPathComponent("db.sqlite3") documentsFolder.appendingPathComponent("db.sqlite3")
} }
/// Indicate if the user has write permissions.
private(set) var isUnlocked: Bool {
get {
UserDefaults.standard.bool(forKey: "unlocked")
}
set {
UserDefaults.standard.set(newValue, forKey: "unlocked")
}
}
let serverUrl = URL(string: "https://cc.ssl443.org")! let serverUrl = URL(string: "https://cc.ssl443.org")!
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@ -62,17 +70,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
//resetToFactoryState() //resetToFactoryState()
needsDownload = !FileManager.default.fileExists(atPath: dbUrl.path)
database = Database(url: dbUrl, server: serverUrl) 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")
}
return true return true
} }
@ -83,6 +81,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
UserDefaults.standard.removeObject(forKey: Classifier.userDefaultsKey) UserDefaults.standard.removeObject(forKey: Classifier.userDefaultsKey)
} }
func lock() {
isUnlocked = false
}
func checkUnlock(with pin: Int) -> Bool {
isUnlocked = pin == unlockCode
return isUnlocked
}
private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool { private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
log("Shortcut pressed") log("Shortcut pressed")
shouldLaunchCamera = true shouldLaunchCamera = true
@ -90,7 +97,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
func applicationDidBecomeActive(_ application: UIApplication) { func applicationDidBecomeActive(_ application: UIApplication) {
app.database?.uploadRemainingCaps()
app.database?.uploadRemainingImages() app.database?.uploadRemainingImages()
guard shouldLaunchCamera else { return } guard shouldLaunchCamera else { return }
shouldLaunchCamera = false shouldLaunchCamera = false
if let c = (frontmostViewController as? UINavigationController)?.topViewController as? TableView { if let c = (frontmostViewController as? UINavigationController)?.topViewController as? TableView {

View File

@ -2,7 +2,7 @@
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="qlf-I7-aOI"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="qlf-I7-aOI">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/> <capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
@ -127,7 +127,7 @@
<color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/> <color key="textColor" systemColor="secondaryLabelColor" red="0.23529411759999999" green="0.23529411759999999" blue="0.26274509800000001" alpha="0.59999999999999998" colorSpace="custom" customColorSpace="sRGB"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Some brand with a long name of more than 2 lines" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bRK-Je-2c8"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Some brand with a long name of more than one line" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bRK-Je-2c8">
<rect key="frame" x="106" y="32.5" width="261" height="54.5"/> <rect key="frame" x="106" y="32.5" width="261" height="54.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/> <fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
@ -316,7 +316,7 @@
<viewLayoutGuide key="safeArea" id="WAE-if-wuA"/> <viewLayoutGuide key="safeArea" id="WAE-if-wuA"/>
</view> </view>
<navigationItem key="navigationItem" id="bdd-GD-zBH"> <navigationItem key="navigationItem" id="bdd-GD-zBH">
<barButtonItem key="rightBarButtonItem" title="Average" id="0qm-Vt-S3s"> <barButtonItem key="rightBarButtonItem" title="Colors" id="0qm-Vt-S3s">
<connections> <connections>
<action selector="toggleAverageColor:" destination="wzG-WL-mtW" id="EhG-8E-FcQ"/> <action selector="toggleAverageColor:" destination="wzG-WL-mtW" id="EhG-8E-FcQ"/>
</connections> </connections>
@ -335,15 +335,13 @@
<scene sceneID="fFw-OX-Mag"> <scene sceneID="fFw-OX-Mag">
<objects> <objects>
<tableViewController storyboardIdentifier="SortController" id="xVJ-JZ-U8g" customClass="SortController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController"> <tableViewController storyboardIdentifier="SortController" id="xVJ-JZ-U8g" customClass="SortController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="static" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="LcF-G6-Sln"> <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="LcF-G6-Sln">
<rect key="frame" x="0.0" y="0.0" width="150" height="310"/> <rect key="frame" x="0.0" y="0.0" width="150" height="310"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" systemColor="secondarySystemBackgroundColor" red="0.94901960780000005" green="0.94901960780000005" blue="0.96862745100000003" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<sections> <prototypes>
<tableViewSection headerTitle="Sort order" id="XuT-wG-qHV"> <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" reuseIdentifier="SortCell" textLabel="2cU-Pz-MYZ" rowHeight="40" style="IBUITableViewCellStyleDefault" id="vYb-0s-NQp">
<cells> <rect key="frame" x="0.0" y="73.5" width="150" height="40"/>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="2cU-Pz-MYZ" rowHeight="40" style="IBUITableViewCellStyleDefault" id="vYb-0s-NQp">
<rect key="frame" x="0.0" y="55.5" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="vYb-0s-NQp" id="8bD-vd-HWF"> <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="vYb-0s-NQp" id="8bD-vd-HWF">
<rect key="frame" x="0.0" y="0.0" width="110" height="40"/> <rect key="frame" x="0.0" y="0.0" width="110" height="40"/>
@ -360,85 +358,8 @@
</tableViewCellContentView> </tableViewCellContentView>
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/> <color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
</tableViewCell> </tableViewCell>
</cells> </prototypes>
</tableViewSection> <sections/>
<tableViewSection headerTitle="Sort by" id="a86-13-jdq">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" textLabel="jBZ-Qh-cQp" rowHeight="40" style="IBUITableViewCellStyleDefault" id="RIw-UJ-LAh">
<rect key="frame" x="0.0" y="151.5" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="RIw-UJ-LAh" id="84U-pf-v8B">
<rect key="frame" x="0.0" y="0.0" width="110" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Id" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="jBZ-Qh-cQp">
<rect key="frame" x="16" y="0.0" width="86" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="Vbx-7v-vhu" rowHeight="40" style="IBUITableViewCellStyleDefault" id="V6N-Bw-TfC">
<rect key="frame" x="0.0" y="191.5" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="V6N-Bw-TfC" id="kjo-xX-12y">
<rect key="frame" x="0.0" y="0.0" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="Vbx-7v-vhu">
<rect key="frame" x="16" y="0.0" width="118" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="neG-u0-8YU" rowHeight="40" style="IBUITableViewCellStyleDefault" id="YR5-96-lup">
<rect key="frame" x="0.0" y="231.5" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="YR5-96-lup" id="Bae-Lt-m8T">
<rect key="frame" x="0.0" y="0.0" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Image count" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="neG-u0-8YU">
<rect key="frame" x="16" y="0.0" width="118" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="hUh-3J-pzj" rowHeight="40" style="IBUITableViewCellStyleDefault" id="3yB-nf-zbV">
<rect key="frame" x="0.0" y="271.5" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3yB-nf-zbV" id="xiR-G2-PuL">
<rect key="frame" x="0.0" y="0.0" width="150" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Match" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="hUh-3J-pzj">
<rect key="frame" x="16" y="0.0" width="118" height="40"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" type="system" pointSize="15"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</tableViewCellContentView>
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections> <connections>
<outlet property="dataSource" destination="xVJ-JZ-U8g" id="27V-wO-lvY"/> <outlet property="dataSource" destination="xVJ-JZ-U8g" id="27V-wO-lvY"/>
<outlet property="delegate" destination="xVJ-JZ-U8g" id="TdR-BV-8pz"/> <outlet property="delegate" destination="xVJ-JZ-U8g" id="TdR-BV-8pz"/>

View File

@ -14,7 +14,7 @@ import SQLite
struct Cap { struct Cap {
// MARK: - Static variables // MARK: - Static constants
static let sufficientImageCount = 10 static let sufficientImageCount = 10
@ -37,9 +37,6 @@ struct Cap {
/// The unique number of the cap /// The unique number of the cap
let id: Int let id: Int
/// The tile position of the cap
let tile: Int
/// The name of the cap /// The name of the cap
let name: String let name: String
@ -49,9 +46,6 @@ struct Cap {
/// The number of images existing for the cap /// The number of images existing for the cap
let count: Int let count: Int
/// The average color of the cap
let color: UIColor
/// Indicate if the cap can be found by the recognition model /// Indicate if the cap can be found by the recognition model
let matched: Bool let matched: Bool
@ -60,174 +54,117 @@ struct Cap {
// MARK: Init // MARK: Init
init(name: String, id: Int, color: UIColor) { init(name: String, id: Int) {
self.id = id self.id = id
self.count = 1 self.count = 1
self.name = name self.name = name
self.cleanName = "" self.cleanName = ""
self.tile = id
self.color = color
self.matched = false self.matched = false
self.uploaded = false self.uploaded = false
} }
// 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)
}
}()
static let rowId = Expression<Int>("id")
static let rowName = Expression<String>("name")
static let rowCount = Expression<Int>("count")
static let rowTile = Expression<Int>("tile")
static let rowRed = Expression<Int>("red")
static let rowGreen = Expression<Int>("green")
static let rowBlue = Expression<Int>("blue")
static let rowMatched = Expression<Bool>("matched")
static let rowUploaded = Expression<Bool>("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.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) { init(id: Int, name: String, count: Int) {
self.id = id self.id = id
self.name = name self.name = name
self.count = count self.count = count
self.tile = id - 1
self.cleanName = name.clean self.cleanName = name.clean
self.matched = false self.matched = false
self.color = UIColor.gray self.uploaded = true
self.uploaded = false
} }
func renamed(to name: String) -> Cap {
Cap(from: self, renamed: name)
}
init(from cap: Cap, renamed newName: String) {
self.id = cap.id
self.count = cap.count
self.name = newName
self.cleanName = newName.clean
self.matched = cap.matched
self.uploaded = cap.uploaded
}
// MARK: SQLite
init(row: Row) {
self.id = row[Cap.columnId]
self.name = row[Cap.columnName]
self.count = row[Cap.columnCount]
self.cleanName = name.clean
self.matched = row[Cap.columnMatched]
self.uploaded = row[Cap.columnUploaded]
}
static let table = Table("data")
static var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(columnId, primaryKey: true)
t.column(columnName)
t.column(columnCount)
t.column(columnMatched)
t.column(columnUploaded)
}
}
static let columnId = Expression<Int>("id")
static let columnName = Expression<String>("name")
static let columnCount = Expression<Int>("count")
static let columnMatched = Expression<Bool>("matched")
static let columnUploaded = Expression<Bool>("uploaded")
var insertQuery: Insert { var insertQuery: Insert {
let colors = color.rgb
return Cap.table.insert( return Cap.table.insert(
Cap.rowId <- id, Cap.columnId <- id,
Cap.rowName <- name, Cap.columnName <- name,
Cap.rowCount <- count, Cap.columnCount <- count,
Cap.rowTile <- tile, Cap.columnMatched <- matched,
Cap.rowRed <- colors.red, Cap.columnUploaded <- uploaded)
Cap.rowGreen <- colors.green,
Cap.rowBlue <- colors.blue,
Cap.rowMatched <- matched,
Cap.rowUploaded <- uploaded)
} }
// MARK: Text // MARK: Display
func matchDescription(match: Float?) -> String { func matchLabelText(match: Float?, appIsUnlocked: Bool) -> String {
guard let match = match else { if let match = match {
return hasSufficientImages ? "" : "⚠️"
}
let percent = Int((match * 100).rounded()) let percent = Int((match * 100).rounded())
return String(format: "%d %%", arguments: [percent]) return String(format: "%d %%", arguments: [percent])
} }
/// The cap id and the number of images guard matched else {
var subtitle: String { return "📵"
}
guard appIsUnlocked, !hasSufficientImages else {
return ""
}
return "⚠️"
}
func countLabelText(appIsUnlocked: Bool) -> String {
guard appIsUnlocked else {
return "\(id)"
}
guard count != 1 else { guard count != 1 else {
return "\(id) (1 image)" return "\(id) (1 image)"
} }
return "\(id) (\(count) images)" return "\(id) (\(count) images)"
} }
// MARK: - Images // MARK: Images
var hasSufficientImages: Bool { var hasSufficientImages: Bool {
count > Cap.sufficientImageCount count >= Cap.sufficientImageCount
}
var hasImage: Bool {
app.storage.hasImage(for: id)
}
/// The main image of the cap
var image: UIImage? {
app.storage.image(for: id)
}
/// The main image of the cap
var thumbnail: UIImage? {
app.storage.thumbnail(for: id)
} }
static func thumbnail(for image: UIImage) -> UIImage { static func thumbnail(for image: UIImage) -> UIImage {
let len = GridViewController.len * 2 let len = GridViewController.len * 2
return image.resize(to: CGSize.init(width: len, height: len)) return image.resize(to: CGSize.init(width: len, height: len))
} }
func updateLocalThumbnail() {
guard let img = image else {
return
}
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.
- 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, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
app.database.downloadImage(for: id, version: number, completion: completion)
}
} }
// MARK: - Protocol Hashable // MARK: - Protocol Hashable
@ -241,17 +178,6 @@ extension Cap: Hashable {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
}
// MARK: - Protocol CustomStringConvertible
extension Cap: CustomStringConvertible {
var description: String {
let rgb = color.rgb
return String(format: "%04d", id) + ";\(name);\(count);\(tile);\(rgb.red);\(rgb.green);\(rgb.blue)\n"
}
} }
// MARK: - Protocol Logger // MARK: - Protocol Logger
@ -261,6 +187,7 @@ extension Cap: Logger { }
// MARK: - String extension // MARK: - String extension
extension String { extension String {
var clean: String { var clean: String {
return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression) return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression)
} }

View File

@ -59,10 +59,6 @@ class Classifier: Logger {
let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence } let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence }
log("Classifed image with \(matches.count) classes") log("Classifed image with \(matches.count) classes")
DispatchQueue.global(qos: .background).async {
app.database.update(recognizedCaps: Set(matches.keys))
}
return matches return matches
} }
} }

View File

@ -0,0 +1,132 @@
//
// Colors.swift
// CapCollector
//
// Created by Christoph on 26.05.20.
// Copyright © 2020 CH. All rights reserved.
//
import UIKit
import SQLite
extension Database {
enum Colors {
static let table = Table("colors")
static let columnRed = Expression<Double>("red")
static let columnGreen = Expression<Double>("green")
static let columnBlue = Expression<Double>("blue")
static var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(Cap.columnId, primaryKey: true)
t.column(columnRed)
t.column(columnGreen)
t.column(columnBlue)
}
}
}
var colors: [Int : UIColor] {
do {
let rows = try db.prepare(Database.Colors.table)
return rows.reduce(into: [:]) { dict, row in
let id = row[Cap.columnId]
let r = CGFloat(row[Database.Colors.columnRed])
let g = CGFloat(row[Database.Colors.columnGreen])
let b = CGFloat(row[Database.Colors.columnBlue])
dict[id] = UIColor(red: r, green: g, blue: b, alpha: 1.0)
}
} catch {
log("Failed to load cap colors: \(error)")
return [:]
}
}
var capsWithColors: Set<Int> {
do {
let rows = try db.prepare(Database.Colors.table.select(Cap.columnId))
return Set(rows.map { $0[Cap.columnId]})
} catch {
log("Failed to load caps with colors: \(error)")
return []
}
}
var capsWithoutColors: Set<Int> {
Set(1...capCount).subtracting(capsWithColors)
}
func removeColor(for cap: Int) -> Bool {
do {
try db.run(Colors.table.filter(Cap.columnId == cap).delete())
return true
} catch {
log("Failed to delete cap color \(cap): \(error)")
return false
}
}
func set(color: UIColor, for cap: Int) -> Bool {
guard let _ = row(for: cap) else {
return insert(color: color, for: cap)
}
return update(color: color, for: cap)
}
private func insert(color: UIColor, for cap: Int) -> Bool {
let (red, green, blue) = color.rgb
let query = Database.Colors.table.insert(
Cap.columnId <- cap,
Database.Colors.columnRed <- red,
Database.Colors.columnGreen <- green,
Database.Colors.columnBlue <- blue)
do {
try db.run(query)
return true
} catch {
log("Failed to insert color for cap \(cap): \(error)")
return false
}
}
private func update(color: UIColor, for cap: Int) -> Bool {
let (red, green, blue) = color.rgb
let query = Database.Colors.table.filter(Cap.columnId == cap).update(
Database.Colors.columnRed <- red,
Database.Colors.columnGreen <- green,
Database.Colors.columnBlue <- blue)
do {
try db.run(query)
return true
} catch {
log("Failed to update color for cap \(cap): \(error)")
return false
}
}
private func row(for cap: Int) -> Row? {
do {
return try db.pluck(Database.Colors.table.filter(Cap.columnId == cap))
} catch {
log("Failed to get color for cap \(cap): \(error)")
return nil
}
}
func color(for cap: Int) -> UIColor? {
guard let row = self.row(for: cap) else {
return nil
}
let r = CGFloat(row[Database.Colors.columnRed])
let g = CGFloat(row[Database.Colors.columnGreen])
let b = CGFloat(row[Database.Colors.columnBlue])
return UIColor(red: r, green: g, blue: b, alpha: 1.0)
}
}

View File

@ -13,27 +13,13 @@ import SQLite
protocol DatabaseDelegate: class { protocol DatabaseDelegate: class {
func database(didChangeCap cap: Int)
func database(didAddCap cap: Cap) func database(didAddCap cap: Cap)
func databaseRequiresFullRefresh() func database(didChangeCap cap: Int)
}
struct Weak { func database(didLoadImageForCap cap: Int)
weak var value : DatabaseDelegate? func databaseNeedsFullRefresh()
init (_ value: DatabaseDelegate) {
self.value = value
}
}
extension Array where Element == Weak {
mutating func reap () {
self = self.filter { $0.value != nil }
}
} }
final class Database { final class Database {
@ -46,13 +32,7 @@ final class Database {
let download: Download let download: Download
private var listeners = [Weak]() weak var delegate: DatabaseDelegate?
// MARK: Listeners
func add(listener: DatabaseDelegate) {
listeners.append(Weak(listener))
}
init?(url: URL, server: URL) { init?(url: URL, server: URL) {
guard let db = try? Connection(url.path) else { guard let db = try? Connection(url.path) else {
@ -65,6 +45,8 @@ final class Database {
do { do {
try db.run(Cap.createQuery) try db.run(Cap.createQuery)
try db.run(upload.createQuery) try db.run(upload.createQuery)
try db.run(Database.Colors.createQuery)
try db.run(Database.TileImage.createQuery)
} catch { } catch {
return nil return nil
} }
@ -82,15 +64,20 @@ final class Database {
(try? db.prepare(Cap.table))?.map(Cap.init) ?? [] (try? db.prepare(Cap.table))?.map(Cap.init) ?? []
} }
/// A dictionary of all caps, indexed by their ids
var capDict: [Int : Cap] {
caps.reduce(into: [:]) { $0[$1.id] = $1 }
}
/// The ids of the caps which weren't included in the last classification /// The ids of the caps which weren't included in the last classification
var unmatchedCaps: [Int] { var unmatchedCaps: [Int] {
let query = Cap.table.select(Cap.rowId).filter(Cap.rowMatched == false) let query = Cap.table.select(Cap.columnId).filter(Cap.columnMatched == false)
return (try? db.prepare(query).map { $0[Cap.rowId] }) ?? [] return (try? db.prepare(query).map { $0[Cap.columnId] }) ?? []
} }
/// The number of caps which could be recognized during the last classification /// The number of caps which could be recognized during the last classification
var recognizedCapCount: Int { var recognizedCapCount: Int {
(try? db.scalar(Cap.table.filter(Cap.rowMatched == true).count)) ?? 0 (try? db.scalar(Cap.table.filter(Cap.columnMatched == true).count)) ?? 0
} }
/// The number of caps currently in the database /// The number of caps currently in the database
@ -100,32 +87,54 @@ final class Database {
/// The total number of images for all caps /// The total number of images for all caps
var imageCount: Int { var imageCount: Int {
(try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.rowCount] }) ?? 0 (try? db.prepare(Cap.table).reduce(0) { $0 + $1[Cap.columnCount] }) ?? 0
}
/// The caps without a downloaded image
var capsWithoutImages: [Cap] {
caps.filter({ !app.storage.hasImage(for: $0.id) })
} }
/// The number of caps without a downloaded image /// The number of caps without a downloaded image
var capsWithoutImages: Int { var capCountWithoutImages: Int {
caps.filter({ !$0.hasImage }).count capsWithoutImages.count
} }
/// The caps without a downloaded image
var capsWithoutThumbnails: [Cap] {
caps.filter({ !app.storage.hasThumbnail(for: $0.id) })
}
/// The number of caps without a downloaded image
var capCountWithoutThumbnails: Int {
capsWithoutThumbnails.count
}
var pendingUploads: [(cap: Int, version: Int)] { var pendingImageUploads: [(cap: Int, version: Int)] {
do { do {
return try db.prepare(upload.table).map { row in return try db.prepare(upload.table).map { row in
(cap: row[upload.rowCapId], version: row[upload.rowCapVersion]) (cap: row[upload.rowCapId], version: row[upload.rowCapVersion])
} }
} catch { } catch {
log("Failed to get pending uploads") log("Failed to get pending image uploads")
return [] return []
} }
} }
/// Indicate if there are any unfinished uploads /// Indicate if there are any unfinished uploads
var hasPendingUploads: Bool { var hasPendingImageUploads: Bool {
((try? db.scalar(upload.table.count)) ?? 0) > 0 ((try? db.scalar(upload.table.count)) ?? 0) > 0
} }
var pendingCapUploads: [Cap] {
do {
return try db.prepare(Cap.table.filter(Cap.columnUploaded == false)).map(Cap.init)
} catch {
log("Failed to get pending cap uploads")
return []
}
}
var classifierVersion: Int { var classifierVersion: Int {
set { set {
UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey) UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey)
@ -150,29 +159,27 @@ final class Database {
- returns: `true`, if the cap was created. - returns: `true`, if the cap was created.
*/ */
func createCap(image: UIImage, name: String) -> Bool { func createCap(image: UIImage, name: String) -> Bool {
guard let color = image.averageColor else { let cap = Cap(name: name, id: capCount + 1)
return false
}
let cap = Cap(name: name, id: capCount, color: color)
guard insert(cap: cap) else { guard insert(cap: cap) else {
log("Cap not inserted")
return false return false
} }
guard app.storage.save(image: image, for: cap.id) else { guard app.storage.save(image: image, for: cap.id) else {
log("Cap image not saved")
return false return false
} }
listeners.forEach { $0.value?.database(didAddCap: cap) }
upload.upload(name: name, for: cap.id) { success in upload.upload(name: name, for: cap.id) { success in
guard success else { guard success else {
return return
} }
self.update(uploaded: true, for: cap.id) self.update(uploaded: true, for: cap.id)
self.upload.uploadImage(for: cap.id, version: 0) { count in self.upload.uploadImage(for: cap.id, version: 0) { actualVersion in
guard let count = count else { guard let actualVersion = actualVersion else {
self.log("Failed to upload first image for cap \(cap.id)") self.log("Failed to upload first image for cap \(cap.id)")
return return
} }
self.log("Uploaded first image for cap \(cap.id)") self.log("Uploaded first image for cap \(cap.id)")
self.update(count: count, for: cap.id) self.update(count: actualVersion + 1, for: cap.id)
} }
} }
return true return true
@ -185,11 +192,13 @@ final class Database {
- note: When a new cap is created, use `createCap(image:name:)` instead - note: When a new cap is created, use `createCap(image:name:)` instead
*/ */
@discardableResult @discardableResult
private func insert(cap: Cap, notifyDelegate: Bool = true) -> Bool { private func insert(cap: Cap, notify: Bool = true) -> Bool {
do { do {
try db.run(cap.insertQuery) try db.run(cap.insertQuery)
if notifyDelegate { if notify {
listeners.forEach { $0.value?.database(didAddCap: cap) } DispatchQueue.main.async {
self.delegate?.database(didAddCap: cap)
}
} }
return true return true
} catch { } catch {
@ -211,14 +220,12 @@ final class Database {
log("Failed update count \(version) for cap \(cap)") log("Failed update count \(version) for cap \(cap)")
return false return false
} }
listeners.forEach { $0.value?.database(didChangeCap: cap) }
guard addPendingUpload(for: cap, version: version) else { guard addPendingUpload(for: cap, version: version) else {
log("Failed to add cap \(cap) version \(version) to upload queue") log("Failed to add cap \(cap) version \(version) to upload queue")
return false return false
} }
upload.uploadImage(for: cap, version: version) { count in upload.uploadImage(for: cap, version: version) { actualVersion in
guard let _ = count else { guard let actualVersion = actualVersion else {
self.log("Failed to upload image \(version) for cap \(cap)") self.log("Failed to upload image \(version) for cap \(cap)")
return return
} }
@ -226,18 +233,23 @@ final class Database {
self.log("Failed to remove version \(version) for cap \(cap) from upload queue") self.log("Failed to remove version \(version) for cap \(cap) from upload queue")
return return
} }
self.log("Uploaded version \(version) for cap \(cap)") self.log("Uploaded version \(actualVersion) for cap \(cap)")
self.update(count: actualVersion + 1, for: cap)
} }
return true return true
} }
// MARK: Updating cap properties // MARK: Updating cap properties
private func update(_ property: String, for cap: Int, setter: Setter...) -> Bool { private func update(_ property: String, for cap: Int, notify: Bool = true, setter: Setter...) -> Bool {
do { do {
let query = updateQuery(for: cap).update(setter) let query = updateQuery(for: cap).update(setter)
try db.run(query) try db.run(query)
listeners.forEach { $0.value?.database(didChangeCap: cap) } if notify {
DispatchQueue.main.async {
self.delegate?.database(didChangeCap: cap)
}
}
return true return true
} catch { } catch {
log("Failed to update \(property) for cap \(cap): \(error)") log("Failed to update \(property) for cap \(cap): \(error)")
@ -247,33 +259,36 @@ final class Database {
@discardableResult @discardableResult
private func update(uploaded: Bool, for cap: Int) -> Bool { private func update(uploaded: Bool, for cap: Int) -> Bool {
update("uploaded", for: cap, setter: Cap.rowUploaded <- uploaded) update("uploaded", for: cap, setter: Cap.columnUploaded <- uploaded)
} }
@discardableResult
private func update(count: Int, for cap: Int) -> Bool {
update("count", for: cap, setter: Cap.columnCount <- count)
}
@discardableResult
private func update(matched: Bool, for cap: Int) -> Bool {
update("matched", for: cap, setter: Cap.columnMatched <- matched)
}
// MARK: External editing
/**
Update the `name` of a cap.
*/
@discardableResult @discardableResult
func update(name: String, for cap: Int) -> Bool { func update(name: String, for cap: Int) -> Bool {
update("name", for: cap, setter: Cap.rowName <- name) guard update("name", for: cap, setter: Cap.columnName <- name, Cap.columnUploaded <- false) else {
return false
}
uploadRemainingCaps()
return true
} }
@discardableResult @discardableResult
func update(color: UIColor, for cap: Int) -> Bool { private func updateWithoutUpload(name: String, for cap: Int) -> Bool {
let (red, green, blue) = color.rgb update("name", for: cap, notify: false, setter: Cap.columnName <- name)
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<Int>) { func update(recognizedCaps: Set<Int>) {
@ -298,7 +313,9 @@ final class Database {
} }
} }
func addPendingUpload(for cap: Int, version: Int) -> Bool { // MARK: Uploads
private func addPendingUpload(for cap: Int, version: Int) -> Bool {
do { do {
try db.run(upload.insertQuery(for: cap, version: version)) try db.run(upload.insertQuery(for: cap, version: version))
return true return true
@ -308,7 +325,7 @@ final class Database {
} }
} }
func removePendingUpload(for cap: Int, version: Int) -> Bool { private func removePendingUpload(for cap: Int, version: Int) -> Bool {
do { do {
try db.run(upload.deleteQuery(for: cap, version: version)) try db.run(upload.deleteQuery(for: cap, version: version))
return true return true
@ -335,8 +352,8 @@ final class Database {
private func count(for cap: Int) -> Int? { private func count(for cap: Int) -> Int? {
do { do {
let row = try db.pluck(updateQuery(for: cap).select(Cap.rowCount)) let row = try db.pluck(updateQuery(for: cap).select(Cap.columnCount))
return row?[Cap.rowCount] return row?[Cap.columnCount]
} catch { } catch {
log("Failed to get count for cap \(cap)") log("Failed to get count for cap \(cap)")
return nil return nil
@ -345,7 +362,7 @@ final class Database {
func countOfCaps(withImageCountLessThan limit: Int) -> Int { func countOfCaps(withImageCountLessThan limit: Int) -> Int {
do { do {
return try db.scalar(Cap.table.filter(Cap.rowCount < limit).count) return try db.scalar(Cap.table.filter(Cap.columnCount < limit).count)
} catch { } catch {
log("Failed to get caps with less than \(limit) images") log("Failed to get caps with less than \(limit) images")
return 0 return 0
@ -358,7 +375,7 @@ final class Database {
var capsFound = 0 var capsFound = 0
repeat { repeat {
currentCount += 1 currentCount += 1
capsFound = try db.scalar(Cap.table.filter(Cap.rowCount == currentCount).count) capsFound = try db.scalar(Cap.table.filter(Cap.columnCount == currentCount).count)
} while capsFound == 0 } while capsFound == 0
return (currentCount, capsFound) return (currentCount, capsFound)
@ -368,58 +385,77 @@ final class Database {
} }
func updateQuery(for cap: Int) -> Table { func updateQuery(for cap: Int) -> Table {
Cap.table.filter(Cap.rowId == cap) Cap.table.filter(Cap.columnId == cap)
} }
// MARK: Downloads // MARK: Downloads
@discardableResult @discardableResult
func downloadMainImage(for cap: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { func downloadMainImage(for cap: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
return download.image(for: cap, version: 0) { image in return download.mainImage(for: cap) { success in
// Guaranteed to be on the main queue guard success else {
guard let image = image else { completion(false)
completion(nil)
return return
} }
DispatchQueue.main.async {
defer { self.delegate?.database(didLoadImageForCap: cap)
completion(image)
} }
if !app.storage.save(thumbnail: Cap.thumbnail(for: image), for: cap) { completion(true)
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 @discardableResult
func downloadImage(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { func downloadImage(for cap: Int, version: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
return download.image(for: cap, version: version, completion: completion) return download.image(for: cap, version: version, completion: completion)
} }
func getServerDatabaseSize(completion: @escaping (_ size: Int64?) -> Void) { func downloadCapNames(completion: @escaping (_ success: Bool) -> Void) {
download.databaseSize(completion: completion) log("Downloading cap names")
} download.names { names in
guard let names = names else {
func downloadServerDatabase(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void, processed: (() -> Void)? = nil) { DispatchQueue.main.async {
download.database(progress: progress) { tempUrl in
guard let url = tempUrl else {
self.log("Failed to download database")
completion(false) completion(false)
}
return return
} }
self.update(names: names)
DispatchQueue.main.async {
completion(true) completion(true)
self.processServerDatabase(at: url) }
processed?() }
}
private func update(names: [String]) {
let notify = capCount > 0
log("Downloaded cap names (initialDownload: \(!notify))")
let caps = self.capDict
let changed: [Int] = names.enumerated().compactMap { id, name in
let id = id + 1
guard let existingName = caps[id]?.name else {
// Insert cap
let cap = Cap(id: id, name: name, count: 0)
guard insert(cap: cap, notify: notify) else {
return nil
}
return id
}
guard existingName != name else {
// Name unchanged
return nil
}
guard updateWithoutUpload(name: name, for: id) else {
return nil
}
return id
}
if !notify {
log("Added \(changed.count) new caps after initial download")
delegate?.databaseNeedsFullRefresh()
} }
} }
func downloadMainCapImages(progress: @escaping (_ current: Int, _ total: Int) -> Void) { func downloadMainCapImages(progress: @escaping (_ current: Int, _ total: Int) -> Void) {
let caps = self.caps.filter { !$0.hasImage }.map { $0.id } let caps = capsWithoutImages.map { $0.id }
var downloaded = 0 var downloaded = 0
let total = caps.count let total = caps.count
@ -458,7 +494,6 @@ final class Database {
} }
self.log("Finished all image downloads") self.log("Finished all image downloads")
} }
} }
func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) { func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) {
@ -474,7 +509,7 @@ final class Database {
completion(nil, nil) completion(nil, nil)
return return
} }
self.log("Getting classifier size: Own version \(ownVersion), server version \(version)") self.log("Getting size of classifier \(version)")
self.download.classifierSize { size in self.download.classifierSize { size in
completion(version, size) completion(version, size)
} }
@ -513,37 +548,50 @@ final class Database {
} }
} }
func downloadImageCounts() { func downloadImageCounts(completion: @escaping (_ success: Bool) -> Void) {
guard !hasPendingUploads else {
log("Waiting to refresh server image counts (uploads pending)")
return
}
log("Refreshing server image counts") log("Refreshing server image counts")
app.database.download.imageCounts { counts in download.imageCounts { counts in
guard let counts = counts else { guard let counts = counts else {
self.log("Failed to download server image counts") self.log("Failed to download server image counts")
DispatchQueue.main.async {
completion(false)
}
return return
} }
self.didDownload(imageCounts: counts) let newCaps = self.didDownload(imageCounts: counts)
guard newCaps.count > 0 else {
DispatchQueue.main.async {
completion(true)
}
return
}
self.log("Found \(newCaps.count) new caps on the server.")
self.downloadInfo(for: newCaps) { success in
DispatchQueue.main.async {
completion(success)
}
}
} }
} }
private func didDownload(imageCounts newCounts: [(cap: Int, count: Int)]) { private func didDownload(imageCounts newCounts: [Int]) -> [Int : Int] {
let capsCounts = self.caps.reduce(into: [:]) { $0[$1.id] = $1.count } let capsCounts = capDict
if newCounts.count != capsCounts.count { if newCounts.count != capsCounts.count {
log("Downloaded \(newCounts.count) image counts, but \(app.database.capCount) caps stored locally") log("Downloaded \(newCounts.count) image counts, but \(capsCounts.count) caps stored locally")
return
} }
let changed = newCounts.compactMap { id, newCount -> Int? in var newCaps = [Int : Int]()
guard let oldCount = capsCounts[id] else { let changed = newCounts.enumerated().compactMap { id, newCount -> Int? in
let id = id + 1
guard let oldCount = capsCounts[id]?.count else {
log("Received count \(newCount) for unknown cap \(id)") log("Received count \(newCount) for unknown cap \(id)")
newCaps[id] = newCount
return nil return nil
} }
guard oldCount != newCount else { guard oldCount != newCount else {
return nil return nil
} }
app.database.update(count: newCount, for: id) self.update(count: newCount, for: id)
return id return id
} }
switch changed.count { switch changed.count {
@ -556,28 +604,72 @@ final class Database {
default: default:
log("Refreshed image counts for all caps (\(changed.count) changed)") log("Refreshed image counts for all caps (\(changed.count) changed)")
} }
return newCaps
} }
private func processServerDatabase(at url: URL) { private func downloadInfo(for newCaps: [Int : Int], completion: @escaping (_ success: Bool) -> Void) {
guard let db = ServerDatabase(downloadedTo: url) else { var success = true
log("Failed to open downloaded server database") let group = DispatchGroup()
for (id, count) in newCaps {
group.enter()
download.name(for: id) { name in
guard let name = name else {
self.log("Failed to get name for new cap \(id)")
success = false
group.leave()
return return
} }
for (id, count, name) in db.caps {
let cap = Cap(id: id, name: name, count: count) let cap = Cap(id: id, name: name, count: count)
insert(cap: cap, notifyDelegate: false) self.insert(cap: cap)
group.leave()
}
}
if group.wait(timeout: .now() + .seconds(30)) != .success {
self.log("Timed out waiting for images to be downloaded")
}
completion(success)
}
func downloadImageCount(for cap: Int) {
download.imageCount(for: cap) { count in
guard let count = count else {
return
}
self.update(count: count, for: cap)
}
}
func uploadRemainingCaps() {
let uploads = self.pendingCapUploads
guard uploads.count > 0 else {
log("No pending cap uploads")
return
}
log("\(uploads.count) cap uploads pending")
for cap in uploads {
upload.upload(name: cap.name, for: cap.id) { success in
guard success else {
self.log("Failed to upload cap \(cap.id)")
return
}
self.log("Uploaded cap \(cap.id)")
self.update(uploaded: true, for: cap.id)
}
} }
listeners.forEach { $0.value?.databaseRequiresFullRefresh() }
} }
func uploadRemainingImages() { func uploadRemainingImages() {
guard pendingUploads.count > 0 else { let uploads = pendingImageUploads
log("No pending uploads") guard uploads.count > 0 else {
log("No pending image uploads")
return return
} }
log("\(pendingUploads.count) image uploads pending") log("\(uploads.count) image uploads pending")
for (cap, version) in pendingUploads { for (cap, version) in uploads {
upload.uploadImage(for: cap, version: version) { count in upload.uploadImage(for: cap, version: version) { count in
guard let _ = count else { guard let _ = count else {
self.log("Failed to upload version \(version) of cap \(cap)") self.log("Failed to upload version \(version) of cap \(cap)")
@ -607,13 +699,19 @@ final class Database {
log("No need to switch main image with itself for cap \(cap)") log("No need to switch main image with itself for cap \(cap)")
return return
} }
upload.setMainImage(for: cap, to: version) { color in upload.setMainImage(for: cap, to: version) { success in
guard let color = color else { guard success else {
self.log("Could not make \(version) the main image for cap \(cap)") self.log("Could not make \(version) the main image for cap \(cap)")
return return
} }
self.update(color: color, for: cap) guard app.storage.switchMainImage(to: version, for: cap) else {
self.listeners.forEach { $0.value?.database(didChangeCap: cap) } self.log("Could not switch \(version) to main image for cap \(cap)")
return
}
DispatchQueue.main.async {
self.delegate?.database(didLoadImageForCap: cap)
}
} }
} }
} }

View File

@ -23,18 +23,30 @@ final class Download {
let delegate = Delegate() let delegate = Delegate()
self.serverUrl = server self.serverUrl = server
self.session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) self.session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
self.delegate = delegate self.delegate = delegate
} }
// MARK: Paths // MARK: Paths
private static func serverDatabaseUrl(server: URL) -> URL { var serverNameListUrl: URL {
server.appendingPathComponent("db.sqlite3") Download.serverNameListUrl(server: serverUrl)
} }
var serverDatabaseUrl: URL { private static func serverNameListUrl(server: URL) -> URL {
Download.serverDatabaseUrl(server: serverUrl) server.appendingPathComponent("names.txt")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
private var serverRecognitionModelUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverAllCountsUrl: URL {
serverUrl.appendingPathComponent("counts")
} }
var serverImageUrl: URL { var serverImageUrl: URL {
@ -45,22 +57,14 @@ final class Download {
serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version)) serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version))
} }
private func serverNameUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("name/\(cap)")
}
private func serverImageCountUrl(for cap: Int) -> URL { private func serverImageCountUrl(for cap: Int) -> URL {
serverUrl.appendingPathComponent("count/\(cap)") 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 // MARK: Delegate
final class Delegate: NSObject, URLSessionDownloadDelegate { final class Delegate: NSObject, URLSessionDownloadDelegate {
@ -116,7 +120,7 @@ final class Download {
- Returns: `true`, of the file download was started, `false`, if the image is already downloading. - Returns: `true`, of the file download was started, `false`, if the image is already downloading.
*/ */
@discardableResult @discardableResult
func mainImage(for cap: Int, completion: ((_ image: UIImage?) -> Void)?) -> Bool { func mainImage(for cap: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
let url = serverImageUrl(for: cap) let url = serverImageUrl(for: cap)
let query = "Main image of cap \(cap)" let query = "Main image of cap \(cap)"
guard !downloadingMainImages.contains(cap) else { guard !downloadingMainImages.contains(cap) else {
@ -125,28 +129,19 @@ final class Download {
downloadingMainImages.insert(cap) downloadingMainImages.insert(cap)
let task = session.downloadTask(with: url) { fileUrl, response, error in 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 { DispatchQueue.main.async {
completion?(nil) self.downloadingMainImages.remove(cap)
} }
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
completion(false)
return return
} }
guard app.storage.saveImage(at: fileUrl, for: cap) else { guard app.storage.saveImage(at: fileUrl, for: cap) else {
self.log("Request '\(query)' could not move downloaded file") self.log("Request '\(query)' could not move downloaded file")
DispatchQueue.main.async { completion(false)
completion?(nil)
}
return return
} }
DispatchQueue.main.async { completion(true)
guard let image = app.storage.image(for: cap) else {
self.log("Request '\(query)' received an invalid image")
completion?(nil)
return
}
completion?(image)
}
} }
task.resume() task.resume()
return true return true
@ -157,35 +152,23 @@ final class Download {
- Parameter cap: The id of the cap. - Parameter cap: The id of the cap.
- Parameter version: The image version to download. - Parameter version: The image version to download.
- Parameter completion: A closure with the resulting image - 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. - Returns: `true`, of the file download was started, `false`, if the image is already downloading.
*/ */
@discardableResult @discardableResult
func image(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool { func image(for cap: Int, version: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
let url = serverImageUrl(for: cap, version: version) let url = serverImageUrl(for: cap, version: version)
let query = "Image of cap \(cap) version \(version)" let query = "Image of cap \(cap) version \(version)"
let task = session.downloadTask(with: url) { fileUrl, response, error in let task = session.downloadTask(with: url) { fileUrl, response, error in
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else { guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
DispatchQueue.main.async { completion(false)
completion(nil)
}
return return
} }
guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else { guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else {
self.log("Request '\(query)' could not move downloaded file") self.log("Request '\(query)' could not move downloaded file")
DispatchQueue.main.async { completion(false)
completion(nil)
}
return return
} }
DispatchQueue.main.async { completion(true)
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() task.resume()
return true return true
@ -194,57 +177,40 @@ final class Download {
func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) { func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) {
let url = serverImageCountUrl(for: cap) let url = serverImageCountUrl(for: cap)
let query = "Image count for cap \(cap)" let query = "Image count for cap \(cap)"
let task = session.dataTask(with: url) { data, response, error in session.startTaskExpectingInt(with: url, query: query, completion: completion)
let int = self.convertIntResponse(to: query, data, response, error)
completion(int)
}
task.resume()
} }
func imageCounts(completion: @escaping ([(cap: Int, count: Int)]?) -> Void) { func name(for cap: Int, completion: @escaping (_ name: String?) -> Void) {
let url = serverAllCountsUrl let url = serverNameUrl(for: cap)
let query = "Name for cap \(cap)"
session.startTaskExpectingString(with: url, query: query, completion: completion)
}
func imageCounts(completion: @escaping ([Int]?) -> Void) {
let query = "Image count of all caps" let query = "Image count of all caps"
let task = session.dataTask(with: url) { data, response, error in session.startTaskExpectingData(with: serverAllCountsUrl, query: query) { data in
guard let string = self.convertStringResponse(to: query, data, response, error) else { guard let data = data else {
completion(nil) completion(nil)
return return
} }
completion(data.map(Int.init))
}
}
// Convert the encoded string into (id, count) pairs func names(completion: @escaping ([String]?) -> Void) {
let parts = string.components(separatedBy: ";") let query = "Download of server database"
let array: [(cap: Int, count: Int)] = parts.compactMap { s in session.startTaskExpectingString(with: serverNameListUrl, query: query) { string in
let p = s.components(separatedBy: "#") completion(string?.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n"))
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) { func databaseSize(completion: @escaping (_ size: Int64?) -> Void) {
size(of: "database size", to: serverDatabaseUrl, completion: completion) size(of: "database size", to: serverNameListUrl, 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) { func classifierVersion(completion: @escaping (Int?) -> Void) {
let query = "Server classifier version" let query = "Server classifier version"
let task = session.dataTask(with: serverClassifierVersionUrl) { data, response, error in session.startTaskExpectingInt(with: serverClassifierVersionUrl, query: query, completion: completion)
let int = self.convertIntResponse(to: query, data, response, error)
completion(int)
}
task.resume()
} }
func classifierSize(completion: @escaping (Int64?) -> Void) { func classifierSize(completion: @escaping (Int64?) -> Void) {
@ -326,3 +292,68 @@ final class Download {
} }
extension Download: Logger { } extension Download: Logger { }
extension URLSession {
func startTaskExpectingData(with url: URL, query: String, completion: @escaping (Data?) -> Void) {
let task = dataTask(with: url) { data, response, error in
if let error = error {
log("Request '\(query)' produced an error: \(error)")
completion(nil)
return
}
guard let response = response else {
log("Request '\(query)' received no response")
completion(nil)
return
}
guard let urlResponse = response as? HTTPURLResponse else {
log("Request '\(query)' received an invalid response: \(response)")
completion(nil)
return
}
guard urlResponse.statusCode == 200 else {
log("Request '\(query)' failed with status code \(urlResponse.statusCode)")
completion(nil)
return
}
guard let d = data else {
log("Request '\(query)' received no data")
completion(nil)
return
}
completion(d)
}
task.resume()
}
func startTaskExpectingString(with url: URL, query: String, completion: @escaping (String?) -> Void) {
startTaskExpectingData(with: url, query: query) { data in
guard let data = data else {
completion(nil)
return
}
guard let string = String(data: data, encoding: .utf8) else {
log("Request '\(query)' received invalid data (not a string)")
completion(nil)
return
}
completion(string)
}
}
func startTaskExpectingInt(with url: URL, query: String, completion: @escaping (Int?) -> Void) {
startTaskExpectingString(with: url, query: query) { string in
guard let string = string else {
completion(nil)
return
}
guard let int = Int(string.trimmingCharacters(in: .whitespacesAndNewlines)) else {
log("Request '\(query)' received an invalid value '\(string)'")
completion(nil)
return
}
completion(int)
}
}
}

View File

@ -1,47 +0,0 @@
//
// 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<Int>("id")
let rowName = Expression<String>("name")
let rowCount = Expression<Int>("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 { }

View File

@ -43,6 +43,10 @@ final class Storage {
baseUrl.appendingPathComponent("\(cap)-thumb.jpg") baseUrl.appendingPathComponent("\(cap)-thumb.jpg")
} }
private func tileImageUrl(for image: String) -> URL {
baseUrl.appendingPathComponent(image.clean + ".tile")
}
// MARK: Storage // MARK: Storage
/** /**
@ -160,6 +164,20 @@ final class Storage {
return true return true
} }
// MARK: High-level functions
func switchMainImage(to version: Int, for cap: Int) -> Bool {
guard deleteThumbnail(for: cap) else {
return false
}
let newImagePath = localImageUrl(for: cap, version: version)
guard fm.fileExists(atPath: newImagePath.path) else {
return deleteImage(for: cap, version: version)
}
let oldImagePath = localImageUrl(for: cap, version: 0)
return move(newImagePath, to: oldImagePath)
}
// MARK: Status // MARK: Status
/** /**
@ -171,6 +189,15 @@ final class Storage {
fm.fileExists(atPath: localImageUrl(for: cap, version: 0).path) fm.fileExists(atPath: localImageUrl(for: cap, version: 0).path)
} }
/**
Check if a thumbnail exists for a cap
- parameter cap: The id of the cap
- returns: True, if a thumbnail exists
*/
func hasThumbnail(for cap: Int) -> Bool {
fm.fileExists(atPath: thumbnailUrl(for: cap).path)
}
func existingImageUrl(for cap: Int, version: Int = 0) -> URL? { func existingImageUrl(for cap: Int, version: Int = 0) -> URL? {
let url = localImageUrl(for: cap, version: version) let url = localImageUrl(for: cap, version: version)
return fm.fileExists(atPath: url.path) ? url : nil return fm.fileExists(atPath: url.path) ? url : nil
@ -212,21 +239,6 @@ final class Storage {
return image 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. Get the thumbnail data for a cap.
If the image exists on disk, it is returned. If the image exists on disk, it is returned.
@ -268,15 +280,18 @@ final class Storage {
} }
} }
func averageColor(for cap: Int, version: Int = 0) -> UIColor? { func ciImage(for cap: Int) -> CIImage? {
guard let inputImage = ciImage(for: cap, version: version) else { let url = thumbnailUrl(for: cap)
guard fm.fileExists(atPath: url.path) else {
return nil
}
guard let image = CIImage(contentsOf: url) else {
error("Failed to read CIImage for main image of cap \(cap)") error("Failed to read CIImage for main image of cap \(cap)")
return nil return nil
} }
return inputImage.averageColor return image
} }
private func readData(from url: URL) -> Data? { private func readData(from url: URL) -> Data? {
guard fm.fileExists(atPath: url.path) else { guard fm.fileExists(atPath: url.path) else {
return nil return nil
@ -304,14 +319,25 @@ final class Storage {
@discardableResult @discardableResult
func deleteImage(for cap: Int, version: Int) -> Bool { func deleteImage(for cap: Int, version: Int) -> Bool {
guard let url = existingImageUrl(for: cap, version: version) else { let url = localImageUrl(for: cap, version: version)
return delete(at: url)
}
@discardableResult
func deleteThumbnail(for cap: Int) -> Bool {
let url = thumbnailUrl(for: cap)
return delete(at: url)
}
private func delete(at url: URL) -> Bool {
guard fm.fileExists(atPath: url.path) else {
return true return true
} }
do { do {
try fm.removeItem(at: url) try fm.removeItem(at: url)
return true return true
} catch { } catch {
log("Failed to delete image \(version) for cap \(cap): \(error)") log("Failed to delete file \(url.lastPathComponent): \(error)")
return false return false
} }
} }

View File

@ -0,0 +1,134 @@
//
// TileImage.swift
// CapCollector
//
// Created by Christoph on 20.05.20.
// Copyright © 2020 CH. All rights reserved.
//
import Foundation
import SQLite
extension Database {
struct TileImage {
let name: String
let width: Int
/// The tiles of each cap, with the index being the tile, and the value being the cap id.
let caps: [Int]
var encodedCaps: Data {
caps.map(UInt16.init).withUnsafeBytes { (p) in
Data(buffer: p.bindMemory(to: UInt8.self))
}
}
init(name: String, width: Int, caps: [Int]) {
self.name = name
self.width = width
self.caps = caps
}
init(row: Row) {
self.name = row[TileImage.columnName]
self.width = row[TileImage.columnWidth]
self.caps = row[TileImage.columnCaps].withUnsafeBytes { p in
p.bindMemory(to: UInt16.self).map(Int.init)
}
}
var insertQuery: Insert {
TileImage.table.insert(
TileImage.columnName <- name,
TileImage.columnWidth <- width,
TileImage.columnCaps <- encodedCaps)
}
var updateQuery: Update {
TileImage.table.update(
TileImage.columnWidth <- width,
TileImage.columnCaps <- encodedCaps)
}
static let columnName = Expression<String>("name")
static let columnWidth = Expression<Int>("width")
static let columnCaps = Expression<Data>("caps")
static let table = Table("images")
static func named(_ name: String) -> Table {
table.filter(columnName == name)
}
static func exists(_ name: String) -> Table {
named(name).select(columnName)
}
static var createQuery: String {
table.create(ifNotExists: true) { t in
t.column(Cap.columnId, primaryKey: true)
t.column(columnName)
t.column(columnWidth)
t.column(columnCaps)
}
}
}
func save(tileImage: TileImage) -> Bool {
guard exists(tileImage.name) else {
return insert(tileImage)
}
return update(tileImage)
}
var tileImages: [TileImage] {
(try? db.prepare(TileImage.table).map(TileImage.init)) ?? []
}
func tileImage(named name: String) -> TileImage? {
do {
guard let row = try db.pluck(TileImage.named(name)) else {
return nil
}
return TileImage(row: row)
} catch {
log("Failed to get tile image \(name): \(error)")
return nil
}
}
private func exists(_ tileImage: String) -> Bool {
do {
return try db.pluck(TileImage.exists(tileImage)) != nil
} catch {
log("Failed to check tile image \(tileImage): \(error)")
return false
}
}
private func insert(_ tileImage: TileImage) -> Bool {
do {
try db.run(tileImage.insertQuery)
return true
} catch {
log("Failed to insert tile image \(tileImage): \(error)")
return false
}
}
private func update(_ tileImage: TileImage) -> Bool {
do {
try db.run(tileImage.updateQuery)
return true
} catch {
log("Failed to update tile image \(tileImage): \(error)")
return false
}
}
}

View File

@ -90,6 +90,7 @@ struct Upload {
completion(false) completion(false)
return return
} }
completion(true)
} }
task.resume() task.resume()
} }
@ -134,44 +135,35 @@ struct Upload {
/** /**
Sets the main image for a cap to a different version. Sets the main image for a cap to a different version.
- Parameter cap: The id of the cap - Parameter cap: The id of the cap
- Parameter version: The version to set as the main version. - Parameter version: The version to set as the main version.
- Parameter completion: A callback with the new average color on completion. - Parameter completion: A callback with the result on completion.
*/ */
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ averageColor: UIColor?) -> Void) { func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ success: Bool) -> Void) {
guard let averageColor = app.storage.averageColor(for: cap, version: version) else {
completion(nil)
return
}
let url = serverChangeMainImageUrl(for: cap, to: version) let url = serverChangeMainImageUrl(for: cap, to: version)
var request = URLRequest(url: url) let task = URLSession.shared.dataTask(with: url) { data, response, error in
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 { if let error = error {
self.log("Failed to set main image of cap \(cap) to \(version): \(error)") self.log("Failed to set main image of cap \(cap) to \(version): \(error)")
completion(nil) completion(false)
return return
} }
guard let response = response else { guard let response = response else {
self.log("Failed to set main image of cap \(cap) to \(version): No response") self.log("Failed to set main image of cap \(cap) to \(version): No response")
completion(nil) completion(false)
return return
} }
guard let urlResponse = response as? HTTPURLResponse else { guard let urlResponse = response as? HTTPURLResponse else {
self.log("Failed to set main image of cap \(cap) to \(version): \(response)") self.log("Failed to set main image of cap \(cap) to \(version): \(response)")
completion(nil) completion(false)
return return
} }
guard urlResponse.statusCode == 200 else { guard urlResponse.statusCode == 200 else {
self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)") self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)")
completion(nil) completion(false)
return return
} }
completion(true)
completion(averageColor)
} }
task.resume() task.resume()
} }

View File

@ -10,13 +10,13 @@ import UIKit
extension UIColor { extension UIColor {
var rgb: (red: Int, green: Int, blue: Int) { var rgb: (red: Double, green: Double, blue: Double) {
var fRed: CGFloat = 0 var fRed: CGFloat = 0
var fGreen: CGFloat = 0 var fGreen: CGFloat = 0
var fBlue: CGFloat = 0 var fBlue: CGFloat = 0
var fAlpha: CGFloat = 0 var fAlpha: CGFloat = 0
getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha) getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha)
return (Int(fRed * 255.0), Int(fGreen * 255.0), Int(fBlue * 255.0)) return (Double(fRed), Double(fGreen), Double(fBlue))
} }
} }

View File

@ -89,6 +89,35 @@ extension UIImage {
extension CIImage { extension CIImage {
func averageColor(context: CIContext) -> UIColor? {
let extentVector = CIVector(
x: extent.origin.x,
y: extent.origin.y,
z: extent.size.width,
w: extent.size.height)
guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: self, kCIInputExtentKey: extentVector]) else {
log("Failed to create filter")
return nil
}
guard let outputImage = filter.outputImage else {
log("Failed get filter output")
return nil
}
var bitmap = [UInt8](repeating: 0, count: 4)
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4,
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
format: .RGBA8, colorSpace: nil)
return UIColor(
red: saturate(bitmap[0]),
green: saturate(bitmap[1]),
blue: saturate(bitmap[2]),
alpha: CGFloat(bitmap[3]) / 255)
}
var averageColor: UIColor? { var averageColor: UIColor? {
let extentVector = CIVector( let extentVector = CIVector(
x: extent.origin.x, x: extent.origin.x,
@ -106,8 +135,13 @@ extension CIImage {
} }
var bitmap = [UInt8](repeating: 0, count: 4) var bitmap = [UInt8](repeating: 0, count: 4)
let context = CIContext(options: [.workingColorSpace: kCFNull!]) guard let null = kCFNull else {
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil) return nil
}
let context = CIContext(options: [.workingColorSpace: null])
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4,
bounds: CGRect(x: 0, y: 0, width: 1, height: 1),
format: .RGBA8, colorSpace: nil)
let color = UIColor( let color = UIColor(
red: saturate(bitmap[0]), red: saturate(bitmap[0]),

View File

@ -12,6 +12,20 @@ protocol Logger {
} }
private let df: DateFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .short
return df
}()
func log(_ message: String, file: String = #file, line: Int = #line) {
let date = df.string(from: Date())
let m = "[\(date)][\(file.components(separatedBy: "/").last ?? file):\(line)] \(message)"
print(m)
Log.write(m + "\n")
}
extension Logger { extension Logger {
static var logToken: String { static var logToken: String {
@ -35,10 +49,28 @@ extension Logger {
} }
private static func addToFile(_ message: String) { private static func addToFile(_ message: String) {
logFile += "\n" + message Log.write("\n" + message)
print(message) print(message)
} }
} }
private(set) var logFile: String = "" enum Log {
static func set(logFile: String) throws {
let url = URL(fileURLWithPath: logFile)
if !FileManager.default.fileExists(atPath: logFile) {
try "Log started".write(to: url, atomically: false, encoding: .utf8)
}
file = FileHandle(forWritingAtPath: logFile)
}
static func write(_ message: String) {
guard let f = file else {
return
}
f.write(message.data(using: .utf8)!)
}
private static var file: FileHandle?
}

View File

@ -17,7 +17,7 @@ class CapCell: UITableViewCell {
@IBOutlet private weak var nameLabel: UILabel! @IBOutlet private weak var nameLabel: UILabel!
@IBOutlet weak var countLabel: UILabel! @IBOutlet private weak var countLabel: UILabel!
var id: Int = 0 var id: Int = 0
@ -25,24 +25,15 @@ class CapCell: UITableViewCell {
capImage.image = image ?? UIImage(named: "launch") capImage.image = image ?? UIImage(named: "launch")
} }
func set(cap: Cap, match: Float?) { func set(name: String) {
id = cap.id self.nameLabel.text = name
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 func set(matchLabel: String) {
self.matchLabel.text = matchLabel
matchLabel.text = cap.matchDescription(match: match)
nameLabel.text = cap.name
countLabel.text = cap.subtitle
} }
func set(countLabel: String) {
self.countLabel.text = countLabel
}
} }

View File

@ -10,10 +10,7 @@ import UIKit
class GridViewController: UIViewController { class GridViewController: UIViewController {
/// The number of caps horizontally. /// The number of horizontal pixels for each cap.
private let columns = 40
/// The number of hroizontal pixels for each cap.
static let len: CGFloat = 60 static let len: CGFloat = 60
private lazy var rowHeight = GridViewController.len * 0.866 private lazy var rowHeight = GridViewController.len * 0.866
@ -27,12 +24,20 @@ class GridViewController: UIViewController {
@IBOutlet weak var scrollView: UIScrollView! @IBOutlet weak var scrollView: UIScrollView!
/// A dictionary of the caps for the tiles /// A dictionary of the caps for the tiles
private var tiles = [Cap]() private var tiles = [Int]()
/// The name of the tile image
private var name: String = "default"
/// The number of caps horizontally.
private var columns = 40
/// A dictionary for the colors of the caps
private var colors = [Int : UIColor]()
/// The currently displaxed image views indexed by their tile ids
private var installedTiles = [Int : RoundedImageView]() private var installedTiles = [Int : RoundedImageView]()
private var changedTiles = Set<Int>()
private var selectedTile: Int? = nil private var selectedTile: Int? = nil
private weak var selectionView: RoundedButton! private weak var selectionView: RoundedButton!
@ -47,34 +52,51 @@ class GridViewController: UIViewController {
private var isShowingColors = false private var isShowingColors = false
private var capCount = 0
@IBAction func toggleAverageColor(_ sender: Any) { @IBAction func toggleAverageColor(_ sender: Any) {
isShowingColors = !isShowingColors isShowingColors = !isShowingColors
for (tile, view) in installedTiles { for (tile, view) in installedTiles {
if isShowingColors { if isShowingColors {
view.image = nil view.image = nil
view.backgroundColor = tileColor(tile: tile)
} else { } else {
if let image = tiles[tile].thumbnail { let id = tiles[tile]
if let image = app.storage.thumbnail(for: id) {
view.image = image view.image = image
continue continue
} }
tiles[tile].downloadMainImage() { image in self.downloadImage(cap: id, tile: tile)
view.image = image
} }
} }
} }
func load(tileImage: Database.TileImage) {
let totalCount = app.database.capCount
let firstNewId = tileImage.caps.count + 1
if totalCount >= firstNewId {
self.tiles = tileImage.caps + (firstNewId...totalCount)
} else {
self.tiles = tileImage.caps
}
self.columns = tileImage.width
self.name = tileImage.name
}
private func saveTileImage() {
let tileImage = Database.TileImage(name: name, width: columns, caps: tiles)
guard app.database.save(tileImage: tileImage) else {
log("Failed to save tile image")
return
}
log("Tile image saved")
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
app.database.add(listener: self) colors = app.database.colors
capCount = app.database.capCount
tiles = app.database.caps.sorted { $0.tile < $1.tile }
let width = CGFloat(columns) * GridViewController.len + GridViewController.len / 2 let width = CGFloat(columns) * GridViewController.len + GridViewController.len / 2
let height = (CGFloat(capCount) / CGFloat(columns)).rounded(.up) * rowHeight + margin let height = (CGFloat(tiles.count) / CGFloat(columns)).rounded(.up) * rowHeight + margin
canvasSize = CGSize(width: width, height: height) canvasSize = CGSize(width: width, height: height)
myView = UIView(frame: CGRect(origin: .zero, size: canvasSize)) myView = UIView(frame: CGRect(origin: .zero, size: canvasSize))
@ -103,20 +125,14 @@ class GridViewController: UIViewController {
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
saveChangedTiles() saveTileImage()
} }
// MARK: Tiles // MARK: Tiles
private func tileColor(tile: Int) -> UIColor { private func tileColor(tile: Int) -> UIColor? {
return tiles[tile].color let id = tiles[tile]
} return colors[id]
private func saveChangedTiles() {
for tile in changedTiles {
let cap = tiles[tile]
app.database.update(tile: tile, for: cap.id)
}
} }
/** /**
@ -126,8 +142,6 @@ class GridViewController: UIViewController {
let temp = tiles[rhs] let temp = tiles[rhs]
tiles[rhs] = tiles[lhs] tiles[rhs] = tiles[lhs]
tiles[lhs] = temp tiles[lhs] = temp
changedTiles.insert(lhs)
changedTiles.insert(rhs)
return true return true
} }
@ -193,22 +207,53 @@ class GridViewController: UIViewController {
private func makeTile(_ tile: Int) { private func makeTile(_ tile: Int) {
let view = RoundedImageView(frame: frame(for: tile)) let view = RoundedImageView(frame: frame(for: tile))
myView.addSubview(view) myView.addSubview(view)
view.backgroundColor = tileColor(tile: tile)
defer { defer {
installedTiles[tile] = view installedTiles[tile] = view
} }
// Only set image if images are shown // Only set image if images are shown
guard !isShowingColors else { guard !isShowingColors else {
view.backgroundColor = tileColor(tile: tile)
return return
} }
if let image = tiles[tile].thumbnail { if let image = app.storage.thumbnail(for: tiles[tile]) {
view.image = image view.image = image
return return
} }
tiles[tile].downloadMainImage() { image in downloadImage(tile: tile)
}
private func downloadImage(tile: Int) {
let id = tiles[tile]
downloadImage(cap: id, tile: tile)
}
private func downloadImage(cap id: Int, tile: Int) {
app.database.downloadMainImage(for: id) { success in
guard success else {
return
}
guard let view = self.installedTiles[tile] else {
self.log("No installed tile for downloaded image \(id)")
return
}
guard let image = app.storage.thumbnail(for: id) else {
self.log("Failed to load image for cap \(id) after successful download")
return
}
DispatchQueue.main.async {
guard self.isShowingColors else {
view.image = image view.image = image
return
}
guard let color = image.averageColor else {
self.log("Failed to get average color from image for cap \(id)")
return
}
view.backgroundColor = color
self.colors[id] = color
}
} }
} }
@ -251,7 +296,7 @@ class GridViewController: UIViewController {
} }
private func showTiles(in rect: CGRect) { private func showTiles(in rect: CGRect) {
for tile in 0..<capCount { for tile in 0..<tiles.count {
refresh(tile: tile, inVisibleRect: rect) refresh(tile: tile, inVisibleRect: rect)
} }
} }
@ -313,30 +358,3 @@ private extension Int {
} }
extension GridViewController: Logger { } extension GridViewController: Logger { }
extension GridViewController: DatabaseDelegate {
func database(didChangeCap id: Int) {
guard let view = installedTiles[id] else {
return
}
guard let cap = app.database.cap(for: id) else {
return
}
tiles[cap.tile] = cap
view.backgroundColor = cap.color
// Only set image if images are shown
if !isShowingColors {
view.image = cap.image
}
}
func database(didAddCap cap: Cap) {
tiles.append(cap)
refresh(tile: cap.tile, inVisibleRect: visibleRect)
}
func databaseRequiresFullRefresh() {
updateTiles()
}
}

View File

@ -96,28 +96,35 @@ class ImageSelector: UIViewController {
private func downloadImages() { private func downloadImages() {
images = [UIImage?](repeating: nil, count: cap.count) images = [UIImage?](repeating: nil, count: cap.count)
log("\(cap.count) images for cap \(cap.id)") log("\(cap.count) images for cap \(cap.id)")
if let image = cap.image { if let image = app.storage.image(for: cap.id) {
self.images[0] = image self.images[0] = image
self.collection.reloadItems(at: [IndexPath(row: 0, section: 0)]) self.collection.reloadItems(at: [IndexPath(row: 0, section: 0)])
} else { } else {
cap.downloadMainImage { image in app.database.downloadMainImage(for: cap.id) { success in
guard success, let image = app.storage.image(for: self.cap.id) else {
return
}
self.images[0] = image self.images[0] = image
DispatchQueue.main.async {
self.collection.reloadItems(at: [IndexPath(row: 0, section: 0)]) self.collection.reloadItems(at: [IndexPath(row: 0, section: 0)])
} }
} }
}
guard cap.count > 0 else { guard cap.count > 0 else {
return return
} }
for number in 1..<cap.count { for number in 1..<cap.count {
_ = cap.downloadImage(number) { image in app.database.downloadImage(for: cap.id, version: number) { success in
guard let image = image else { guard success, let image = app.storage.image(for: self.cap.id, version: number) else {
return return
} }
self.images[number] = image self.images[number] = image
DispatchQueue.main.async {
self.collection.reloadItems(at: [IndexPath(row: number, section: 0)]) self.collection.reloadItems(at: [IndexPath(row: number, section: 0)])
} }
} }
} }
}
// MARK: Select // MARK: Select

View File

@ -1,32 +0,0 @@
//
// LogViewController.swift
// CapCollector
//
// Created by Christoph on 05.04.19.
// Copyright © 2019 CH. All rights reserved.
//
import UIKit
class LogViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
textView.text = logFile
}
@IBOutlet weak var textView: UITextView!
/*
// 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.
}
*/
}

View File

@ -132,14 +132,22 @@ class SearchAndDisplayAccessory: PassthroughView {
// MARK: Cap image // MARK: Cap image
func showImageView(with image: UIImage) { func showImageView(with image: UIImage, isUnlocked: Bool) {
capImage.image = image capImage.image = image
saveButton.isHidden = !isUnlocked
saveButton.isEnabled = isUnlocked
let text = isUnlocked ? "Delete" : "Clear image"
deleteButton.setTitle(text, for: .normal)
showImageView() showImageView()
} }
func discardImage() { func discardImage() {
dismissAndClearSearchBar() DispatchQueue.main.async {
hideImageView() self.dismissAndClearSearchBar()
self.hideImageView()
}
delegate?.capAccessoryDidDiscardImage() delegate?.capAccessoryDidDiscardImage()
} }

View File

@ -13,68 +13,101 @@ enum SortCriteria: Int {
case name = 1 case name = 1
case count = 2 case count = 2
case match = 3 case match = 3
var text: String {
switch self {
case .id:
return "Id"
case .name:
return "Name"
case .count:
return "Count"
case .match:
return "Match"
}
}
} }
protocol SortControllerDelegate: class { protocol SortControllerDelegate: class {
var sortControllerShouldIncludeMatchOption: Bool { get }
func sortController(didSelect sortType: SortCriteria, ascending: Bool) func sortController(didSelect sortType: SortCriteria, ascending: Bool)
} }
class SortController: UITableViewController { class SortController: UITableViewController {
var selected: SortCriteria = .count @IBOutlet weak var thirdRowLabel: UILabel!
var ascending: Bool = true var selected: SortCriteria = .id
private var includeMatches: Bool { var ascending: Bool = false
delegate?.sortControllerShouldIncludeMatchOption ?? false
}
weak var delegate: SortControllerDelegate? weak var delegate: SortControllerDelegate?
var options = [SortCriteria]()
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
let height = includeMatches ? 298 : 258 preferredContentSize = CGSize(width: 200, height: 139 + options.count * 40)
preferredContentSize = CGSize(width: 200, height: height)
} }
private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
let generator = UIImpactFeedbackGenerator(style: style) UIImpactFeedbackGenerator(style: style).impactOccurred()
generator.impactOccurred() }
private func sortCriteria(for index: Int) -> SortCriteria {
index < options.count ? options[index] : .match
} }
// MARK: - Table view data source // MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 2
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
section == 0 ? "Sort order" : "Sort by"
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
section == 0 ? 1 : options.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "SortCell")!
guard indexPath.section != 0 else {
cell.accessoryType = ascending ? .checkmark : .none
cell.textLabel?.text = "Ascending"
return cell
}
let select = sortCriteria(for: indexPath.row)
cell.textLabel?.text = select.text
guard select == selected else {
cell.accessoryType = .none
return cell
}
cell.accessoryType = .checkmark
return cell
}
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
40
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
guard indexPath.section == 1 else { guard indexPath.section == 1 else {
ascending = !ascending ascending = !ascending
tableView.reloadData() tableView.reloadRows(at: [indexPath], with: .automatic)
delegate?.sortController(didSelect: selected, ascending: ascending) delegate?.sortController(didSelect: selected, ascending: ascending)
giveFeedback(.light) giveFeedback(.light)
return return
} }
giveFeedback(.medium) giveFeedback(.medium)
selected = SortCriteria(rawValue: indexPath.row)! selected = sortCriteria(for: indexPath.row)
tableView.reloadRows(at: [indexPath], with: .automatic)
delegate?.sortController(didSelect: selected, ascending: ascending) delegate?.sortController(didSelect: selected, ascending: ascending)
self.dismiss(animated: true) self.dismiss(animated: true)
} }
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
guard indexPath.row == 3 else { return indexPath }
return includeMatches ? indexPath : nil
}
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
}
}
} }

View File

@ -35,6 +35,10 @@ class TableView: UITableViewController {
/// This will be set to a cap id when adding a cap to it /// This will be set to a cap id when adding a cap to it
private var capToAddImageTo: Int? private var capToAddImageTo: Int?
private var isUnlocked = false
private var processingScreenHud: JGProgressHUD?
// MARK: Computed properties // MARK: Computed properties
private var titleText: String { private var titleText: String {
@ -54,7 +58,7 @@ class TableView: UITableViewController {
private var subtitleText: String { private var subtitleText: String {
let capCount = app.database.capCount let capCount = app.database.capCount
guard capCount > 0 else { guard capCount > 0, isUnlocked else {
return "" return ""
} }
let allImages = app.database.imageCount let allImages = app.database.imageCount
@ -76,18 +80,11 @@ class TableView: UITableViewController {
// MARK: - Actions // MARK: - Actions
@IBAction func updateInfo(_ sender: UIBarButtonItem) { @IBAction func updateInfo(_ sender: UIBarButtonItem) {
downloadNewestClassifierIfNeeded() downloadCapNames()
downloadImageCounts()
checkIfCapImagesNeedDownload()
} }
@IBAction func showMosaic(_ sender: UIBarButtonItem) { @IBAction func showMosaic(_ sender: UIBarButtonItem) {
let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController checkThumbnailsAndColorsBeforShowingGrid()
guard let nav = navigationController as? NavigationController else {
return
}
nav.pushViewController(vc, animated: true)
nav.allowLandscape = true
} }
func showCameraView() { func showCameraView() {
@ -104,6 +101,10 @@ class TableView: UITableViewController {
controller.ascending = sortAscending controller.ascending = sortAscending
controller.delegate = self controller.delegate = self
controller.options = [.id, .name]
if isUnlocked { controller.options.append(.count) }
if matches != nil { controller.options.append(.match) }
let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller) let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller)
presentationController.sourceView = navigationItem.titleView! presentationController.sourceView = navigationItem.titleView!
@ -116,26 +117,30 @@ class TableView: UITableViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
app.database.add(listener: self)
tableView.rowHeight = 100 tableView.rowHeight = 100
accessory = SearchAndDisplayAccessory(width: self.view.frame.width) accessory = SearchAndDisplayAccessory(width: self.view.frame.width)
accessory?.delegate = self accessory?.delegate = self
app.database.delegate = self
let count = app.database.capCount
if count == 0 {
log("No caps found, downloading names")
downloadCapNames()
showProcessingScreen()
} else {
log("Loaded \(count) caps")
reloadCapsFromDatabase() reloadCapsFromDatabase()
loadClassifier()
}
} }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
(navigationController as? NavigationController)?.allowLandscape = false (navigationController as? NavigationController)?.allowLandscape = false
} isUnlocked = app.isUnlocked
log(isUnlocked ? "App is unlocked" : "App is locked")
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
checkDatabaseIsDownloaded()
checkClassifierIsDownloaded()
} }
override func didMove(toParent parent: UIViewController?) { override func didMove(toParent parent: UIViewController?) {
@ -168,6 +173,9 @@ class TableView: UITableViewController {
let recognizer = UITapGestureRecognizer(target: self, action: #selector(titleWasTapped)) let recognizer = UITapGestureRecognizer(target: self, action: #selector(titleWasTapped))
stackView.isUserInteractionEnabled = true stackView.isUserInteractionEnabled = true
stackView.addGestureRecognizer(recognizer) stackView.addGestureRecognizer(recognizer)
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(attemptChangeOfUserPermissions))
stackView.addGestureRecognizer(longPress)
} }
private func updateNavigationItemTitleView() { private func updateNavigationItemTitleView() {
@ -179,8 +187,167 @@ class TableView: UITableViewController {
// MARK: Starting updates // MARK: Starting updates
private func checkThumbnailsAndColorsBeforShowingGrid() {
let missingImageCount = app.database.capCountWithoutImages
guard missingImageCount == 0 else {
askUserToDownload(capImages: missingImageCount)
return
}
createMissingThumbnailsBeforeShowingGrid()
}
private func createMissingThumbnailsBeforeShowingGrid() {
let missing = app.database.capsWithoutThumbnails.map { $0.id }
guard missing.count > 0 else {
log("No thumbnails missing, checking colors")
checkColorsBeforeShowingGrid()
return
}
log("Generating \(missing.count) thumbnails")
let hud = JGProgressHUD(style: traitCollection.userInterfaceStyle == .dark ? .dark : .light)
hud.indicatorView = JGProgressHUDPieIndicatorView()
hud.detailTextLabel.text = "0 % complete (0 / \(missing.count)"
hud.textLabel.text = "Generating thumbnails"
hud.show(in: self.view)
let group = DispatchGroup()
var done = 0
let split = 50
DispatchQueue.global(qos: .background).async {
for part in missing.split(intoPartsOf: split) {
for id in part {
group.enter()
defer {
done += 1
let ratio = Float(done) / Float(missing.count)
let percent = Int((ratio * 100).rounded())
DispatchQueue.main.async {
hud.progress = ratio
hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(missing.count))"
}
group.leave()
}
guard let image = app.storage.image(for: id) else {
return
}
let thumbnail = Cap.thumbnail(for: image)
app.storage.save(thumbnail: thumbnail, for: id)
}
if group.wait(timeout: .now() + .seconds(30)) != .success {
self.log("Timed out waiting for thumbnails to be generated")
}
}
DispatchQueue.main.async {
hud.dismiss()
self.checkColorsBeforeShowingGrid()
}
}
}
private func checkColorsBeforeShowingGrid() {
let missing = Array(app.database.capsWithoutColors)
guard missing.count > 0 else {
log("No missing colors, showing grid")
showGrid()
return
}
log("Generating \(missing.count) colors")
let hud = JGProgressHUD(style: traitCollection.userInterfaceStyle == .dark ? .dark : .light)
hud.indicatorView = JGProgressHUDPieIndicatorView()
hud.detailTextLabel.text = "0 % complete (0 / \(missing.count)"
hud.textLabel.text = "Generating colors"
hud.show(in: self.view)
let group = DispatchGroup()
var done = 0
let split = 50
let context = CIContext(options: [.workingColorSpace: kCFNull!])
DispatchQueue.global(qos: .background).async {
for part in missing.split(intoPartsOf: split) {
for id in part {
group.enter()
defer {
done += 1
let ratio = Float(done) / Float(missing.count)
let percent = Int((ratio * 100).rounded())
DispatchQueue.main.async {
hud.progress = ratio
hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(missing.count))"
}
group.leave()
}
guard let image = app.storage.ciImage(for: id) else {
return
}
guard let color = image.averageColor(context: context) else {
return
}
app.database.set(color: color, for: id)
}
if group.wait(timeout: .now() + .seconds(30)) != .success {
self.log("Timed out waiting for colors to be generated")
}
context.clearCaches()
}
DispatchQueue.main.async {
hud.dismiss()
self.showGrid()
}
}
}
private func showGrid() {
let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController
guard let nav = navigationController as? NavigationController else {
return
}
if let tileImage = app.database.tileImage(named: "default") {
log("Showing existing tile image")
vc.load(tileImage: tileImage)
} else {
let tileImage = Database.TileImage(name: "default", width: 40, caps: [])
log("Showing default tile image")
vc.load(tileImage: tileImage)
}
nav.pushViewController(vc, animated: true)
nav.allowLandscape = true
}
private func downloadCapNames() {
app.database.downloadCapNames { success in
guard success else {
self.hideProcessingScreen()
self.showAlert("Failed to download cap names", title: "Sync failed")
return
}
self.downloadImageCounts()
}
}
private func downloadImageCounts() { private func downloadImageCounts() {
app.database.downloadImageCounts() app.database.downloadImageCounts { success in
guard success else {
self.hideProcessingScreen()
self.showAlert("Failed to download image counts", title: "Sync failed")
return
}
self.hideProcessingScreen()
self.checkIfCapImagesNeedDownload()
}
}
private func checkIfCapImagesNeedDownload() {
let count = app.database.capCountWithoutImages
guard count > 0 else {
log("No cap images to download")
self.downloadNewestClassifierIfNeeded()
return
}
DispatchQueue.main.async {
self.askUserToDownload(capImages: count)
}
} }
private func downloadNewestClassifierIfNeeded() { private func downloadNewestClassifierIfNeeded() {
@ -198,31 +365,108 @@ class TableView: UITableViewController {
let detail = "Choose a new name for the cap" let detail = "Choose a new name for the cap"
askUserForText("Enter new name", detail: detail, existingText: cap.name, yesText: "Save") { text in askUserForText("Enter new name", detail: detail, existingText: cap.name, yesText: "Save") { text in
guard app.database.update(name: text, for: cap.id) else { guard app.database.update(name: text, for: cap.id) else {
self.showAlert("Name could not be set.", title: "Update failed")
return 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) { private func saveNewCap(for image: UIImage) {
let detail = "Choose a name for the image" let detail = "Choose a name for the image"
askUserForText("Enter name", detail: detail, existingText: accessory!.searchBar.text, yesText: "Save") { text in askUserForText("Enter name", detail: detail, existingText: accessory!.searchBar.text, yesText: "Save") { text in
DispatchQueue.global(qos: .userInitiated).async {
guard app.database.createCap(image: image, name: text) else { guard app.database.createCap(image: image, name: text) else {
self.showAlert("Cap not added") self.showAlert("Cap not added", title: "Database error")
return return
} }
self.accessory!.discardImage() self.accessory!.discardImage()
} }
} }
}
private func updateShownCaps(_ newList: [Cap], insertedId id: Int) {
// Main queue
guard shownCaps.count == newList.count - 1 else {
log("Cap list refresh mismatch: was \(shownCaps.count), is \(newList.count)")
show(sortedCaps: newList)
return
}
guard let index = newList.firstIndex(where: { $0.id == id}) else {
log("Cap list refresh without new cap \(id)")
show(sortedCaps: newList)
return
}
self.tableView.beginUpdates()
self.shownCaps = newList
let indexPath = IndexPath(row: index, section: 0)
self.tableView.insertRows(at: [indexPath], with: .automatic)
self.tableView.endUpdates()
}
// MARK: User interaction // MARK: User interaction
private func checkClassifierIsDownloaded() { private func showProcessingScreen() {
guard processingScreenHud == nil else {
log("Already showing processing screen")
return
}
let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .extraLight
let hud = JGProgressHUD(style: style)
hud.indicatorView = JGProgressHUDIndeterminateIndicatorView()
hud.detailTextLabel.text = "Please wait until the app has finished processing."
hud.textLabel.text = "Processing..."
hud.show(in: self.view)
self.processingScreenHud = hud
}
private func hideProcessingScreen() {
processingScreenHud?.dismiss()
processingScreenHud = nil
}
@objc private func attemptChangeOfUserPermissions() {
guard isUnlocked else {
attemptAppUnlock()
return
}
log("Locking app.")
app.lock()
isUnlocked = false
showAllCapsAndScrollToTop()
updateNavigationItemTitleView()
showAlert("The app was locked to prevent modifications.", title: "Locked")
}
private func attemptAppUnlock() {
log("Presenting unlock dialog to user")
askUserForText("Enter pin", detail: "Enter the correct pin to unlock write permissions for the app.", placeholder: "Pin", yesText: "Unlock") { text in
guard let pin = Int(text), app.checkUnlock(with: pin) else {
self.unlockFailed()
return
}
self.unlockDidSucceed()
}
}
private func unlockFailed() {
showAlert("The pin you entered is incorrect.", title: "Invalid pin")
}
private func unlockDidSucceed() {
showAlert("The app was successfully unlocked.", title: "Unlocked")
isUnlocked = true
showAllCapsAndScrollToTop()
updateNavigationItemTitleView()
}
private func loadClassifier() {
guard classifier == nil else {
return
}
guard let model = app.storage.recognitionModel else { guard let model = app.storage.recognitionModel else {
downloadNewestClassifierIfNeeded() downloadNewestClassifierIfNeeded()
return return
@ -230,54 +474,41 @@ class TableView: UITableViewController {
classifier = Classifier(model: model) 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) { 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)))" 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") { presentUserBinaryChoice("Download images", detail: detail, yesText: "Download", noText: "Later", dismissed: {
self.downloadNewestClassifierIfNeeded()
}) {
self.downloadAllCapImages() self.downloadAllCapImages()
} }
} }
private func askUserToDownload(classifier version: Int, size: Int64?) { 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 oldVersion = app.database.classifierVersion
let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : "" let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : ""
guard oldVersion > 0 else {
askUserToDownloadFirst(classifier: version, sizeText: sizeText)
return
}
askUserToDownloadNew(classifier: version, sizeText: sizeText, oldVersion: oldVersion)
}
private func askUserToDownloadNew(classifier version: Int, sizeText: String, oldVersion: Int) {
let detail = "Version \(version) of the classifier is available for download (You have version \(oldVersion)). Would you like to download it now?"
presentUserBinaryChoice("New classifier", detail: detail + sizeText, yesText: "Download") { presentUserBinaryChoice("New classifier", detail: detail + sizeText, yesText: "Download") {
self.downloadClassifier() 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) { private func askUserToDownloadFirst(classifier version: Int, sizeText: String) {
DispatchQueue.main.sync { let detail = "A classifier to match caps is available for download (version \(version). Would you like to download it now?"
presentUserBinaryChoice("Download 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.async {
let alertController = UIAlertController( let alertController = UIAlertController(
title: title, title: title,
message: detail, message: detail,
@ -304,13 +535,15 @@ class TableView: UITableViewController {
} }
} }
private func presentUserBinaryChoice(_ title: String, detail: String, yesText: String, noText: String = "Cancel", confirmed: @escaping () -> Void) { private func presentUserBinaryChoice(_ title: String, detail: String, yesText: String, noText: String = "Cancel", dismissed: (() -> Void)? = nil, confirmed: @escaping () -> Void) {
let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert) let alert = UIAlertController(title: title, message: detail, preferredStyle: .alert)
let confirm = UIAlertAction(title: yesText, style: .default) { _ in let confirm = UIAlertAction(title: yesText, style: .default) { _ in
confirmed() confirmed()
} }
let cancel = UIAlertAction(title: noText, style: .cancel) let cancel = UIAlertAction(title: noText, style: .cancel) { _ in
dismissed?()
}
alert.addAction(confirm) alert.addAction(confirm)
alert.addAction(cancel) alert.addAction(cancel)
self.present(alert, animated: true) self.present(alert, animated: true)
@ -320,11 +553,12 @@ class TableView: UITableViewController {
// MARK: Starting downloads // MARK: Starting downloads
private func downloadClassifier() { private func downloadClassifier() {
let hud = JGProgressHUD(style: .dark) let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light
hud.vibrancyEnabled = true let hud = JGProgressHUD(style: style)
//hud.vibrancyEnabled = true
hud.indicatorView = JGProgressHUDPieIndicatorView() hud.indicatorView = JGProgressHUDPieIndicatorView()
hud.detailTextLabel.text = "0 % complete" hud.detailTextLabel.text = "0 % complete"
hud.textLabel.text = "Downloading classifier" hud.textLabel.text = "Downloading image classifier"
hud.show(in: self.view) hud.show(in: self.view)
app.database.downloadClassifier(progress: { progress, received, total in app.database.downloadClassifier(progress: { progress, received, total in
@ -332,7 +566,7 @@ class TableView: UITableViewController {
hud.progress = progress hud.progress = progress
let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file)
hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % complete (\(r) / \(t))" hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % (\(r) / \(t))"
} }
}) { success in }) { success in
DispatchQueue.main.async { DispatchQueue.main.async {
@ -342,59 +576,24 @@ class TableView: UITableViewController {
} }
} }
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() { private func downloadAllCapImages() {
let hud = JGProgressHUD(style: .dark) let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light
hud.vibrancyEnabled = true let hud = JGProgressHUD(style: style)
//hud.vibrancyEnabled = true
hud.indicatorView = JGProgressHUDPieIndicatorView() hud.indicatorView = JGProgressHUDPieIndicatorView()
hud.detailTextLabel.text = "0 % complete" hud.detailTextLabel.text = "0 % complete"
hud.textLabel.text = "Downloading cap images" hud.textLabel.text = "Downloading cap images"
hud.show(in: self.view) hud.show(in: self.view)
app.database.downloadMainCapImages { (done, total) in app.database.downloadMainCapImages { done, total in
let progress = Float(done) / Float(total) let progress = Float(done) / Float(total)
let percent = Int((progress * 100).rounded()) let percent = Int((progress * 100).rounded())
hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(total))" hud.detailTextLabel.text = "\(percent) % (\(done) / \(total))"
hud.progress = progress hud.progress = progress
if done >= total { if done >= total {
hud.dismiss(afterDelay: 1.0) hud.dismiss(afterDelay: 1.0)
self.downloadNewestClassifierIfNeeded()
} }
} }
} }
@ -412,7 +611,6 @@ class TableView: UITableViewController {
private func classify(image: UIImage) { private func classify(image: UIImage) {
guard let classifier = self.classifier else { guard let classifier = self.classifier else {
return return
} }
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
@ -428,6 +626,28 @@ class TableView: UITableViewController {
self.sortType = .match self.sortType = .match
self.sortAscending = false self.sortAscending = false
self.showAllCapsAndScrollToTop() self.showAllCapsAndScrollToTop()
DispatchQueue.global(qos: .background).async {
app.database.update(recognizedCaps: Set(matches.keys))
}
}
}
}
private func classifyDummyImage() {
guard let classifier = self.classifier else {
return
}
DispatchQueue.global(qos: .userInitiated).async {
classifier.recognize(image: UIImage(named: "launch")!) { matches in
guard let matches = matches else {
self.log("Failed to classify dummy image")
self.matches = nil
return
}
self.log("Dummy classification finished")
DispatchQueue.global(qos: .background).async {
app.database.update(recognizedCaps: Set(matches.keys))
}
} }
} }
} }
@ -439,8 +659,10 @@ class TableView: UITableViewController {
self.log("Failed to download classifier") self.log("Failed to download classifier")
return return
} }
loadClassifier()
self.log("Classifier was downloaded.") self.log("Classifier was downloaded.")
guard let image = accessory!.currentImage else { guard let image = accessory!.currentImage else {
classifyDummyImage()
return return
} }
classify(image: image) classify(image: image)
@ -530,7 +752,7 @@ class TableView: UITableViewController {
Scroll the table view to the top Scroll the table view to the top
*/ */
private func tableViewScrollToTop() { private func tableViewScrollToTop() {
guard caps.count > 0 else { return } guard shownCaps.count > 0 else { return }
let path = IndexPath(row: 0, section: 0) let path = IndexPath(row: 0, section: 0)
DispatchQueue.main.async { DispatchQueue.main.async {
self.tableView.scrollToRow(at: path, at: .top, animated: true) self.tableView.scrollToRow(at: path, at: .top, animated: true)
@ -550,10 +772,6 @@ extension TableView: SortControllerDelegate {
} }
showAllCapsAndScrollToTop() showAllCapsAndScrollToTop()
} }
var sortControllerShouldIncludeMatchOption: Bool {
matches != nil
}
} }
// MARK: - CameraControllerDelegate // MARK: - CameraControllerDelegate
@ -562,7 +780,7 @@ extension TableView: CameraControllerDelegate {
func didCapture(image: UIImage) { func didCapture(image: UIImage) {
guard let cap = capToAddImageTo else { guard let cap = capToAddImageTo else {
accessory!.showImageView(with: image) accessory!.showImageView(with: image, isUnlocked: isUnlocked)
classify(image: image) classify(image: image)
return return
} }
@ -587,10 +805,30 @@ extension TableView {
override 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 cell = tableView.dequeueReusableCell(withIdentifier: "cap") as! CapCell
let cap = shownCaps[indexPath.row] let cap = shownCaps[indexPath.row]
cell.set(cap: cap, match: match(for: cap.id))
configure(cell: cell, for: cap)
return cell return cell
} }
private func configure(cell: CapCell, for cap: Cap) {
let matchText = cap.matchLabelText(match: match(for: cap.id), appIsUnlocked: self.isUnlocked)
let countText = cap.countLabelText(appIsUnlocked: self.isUnlocked)
cell.id = cap.id
cell.set(name: cap.name)
cell.set(matchLabel: matchText)
cell.set(countLabel: countText)
if let image = app.storage.image(for: cap.id) {
cell.set(image: image)
} else {
cell.set(image: nil)
app.database.downloadMainImage(for: cap.id) { _ in
// Delegate call will update image
}
}
}
override func numberOfSections(in tableView: UITableView) -> Int { override func numberOfSections(in tableView: UITableView) -> Int {
return 1 return 1
} }
@ -609,10 +847,18 @@ extension TableView {
showCameraView() showCameraView()
} }
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
// Prevent unauthorized users from selecting caps
isUnlocked ? indexPath : nil
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
defer { defer {
tableView.deselectRow(at: indexPath, animated: true) tableView.deselectRow(at: indexPath, animated: true)
} }
// Prevent unauthorized users from making changes
guard isUnlocked else {
return
}
let cap = shownCaps[indexPath.row] let cap = shownCaps[indexPath.row]
guard let image = accessory?.capImage.image else { guard let image = accessory?.capImage.image else {
self.giveFeedback(.medium) self.giveFeedback(.medium)
@ -630,11 +876,15 @@ extension TableView {
} }
private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
let generator = UIImpactFeedbackGenerator(style: style) UIImpactFeedbackGenerator(style: style).impactOccurred()
generator.impactOccurred()
} }
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
// Prevent unauthorized users from making changes
guard isUnlocked else {
return nil
}
let cap = shownCaps[indexPath.row] let cap = shownCaps[indexPath.row]
let rename = UIContextualAction(style: .normal, title: "Rename\ncap") { (_, _, success) in let rename = UIContextualAction(style: .normal, title: "Rename\ncap") { (_, _, success) in
@ -660,27 +910,24 @@ extension TableView {
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let cap = shownCaps[indexPath.row] let cap = shownCaps[indexPath.row]
var actions = [UIContextualAction]()
// Prevent unauthorized users from making changes
if isUnlocked {
let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in
self.giveFeedback(.medium) self.giveFeedback(.medium)
success(true) success(true)
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
app.database.download.imageCount(for: cap.id) { count in app.database.downloadImageCount(for: cap.id)
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 count.backgroundColor = .orange
actions.append(count)
}
let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in
self.giveFeedback(.medium) self.giveFeedback(.medium)
self.accessory?.hideImageView() self.accessory?.hideImageView()
guard let image = cap.image else { guard let image = app.storage.image(for: cap.id) else {
success(false) success(false)
return return
} }
@ -688,8 +935,9 @@ extension TableView {
success(true) success(true)
} }
similar.backgroundColor = .blue similar.backgroundColor = .blue
actions.append(similar)
return UISwipeActionsConfiguration(actions: [similar, count]) return UISwipeActionsConfiguration(actions: actions)
} }
} }
@ -701,25 +949,6 @@ extension TableView: Logger { }
extension TableView: DatabaseDelegate { extension TableView: DatabaseDelegate {
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.tableView.visibleCells.first(where: { ($0 as! CapCell).id == id }) {
(cell as! CapCell).set(cap: cap, match: match)
}
}
}
func database(didAddCap cap: Cap) { func database(didAddCap cap: Cap) {
caps.append(cap) caps.append(cap)
updateNavigationItemTitleView() updateNavigationItemTitleView()
@ -737,31 +966,46 @@ extension TableView: DatabaseDelegate {
updateShownCaps(newList, insertedId: cap.id) updateShownCaps(newList, insertedId: cap.id)
} }
private func updateShownCaps(_ newList: [Cap], insertedId id: Int) { func database(didChangeCap 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() updateNavigationItemTitleView()
guard let cap = app.database.cap(for: id) else {
log("Changed cap \(id) not found in database")
return
}
if let index = caps.firstIndex(where: { $0.id == id}) {
caps[index] = cap
} else {
log("Cap not found in full list")
}
if let index = shownCaps.firstIndex(where: { $0.id == id}) {
shownCaps[index] = cap
}
guard let cell = visibleCell(for: id) else {
return
}
configure(cell: cell, for: cap)
}
func database(didLoadImageForCap cap: Int) {
guard let cell = visibleCell(for: cap) else {
return
}
guard let image = app.storage.image(for: cap) else {
log("No image for cap \(cap), although it should be loaded")
return
}
cell.set(image: image)
}
func databaseNeedsFullRefresh() {
reloadCapsFromDatabase() reloadCapsFromDatabase()
} }
private func visibleCell(for cap: Int) -> CapCell? {
tableView.visibleCells
.map { $0 as! CapCell }
.first { $0.id == cap }
}
} }
// MARK: - Protocol CapSearchDelegate // MARK: - Protocol CapSearchDelegate