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:
parent
7287607a60
commit
8892d04f62
@ -14,7 +14,8 @@
|
||||
591832CE21A2A97E00E5987D /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591832CD21A2A97E00E5987D /* Cap.swift */; };
|
||||
591FDD1E234E151600AA379E /* SearchAndDisplayAccessory.xib in Resources */ = {isa = PBXBuildFile; fileRef = 591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */; };
|
||||
591FDD20234E162000AA379E /* SearchAndDisplayAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */; };
|
||||
5970380D225737F800D21B55 /* LogViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5970380C225737F800D21B55 /* LogViewController.swift */; };
|
||||
CE0A501124752A9800A9E753 /* TileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A501024752A9800A9E753 /* TileImage.swift */; };
|
||||
CE0A5013247D745200A9E753 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE0A5012247D745200A9E753 /* Colors.swift */; };
|
||||
CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CECD209D81DE00932C01 /* AppDelegate.swift */; };
|
||||
CE56CED3209D81DE00932C01 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED1209D81DE00932C01 /* Main.storyboard */; };
|
||||
CE56CED5209D81E000932C01 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE56CED4209D81E000932C01 /* Assets.xcassets */; };
|
||||
@ -37,7 +38,6 @@
|
||||
CE56CF0F209D83B800932C01 /* UIImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */; };
|
||||
CE5B7CFC24562673002E5C06 /* Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFB24562673002E5C06 /* Download.swift */; };
|
||||
CE5B7CFE245626D3002E5C06 /* Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFD245626D3002E5C06 /* Upload.swift */; };
|
||||
CE5B7D0024574CCA002E5C06 /* ServerDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5B7CFF24574CCA002E5C06 /* ServerDatabase.swift */; };
|
||||
CE5B7D032458C921002E5C06 /* Reachability in Frameworks */ = {isa = PBXBuildFile; productRef = CE5B7D022458C921002E5C06 /* Reachability */; };
|
||||
CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE6E4827246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift */; };
|
||||
CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE85AA15246A96C3002D1074 /* UINavigationItem+Extensions.swift */; };
|
||||
@ -56,7 +56,8 @@
|
||||
591832CD21A2A97E00E5987D /* Cap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cap.swift; sourceTree = "<group>"; };
|
||||
591FDD1D234E151600AA379E /* SearchAndDisplayAccessory.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchAndDisplayAccessory.xib; sourceTree = "<group>"; };
|
||||
591FDD1F234E162000AA379E /* SearchAndDisplayAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAndDisplayAccessory.swift; sourceTree = "<group>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
@ -82,7 +83,6 @@
|
||||
CE56CEF7209D83B700932C01 /* UIImage+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIImage+Extensions.swift"; sourceTree = "<group>"; };
|
||||
CE5B7CFB24562673002E5C06 /* Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Download.swift; sourceTree = "<group>"; };
|
||||
CE5B7CFD245626D3002E5C06 /* Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Upload.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -194,7 +194,6 @@
|
||||
CE56CEEF209D83B500932C01 /* ImageCell.swift */,
|
||||
5904C3392199C9FA0046A573 /* SortController.swift */,
|
||||
59158B1521E37B0200D90CB0 /* GridViewController.swift */,
|
||||
5970380C225737F800D21B55 /* LogViewController.swift */,
|
||||
);
|
||||
path = Presentation;
|
||||
sourceTree = "<group>";
|
||||
@ -208,7 +207,8 @@
|
||||
CE5B7CFB24562673002E5C06 /* Download.swift */,
|
||||
CE5B7CFD245626D3002E5C06 /* Upload.swift */,
|
||||
CEB269582445DB72004B74B3 /* Database.swift */,
|
||||
CE5B7CFF24574CCA002E5C06 /* ServerDatabase.swift */,
|
||||
CE0A5012247D745200A9E753 /* Colors.swift */,
|
||||
CE0A501024752A9800A9E753 /* TileImage.swift */,
|
||||
);
|
||||
path = Data;
|
||||
sourceTree = "<group>";
|
||||
@ -302,6 +302,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
CE56CF09209D83B800932C01 /* Classifier.swift in Sources */,
|
||||
CE0A5013247D745200A9E753 /* Colors.swift in Sources */,
|
||||
5904C33A2199C9FA0046A573 /* SortController.swift in Sources */,
|
||||
CE56CF0B209D83B800932C01 /* Logger.swift in Sources */,
|
||||
CE56CF04209D83B800932C01 /* UIViewExtensions.swift in Sources */,
|
||||
@ -319,9 +320,7 @@
|
||||
CE56CECE209D81DE00932C01 /* AppDelegate.swift in Sources */,
|
||||
CE56CF0D209D83B800932C01 /* ImageSelector.swift in Sources */,
|
||||
CE56CEFF209D83B800932C01 /* CameraController.swift in Sources */,
|
||||
CE5B7D0024574CCA002E5C06 /* ServerDatabase.swift in Sources */,
|
||||
CE56CF05209D83B800932C01 /* ViewControllerExtensions.swift in Sources */,
|
||||
5970380D225737F800D21B55 /* LogViewController.swift in Sources */,
|
||||
CE56CF0E209D83B800932C01 /* PhotoCaptureHandler.swift in Sources */,
|
||||
CE56CEFE209D83B800932C01 /* RoundedButton.swift in Sources */,
|
||||
CE56CF07209D83B800932C01 /* ImageCell.swift in Sources */,
|
||||
@ -331,6 +330,7 @@
|
||||
CE85AA16246A96C3002D1074 /* UINavigationItem+Extensions.swift in Sources */,
|
||||
CEB269592445DB72004B74B3 /* Database.swift in Sources */,
|
||||
CE56CF02209D83B800932C01 /* RoundedImageView.swift in Sources */,
|
||||
CE0A501124752A9800A9E753 /* TileImage.swift in Sources */,
|
||||
CE56CEF8209D83B800932C01 /* CapCell.swift in Sources */,
|
||||
CE6E4828246C304100570CB0 /* CGImagePropertyOrientation+Extensions.swift in Sources */,
|
||||
);
|
||||
|
@ -15,15 +15,15 @@ import Reachability
|
||||
#warning("ImageSelector: Show icons for failed downloads")
|
||||
#warning("GridController: Allow sorting of caps by color")
|
||||
#warning("GridController: Reorder caps by dragging")
|
||||
#warning("GridController: Load and save current mosaic")
|
||||
#warning("GridController: Add option to specify image width")
|
||||
#warning("TableView: Fix blur background of search bar after transition")
|
||||
#warning("TableView: Add banner to jump down to unmatched caps / bottom")
|
||||
|
||||
#warning("Database: Calculate thumbnails and colors in the background")
|
||||
var shouldLaunchCamera = false
|
||||
|
||||
var app: AppDelegate!
|
||||
|
||||
private let unlockCode = 3849
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
@ -45,13 +45,21 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var storage: Storage!
|
||||
|
||||
var reachability: Reachability!
|
||||
|
||||
var needsDownload = false
|
||||
|
||||
|
||||
var dbUrl: URL {
|
||||
documentsFolder.appendingPathComponent("db.sqlite3")
|
||||
}
|
||||
|
||||
/// Indicate if the user has write permissions.
|
||||
private(set) var isUnlocked: Bool {
|
||||
get {
|
||||
UserDefaults.standard.bool(forKey: "unlocked")
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: "unlocked")
|
||||
}
|
||||
}
|
||||
|
||||
let serverUrl = URL(string: "https://cc.ssl443.org")!
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
@ -62,17 +70,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
//resetToFactoryState()
|
||||
|
||||
needsDownload = !FileManager.default.fileExists(atPath: dbUrl.path)
|
||||
database = Database(url: dbUrl, server: serverUrl)
|
||||
|
||||
if needsDownload {
|
||||
log("New database created")
|
||||
} else {
|
||||
let size = (try! FileManager.default.attributesOfItem(atPath: dbUrl.path) as NSDictionary).fileSize()
|
||||
log("Loaded \(database.capCount) caps, database size: \(size) bytes")
|
||||
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@ -83,6 +81,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
UserDefaults.standard.removeObject(forKey: Classifier.userDefaultsKey)
|
||||
}
|
||||
|
||||
func lock() {
|
||||
isUnlocked = false
|
||||
}
|
||||
|
||||
func checkUnlock(with pin: Int) -> Bool {
|
||||
isUnlocked = pin == unlockCode
|
||||
return isUnlocked
|
||||
}
|
||||
|
||||
private func handleShortCutItem(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
|
||||
log("Shortcut pressed")
|
||||
shouldLaunchCamera = true
|
||||
@ -90,7 +97,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
app.database?.uploadRemainingCaps()
|
||||
app.database?.uploadRemainingImages()
|
||||
|
||||
guard shouldLaunchCamera else { return }
|
||||
shouldLaunchCamera = false
|
||||
if let c = (frontmostViewController as? UINavigationController)?.topViewController as? TableView {
|
||||
|
@ -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">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<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="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</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"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</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"/>
|
||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
|
||||
<nil key="highlightedColor"/>
|
||||
@ -316,7 +316,7 @@
|
||||
<viewLayoutGuide key="safeArea" id="WAE-if-wuA"/>
|
||||
</view>
|
||||
<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>
|
||||
<action selector="toggleAverageColor:" destination="wzG-WL-mtW" id="EhG-8E-FcQ"/>
|
||||
</connections>
|
||||
@ -335,110 +335,31 @@
|
||||
<scene sceneID="fFw-OX-Mag">
|
||||
<objects>
|
||||
<tableViewController storyboardIdentifier="SortController" id="xVJ-JZ-U8g" customClass="SortController" customModule="CapCollector" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" scrollEnabled="NO" dataMode="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"/>
|
||||
<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"/>
|
||||
<sections>
|
||||
<tableViewSection headerTitle="Sort order" id="XuT-wG-qHV">
|
||||
<cells>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<prototypes>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="checkmark" indentationWidth="10" reuseIdentifier="SortCell" textLabel="2cU-Pz-MYZ" rowHeight="40" style="IBUITableViewCellStyleDefault" id="vYb-0s-NQp">
|
||||
<rect key="frame" x="0.0" y="73.5" width="150" height="40"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="vYb-0s-NQp" id="8bD-vd-HWF">
|
||||
<rect key="frame" x="0.0" y="0.0" width="110" height="40"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Ascending" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="2cU-Pz-MYZ">
|
||||
<rect key="frame" x="16" y="0.0" width="86" height="40"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Ascending" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="2cU-Pz-MYZ">
|
||||
<rect key="frame" x="16" y="0.0" width="86" height="40"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</tableViewCellContentView>
|
||||
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
|
||||
</tableViewCell>
|
||||
</cells>
|
||||
</tableViewSection>
|
||||
<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>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<nil key="textColor"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
</tableViewCellContentView>
|
||||
<color key="backgroundColor" cocoaTouchSystemColor="tableCellGroupedBackgroundColor"/>
|
||||
</tableViewCell>
|
||||
</prototypes>
|
||||
<sections/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="xVJ-JZ-U8g" id="27V-wO-lvY"/>
|
||||
<outlet property="delegate" destination="xVJ-JZ-U8g" id="TdR-BV-8pz"/>
|
||||
|
@ -14,7 +14,7 @@ import SQLite
|
||||
|
||||
struct Cap {
|
||||
|
||||
// MARK: - Static variables
|
||||
// MARK: - Static constants
|
||||
|
||||
static let sufficientImageCount = 10
|
||||
|
||||
@ -37,9 +37,6 @@ struct Cap {
|
||||
/// The unique number of the cap
|
||||
let id: Int
|
||||
|
||||
/// The tile position of the cap
|
||||
let tile: Int
|
||||
|
||||
/// The name of the cap
|
||||
let name: String
|
||||
|
||||
@ -49,9 +46,6 @@ struct Cap {
|
||||
/// The number of images existing for the cap
|
||||
let count: Int
|
||||
|
||||
/// The average color of the cap
|
||||
let color: UIColor
|
||||
|
||||
/// Indicate if the cap can be found by the recognition model
|
||||
let matched: Bool
|
||||
|
||||
@ -60,174 +54,117 @@ struct Cap {
|
||||
|
||||
// MARK: Init
|
||||
|
||||
init(name: String, id: Int, color: UIColor) {
|
||||
init(name: String, id: Int) {
|
||||
self.id = id
|
||||
self.count = 1
|
||||
self.name = name
|
||||
self.cleanName = ""
|
||||
self.tile = id
|
||||
self.color = color
|
||||
self.matched = 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) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.count = count
|
||||
self.tile = id - 1
|
||||
self.cleanName = name.clean
|
||||
self.matched = false
|
||||
self.color = UIColor.gray
|
||||
self.uploaded = false
|
||||
self.uploaded = true
|
||||
}
|
||||
|
||||
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 {
|
||||
let colors = color.rgb
|
||||
return Cap.table.insert(
|
||||
Cap.rowId <- id,
|
||||
Cap.rowName <- name,
|
||||
Cap.rowCount <- count,
|
||||
Cap.rowTile <- tile,
|
||||
Cap.rowRed <- colors.red,
|
||||
Cap.rowGreen <- colors.green,
|
||||
Cap.rowBlue <- colors.blue,
|
||||
Cap.rowMatched <- matched,
|
||||
Cap.rowUploaded <- uploaded)
|
||||
Cap.columnId <- id,
|
||||
Cap.columnName <- name,
|
||||
Cap.columnCount <- count,
|
||||
Cap.columnMatched <- matched,
|
||||
Cap.columnUploaded <- uploaded)
|
||||
}
|
||||
|
||||
// MARK: Text
|
||||
// MARK: Display
|
||||
|
||||
func matchDescription(match: Float?) -> String {
|
||||
guard let match = match else {
|
||||
return hasSufficientImages ? "" : "⚠️"
|
||||
func matchLabelText(match: Float?, appIsUnlocked: Bool) -> String {
|
||||
if let match = match {
|
||||
let percent = Int((match * 100).rounded())
|
||||
return String(format: "%d %%", arguments: [percent])
|
||||
}
|
||||
let percent = Int((match * 100).rounded())
|
||||
return String(format: "%d %%", arguments: [percent])
|
||||
|
||||
guard matched else {
|
||||
return "📵"
|
||||
}
|
||||
guard appIsUnlocked, !hasSufficientImages else {
|
||||
return ""
|
||||
}
|
||||
return "⚠️"
|
||||
}
|
||||
|
||||
/// The cap id and the number of images
|
||||
var subtitle: String {
|
||||
func countLabelText(appIsUnlocked: Bool) -> String {
|
||||
guard appIsUnlocked else {
|
||||
return "\(id)"
|
||||
}
|
||||
guard count != 1 else {
|
||||
return "\(id) (1 image)"
|
||||
}
|
||||
return "\(id) (\(count) images)"
|
||||
}
|
||||
|
||||
// MARK: - Images
|
||||
// MARK: Images
|
||||
|
||||
var hasSufficientImages: Bool {
|
||||
count > Cap.sufficientImageCount
|
||||
}
|
||||
|
||||
var hasImage: Bool {
|
||||
app.storage.hasImage(for: id)
|
||||
}
|
||||
|
||||
/// The main image of the cap
|
||||
var image: UIImage? {
|
||||
app.storage.image(for: id)
|
||||
}
|
||||
|
||||
/// The main image of the cap
|
||||
var thumbnail: UIImage? {
|
||||
app.storage.thumbnail(for: id)
|
||||
count >= Cap.sufficientImageCount
|
||||
}
|
||||
|
||||
static func thumbnail(for image: UIImage) -> UIImage {
|
||||
let len = GridViewController.len * 2
|
||||
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
|
||||
@ -241,17 +178,6 @@ extension Cap: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
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
|
||||
@ -261,6 +187,7 @@ extension Cap: Logger { }
|
||||
// MARK: - String extension
|
||||
|
||||
extension String {
|
||||
|
||||
var clean: String {
|
||||
return lowercased().replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression)
|
||||
}
|
||||
|
@ -59,10 +59,6 @@ class Classifier: Logger {
|
||||
let matches = result.reduce(into: [:]) { $0[Int($1.identifier)!] = $1.confidence }
|
||||
|
||||
log("Classifed image with \(matches.count) classes")
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
app.database.update(recognizedCaps: Set(matches.keys))
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
132
CapCollector/Data/Colors.swift
Normal file
132
CapCollector/Data/Colors.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -13,27 +13,13 @@ import SQLite
|
||||
|
||||
protocol DatabaseDelegate: class {
|
||||
|
||||
func database(didChangeCap cap: Int)
|
||||
|
||||
func database(didAddCap cap: Cap)
|
||||
|
||||
func databaseRequiresFullRefresh()
|
||||
}
|
||||
|
||||
struct Weak {
|
||||
func database(didChangeCap cap: Int)
|
||||
|
||||
weak var value : DatabaseDelegate?
|
||||
func database(didLoadImageForCap cap: Int)
|
||||
|
||||
init (_ value: DatabaseDelegate) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == Weak {
|
||||
|
||||
mutating func reap () {
|
||||
self = self.filter { $0.value != nil }
|
||||
}
|
||||
func databaseNeedsFullRefresh()
|
||||
}
|
||||
|
||||
final class Database {
|
||||
@ -46,14 +32,8 @@ final class Database {
|
||||
|
||||
let download: Download
|
||||
|
||||
private var listeners = [Weak]()
|
||||
|
||||
// MARK: Listeners
|
||||
|
||||
func add(listener: DatabaseDelegate) {
|
||||
listeners.append(Weak(listener))
|
||||
}
|
||||
|
||||
weak var delegate: DatabaseDelegate?
|
||||
|
||||
init?(url: URL, server: URL) {
|
||||
guard let db = try? Connection(url.path) else {
|
||||
return nil
|
||||
@ -65,6 +45,8 @@ final class Database {
|
||||
do {
|
||||
try db.run(Cap.createQuery)
|
||||
try db.run(upload.createQuery)
|
||||
try db.run(Database.Colors.createQuery)
|
||||
try db.run(Database.TileImage.createQuery)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@ -82,15 +64,20 @@ final class Database {
|
||||
(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
|
||||
var unmatchedCaps: [Int] {
|
||||
let query = Cap.table.select(Cap.rowId).filter(Cap.rowMatched == false)
|
||||
return (try? db.prepare(query).map { $0[Cap.rowId] }) ?? []
|
||||
let query = Cap.table.select(Cap.columnId).filter(Cap.columnMatched == false)
|
||||
return (try? db.prepare(query).map { $0[Cap.columnId] }) ?? []
|
||||
}
|
||||
|
||||
/// The number of caps which could be recognized during the last classification
|
||||
var recognizedCapCount: Int {
|
||||
(try? db.scalar(Cap.table.filter(Cap.rowMatched == true).count)) ?? 0
|
||||
(try? db.scalar(Cap.table.filter(Cap.columnMatched == true).count)) ?? 0
|
||||
}
|
||||
|
||||
/// The number of caps currently in the database
|
||||
@ -100,32 +87,54 @@ final class Database {
|
||||
|
||||
/// The total number of images for all caps
|
||||
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
|
||||
var capsWithoutImages: Int {
|
||||
caps.filter({ !$0.hasImage }).count
|
||||
var capCountWithoutImages: Int {
|
||||
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 {
|
||||
return try db.prepare(upload.table).map { row in
|
||||
(cap: row[upload.rowCapId], version: row[upload.rowCapVersion])
|
||||
}
|
||||
} catch {
|
||||
log("Failed to get pending uploads")
|
||||
log("Failed to get pending image uploads")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicate if there are any unfinished uploads
|
||||
var hasPendingUploads: Bool {
|
||||
var hasPendingImageUploads: Bool {
|
||||
((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 {
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Classifier.userDefaultsKey)
|
||||
@ -150,29 +159,27 @@ final class Database {
|
||||
- returns: `true`, if the cap was created.
|
||||
*/
|
||||
func createCap(image: UIImage, name: String) -> Bool {
|
||||
guard let color = image.averageColor else {
|
||||
return false
|
||||
}
|
||||
let cap = Cap(name: name, id: capCount, color: color)
|
||||
let cap = Cap(name: name, id: capCount + 1)
|
||||
guard insert(cap: cap) else {
|
||||
log("Cap not inserted")
|
||||
return false
|
||||
}
|
||||
guard app.storage.save(image: image, for: cap.id) else {
|
||||
log("Cap image not saved")
|
||||
return false
|
||||
}
|
||||
listeners.forEach { $0.value?.database(didAddCap: cap) }
|
||||
upload.upload(name: name, for: cap.id) { success in
|
||||
guard success else {
|
||||
return
|
||||
}
|
||||
self.update(uploaded: true, for: cap.id)
|
||||
self.upload.uploadImage(for: cap.id, version: 0) { count in
|
||||
guard let count = count else {
|
||||
self.upload.uploadImage(for: cap.id, version: 0) { actualVersion in
|
||||
guard let actualVersion = actualVersion else {
|
||||
self.log("Failed to upload first image for cap \(cap.id)")
|
||||
return
|
||||
}
|
||||
self.log("Uploaded first image for cap \(cap.id)")
|
||||
self.update(count: count, for: cap.id)
|
||||
self.update(count: actualVersion + 1, for: cap.id)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@ -185,11 +192,13 @@ final class Database {
|
||||
- note: When a new cap is created, use `createCap(image:name:)` instead
|
||||
*/
|
||||
@discardableResult
|
||||
private func insert(cap: Cap, notifyDelegate: Bool = true) -> Bool {
|
||||
private func insert(cap: Cap, notify: Bool = true) -> Bool {
|
||||
do {
|
||||
try db.run(cap.insertQuery)
|
||||
if notifyDelegate {
|
||||
listeners.forEach { $0.value?.database(didAddCap: cap) }
|
||||
if notify {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didAddCap: cap)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
@ -211,14 +220,12 @@ final class Database {
|
||||
log("Failed update count \(version) for cap \(cap)")
|
||||
return false
|
||||
}
|
||||
listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
|
||||
guard addPendingUpload(for: cap, version: version) else {
|
||||
log("Failed to add cap \(cap) version \(version) to upload queue")
|
||||
return false
|
||||
}
|
||||
upload.uploadImage(for: cap, version: version) { count in
|
||||
guard let _ = count else {
|
||||
upload.uploadImage(for: cap, version: version) { actualVersion in
|
||||
guard let actualVersion = actualVersion else {
|
||||
self.log("Failed to upload image \(version) for cap \(cap)")
|
||||
return
|
||||
}
|
||||
@ -226,18 +233,23 @@ final class Database {
|
||||
self.log("Failed to remove version \(version) for cap \(cap) from upload queue")
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let query = updateQuery(for: cap).update(setter)
|
||||
try db.run(query)
|
||||
listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
if notify {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didChangeCap: cap)
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to update \(property) for cap \(cap): \(error)")
|
||||
@ -247,33 +259,36 @@ final class Database {
|
||||
|
||||
@discardableResult
|
||||
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
|
||||
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
|
||||
func update(color: UIColor, for cap: Int) -> Bool {
|
||||
let (red, green, blue) = color.rgb
|
||||
return update("color", for: cap, setter: Cap.rowRed <- red, Cap.rowGreen <- green, Cap.rowBlue <- blue)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(tile: Int, for cap: Int) -> Bool {
|
||||
update("tile", for: cap, setter: Cap.rowTile <- tile)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(count: Int, for cap: Int) -> Bool {
|
||||
update("count", for: cap, setter: Cap.rowCount <- count)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func update(matched: Bool, for cap: Int) -> Bool {
|
||||
update("matched", for: cap, setter: Cap.rowMatched <- matched)
|
||||
private func updateWithoutUpload(name: String, for cap: Int) -> Bool {
|
||||
update("name", for: cap, notify: false, setter: Cap.columnName <- name)
|
||||
}
|
||||
|
||||
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 {
|
||||
try db.run(upload.insertQuery(for: cap, version: version))
|
||||
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 {
|
||||
try db.run(upload.deleteQuery(for: cap, version: version))
|
||||
return true
|
||||
@ -335,8 +352,8 @@ final class Database {
|
||||
|
||||
private func count(for cap: Int) -> Int? {
|
||||
do {
|
||||
let row = try db.pluck(updateQuery(for: cap).select(Cap.rowCount))
|
||||
return row?[Cap.rowCount]
|
||||
let row = try db.pluck(updateQuery(for: cap).select(Cap.columnCount))
|
||||
return row?[Cap.columnCount]
|
||||
} catch {
|
||||
log("Failed to get count for cap \(cap)")
|
||||
return nil
|
||||
@ -345,7 +362,7 @@ final class Database {
|
||||
|
||||
func countOfCaps(withImageCountLessThan limit: Int) -> Int {
|
||||
do {
|
||||
return try db.scalar(Cap.table.filter(Cap.rowCount < limit).count)
|
||||
return try db.scalar(Cap.table.filter(Cap.columnCount < limit).count)
|
||||
} catch {
|
||||
log("Failed to get caps with less than \(limit) images")
|
||||
return 0
|
||||
@ -358,7 +375,7 @@ final class Database {
|
||||
var capsFound = 0
|
||||
repeat {
|
||||
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
|
||||
|
||||
return (currentCount, capsFound)
|
||||
@ -368,58 +385,77 @@ final class Database {
|
||||
}
|
||||
|
||||
func updateQuery(for cap: Int) -> Table {
|
||||
Cap.table.filter(Cap.rowId == cap)
|
||||
Cap.table.filter(Cap.columnId == cap)
|
||||
}
|
||||
|
||||
// MARK: Downloads
|
||||
|
||||
@discardableResult
|
||||
func downloadMainImage(for cap: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
return download.image(for: cap, version: 0) { image in
|
||||
// Guaranteed to be on the main queue
|
||||
guard let image = image else {
|
||||
completion(nil)
|
||||
func downloadMainImage(for cap: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
return download.mainImage(for: cap) { success in
|
||||
guard success else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
defer {
|
||||
completion(image)
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didLoadImageForCap: cap)
|
||||
}
|
||||
if !app.storage.save(thumbnail: Cap.thumbnail(for: image), for: cap) {
|
||||
self.log("Failed to save thumbnail for cap \(cap)")
|
||||
}
|
||||
guard let color = image.averageColor else {
|
||||
self.log("Failed to calculate color for cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.update(color: color, for: cap)
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
func getServerDatabaseSize(completion: @escaping (_ size: Int64?) -> Void) {
|
||||
download.databaseSize(completion: completion)
|
||||
}
|
||||
|
||||
func downloadServerDatabase(progress: Download.Delegate.ProgressHandler? = nil, completion: @escaping (_ success: Bool) -> Void, processed: (() -> Void)? = nil) {
|
||||
download.database(progress: progress) { tempUrl in
|
||||
guard let url = tempUrl else {
|
||||
self.log("Failed to download database")
|
||||
completion(false)
|
||||
func downloadCapNames(completion: @escaping (_ success: Bool) -> Void) {
|
||||
log("Downloading cap names")
|
||||
download.names { names in
|
||||
guard let names = names else {
|
||||
DispatchQueue.main.async {
|
||||
completion(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
self.processServerDatabase(at: url)
|
||||
processed?()
|
||||
self.update(names: names)
|
||||
DispatchQueue.main.async {
|
||||
completion(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
let caps = self.caps.filter { !$0.hasImage }.map { $0.id }
|
||||
let caps = capsWithoutImages.map { $0.id }
|
||||
|
||||
var downloaded = 0
|
||||
let total = caps.count
|
||||
@ -458,7 +494,6 @@ final class Database {
|
||||
}
|
||||
self.log("Finished all image downloads")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func hasNewClassifier(completion: @escaping (_ version: Int?, _ size: Int64?) -> Void) {
|
||||
@ -474,7 +509,7 @@ final class Database {
|
||||
completion(nil, nil)
|
||||
return
|
||||
}
|
||||
self.log("Getting classifier size: Own version \(ownVersion), server version \(version)")
|
||||
self.log("Getting size of classifier \(version)")
|
||||
self.download.classifierSize { size in
|
||||
completion(version, size)
|
||||
}
|
||||
@ -513,37 +548,50 @@ final class Database {
|
||||
}
|
||||
}
|
||||
|
||||
func downloadImageCounts() {
|
||||
guard !hasPendingUploads else {
|
||||
log("Waiting to refresh server image counts (uploads pending)")
|
||||
return
|
||||
}
|
||||
func downloadImageCounts(completion: @escaping (_ success: Bool) -> Void) {
|
||||
log("Refreshing server image counts")
|
||||
app.database.download.imageCounts { counts in
|
||||
download.imageCounts { counts in
|
||||
guard let counts = counts else {
|
||||
self.log("Failed to download server image counts")
|
||||
DispatchQueue.main.async {
|
||||
completion(false)
|
||||
}
|
||||
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)]) {
|
||||
let capsCounts = self.caps.reduce(into: [:]) { $0[$1.id] = $1.count }
|
||||
private func didDownload(imageCounts newCounts: [Int]) -> [Int : Int] {
|
||||
let capsCounts = capDict
|
||||
if newCounts.count != capsCounts.count {
|
||||
log("Downloaded \(newCounts.count) image counts, but \(app.database.capCount) caps stored locally")
|
||||
return
|
||||
log("Downloaded \(newCounts.count) image counts, but \(capsCounts.count) caps stored locally")
|
||||
}
|
||||
let changed = newCounts.compactMap { id, newCount -> Int? in
|
||||
guard let oldCount = capsCounts[id] else {
|
||||
var newCaps = [Int : Int]()
|
||||
let changed = newCounts.enumerated().compactMap { id, newCount -> Int? in
|
||||
let id = id + 1
|
||||
guard let oldCount = capsCounts[id]?.count else {
|
||||
log("Received count \(newCount) for unknown cap \(id)")
|
||||
newCaps[id] = newCount
|
||||
return nil
|
||||
}
|
||||
guard oldCount != newCount else {
|
||||
return nil
|
||||
}
|
||||
app.database.update(count: newCount, for: id)
|
||||
self.update(count: newCount, for: id)
|
||||
return id
|
||||
}
|
||||
switch changed.count {
|
||||
@ -556,28 +604,72 @@ final class Database {
|
||||
default:
|
||||
log("Refreshed image counts for all caps (\(changed.count) changed)")
|
||||
}
|
||||
|
||||
return newCaps
|
||||
}
|
||||
|
||||
private func downloadInfo(for newCaps: [Int : Int], completion: @escaping (_ success: Bool) -> Void) {
|
||||
var success = true
|
||||
let group = DispatchGroup()
|
||||
for (id, count) in newCaps {
|
||||
group.enter()
|
||||
download.name(for: id) { name in
|
||||
guard let name = name else {
|
||||
self.log("Failed to get name for new cap \(id)")
|
||||
success = false
|
||||
group.leave()
|
||||
return
|
||||
}
|
||||
let cap = Cap(id: id, name: name, count: count)
|
||||
self.insert(cap: cap)
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
if group.wait(timeout: .now() + .seconds(30)) != .success {
|
||||
self.log("Timed out waiting for images to be downloaded")
|
||||
}
|
||||
completion(success)
|
||||
}
|
||||
|
||||
func downloadImageCount(for cap: Int) {
|
||||
download.imageCount(for: cap) { count in
|
||||
guard let count = count else {
|
||||
return
|
||||
}
|
||||
self.update(count: count, for: cap)
|
||||
}
|
||||
}
|
||||
|
||||
private func processServerDatabase(at url: URL) {
|
||||
guard let db = ServerDatabase(downloadedTo: url) else {
|
||||
log("Failed to open downloaded server database")
|
||||
func uploadRemainingCaps() {
|
||||
let uploads = self.pendingCapUploads
|
||||
guard uploads.count > 0 else {
|
||||
log("No pending cap uploads")
|
||||
return
|
||||
}
|
||||
for (id, count, name) in db.caps {
|
||||
let cap = Cap(id: id, name: name, count: count)
|
||||
insert(cap: cap, notifyDelegate: false)
|
||||
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() {
|
||||
guard pendingUploads.count > 0 else {
|
||||
log("No pending uploads")
|
||||
let uploads = pendingImageUploads
|
||||
guard uploads.count > 0 else {
|
||||
log("No pending image uploads")
|
||||
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
|
||||
guard let _ = count else {
|
||||
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)")
|
||||
return
|
||||
}
|
||||
upload.setMainImage(for: cap, to: version) { color in
|
||||
guard let color = color else {
|
||||
upload.setMainImage(for: cap, to: version) { success in
|
||||
guard success else {
|
||||
self.log("Could not make \(version) the main image for cap \(cap)")
|
||||
return
|
||||
}
|
||||
self.update(color: color, for: cap)
|
||||
self.listeners.forEach { $0.value?.database(didChangeCap: cap) }
|
||||
guard app.storage.switchMainImage(to: version, for: cap) else {
|
||||
self.log("Could not switch \(version) to main image for cap \(cap)")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.database(didLoadImageForCap: cap)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -23,20 +23,32 @@ final class Download {
|
||||
let delegate = Delegate()
|
||||
|
||||
self.serverUrl = server
|
||||
self.session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
|
||||
self.session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil)
|
||||
self.delegate = delegate
|
||||
}
|
||||
|
||||
// MARK: Paths
|
||||
|
||||
private static func serverDatabaseUrl(server: URL) -> URL {
|
||||
server.appendingPathComponent("db.sqlite3")
|
||||
}
|
||||
|
||||
var serverDatabaseUrl: URL {
|
||||
Download.serverDatabaseUrl(server: serverUrl)
|
||||
var serverNameListUrl: URL {
|
||||
Download.serverNameListUrl(server: serverUrl)
|
||||
}
|
||||
|
||||
private static func serverNameListUrl(server: URL) -> URL {
|
||||
server.appendingPathComponent("names.txt")
|
||||
}
|
||||
|
||||
private var serverClassifierVersionUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.version")
|
||||
}
|
||||
|
||||
private var serverRecognitionModelUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.mlmodel")
|
||||
}
|
||||
|
||||
private var serverAllCountsUrl: URL {
|
||||
serverUrl.appendingPathComponent("counts")
|
||||
}
|
||||
|
||||
var serverImageUrl: URL {
|
||||
serverUrl.appendingPathComponent("images")
|
||||
}
|
||||
@ -45,22 +57,14 @@ final class Download {
|
||||
serverImageUrl.appendingPathComponent(String(format: "%04d/%04d-%02d.jpg", cap, cap, version))
|
||||
}
|
||||
|
||||
private func serverNameUrl(for cap: Int) -> URL {
|
||||
serverUrl.appendingPathComponent("name/\(cap)")
|
||||
}
|
||||
|
||||
private func serverImageCountUrl(for cap: Int) -> URL {
|
||||
serverUrl.appendingPathComponent("count/\(cap)")
|
||||
}
|
||||
|
||||
private var serverClassifierVersionUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.version")
|
||||
}
|
||||
|
||||
private var serverAllCountsUrl: URL {
|
||||
serverUrl.appendingPathComponent("count/all")
|
||||
}
|
||||
|
||||
var serverRecognitionModelUrl: URL {
|
||||
serverUrl.appendingPathComponent("classifier.mlmodel")
|
||||
}
|
||||
|
||||
// MARK: Delegate
|
||||
|
||||
final class Delegate: NSObject, URLSessionDownloadDelegate {
|
||||
@ -116,7 +120,7 @@ final class Download {
|
||||
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
|
||||
*/
|
||||
@discardableResult
|
||||
func mainImage(for cap: Int, completion: ((_ image: UIImage?) -> Void)?) -> Bool {
|
||||
func mainImage(for cap: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
let url = serverImageUrl(for: cap)
|
||||
let query = "Main image of cap \(cap)"
|
||||
guard !downloadingMainImages.contains(cap) else {
|
||||
@ -125,28 +129,19 @@ final class Download {
|
||||
downloadingMainImages.insert(cap)
|
||||
|
||||
let task = session.downloadTask(with: url) { fileUrl, response, error in
|
||||
self.downloadingMainImages.remove(cap)
|
||||
DispatchQueue.main.async {
|
||||
self.downloadingMainImages.remove(cap)
|
||||
}
|
||||
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard app.storage.saveImage(at: fileUrl, for: cap) else {
|
||||
self.log("Request '\(query)' could not move downloaded file")
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let image = app.storage.image(for: cap) else {
|
||||
self.log("Request '\(query)' received an invalid image")
|
||||
completion?(nil)
|
||||
return
|
||||
}
|
||||
completion?(image)
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
return true
|
||||
@ -157,35 +152,23 @@ final class Download {
|
||||
- Parameter cap: The id of the cap.
|
||||
- Parameter version: The image version to download.
|
||||
- Parameter completion: A closure with the resulting image
|
||||
- Note: The closure will be called from the main queue.
|
||||
- Returns: `true`, of the file download was started, `false`, if the image is already downloading.
|
||||
*/
|
||||
@discardableResult
|
||||
func image(for cap: Int, version: Int, completion: @escaping (_ image: UIImage?) -> Void) -> Bool {
|
||||
func image(for cap: Int, version: Int, completion: @escaping (_ success: Bool) -> Void) -> Bool {
|
||||
let url = serverImageUrl(for: cap, version: version)
|
||||
let query = "Image of cap \(cap) version \(version)"
|
||||
let task = session.downloadTask(with: url) { fileUrl, response, error in
|
||||
guard let fileUrl = self.convertResponse(to: query, fileUrl, response, error) else {
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard app.storage.saveImage(at: fileUrl, for: cap, version: version) else {
|
||||
self.log("Request '\(query)' could not move downloaded file")
|
||||
DispatchQueue.main.async {
|
||||
completion(nil)
|
||||
}
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
guard let image = app.storage.image(for: cap, version: version) else {
|
||||
self.log("Request '\(query)' received an invalid image")
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
completion(image)
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
return true
|
||||
@ -194,57 +177,40 @@ final class Download {
|
||||
func imageCount(for cap: Int, completion: @escaping (_ count: Int?) -> Void) {
|
||||
let url = serverImageCountUrl(for: cap)
|
||||
let query = "Image count for cap \(cap)"
|
||||
let task = session.dataTask(with: url) { data, response, error in
|
||||
let int = self.convertIntResponse(to: query, data, response, error)
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
session.startTaskExpectingInt(with: url, query: query, completion: completion)
|
||||
}
|
||||
|
||||
func imageCounts(completion: @escaping ([(cap: Int, count: Int)]?) -> Void) {
|
||||
let url = serverAllCountsUrl
|
||||
func name(for cap: Int, completion: @escaping (_ name: String?) -> Void) {
|
||||
let url = serverNameUrl(for: cap)
|
||||
let query = "Name for cap \(cap)"
|
||||
session.startTaskExpectingString(with: url, query: query, completion: completion)
|
||||
}
|
||||
|
||||
func imageCounts(completion: @escaping ([Int]?) -> Void) {
|
||||
let query = "Image count of all caps"
|
||||
let task = session.dataTask(with: url) { data, response, error in
|
||||
guard let string = self.convertStringResponse(to: query, data, response, error) else {
|
||||
session.startTaskExpectingData(with: serverAllCountsUrl, query: query) { data in
|
||||
guard let data = data else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert the encoded string into (id, count) pairs
|
||||
let parts = string.components(separatedBy: ";")
|
||||
let array: [(cap: Int, count: Int)] = parts.compactMap { s in
|
||||
let p = s.components(separatedBy: "#")
|
||||
guard p.count == 2, let cap = Int(p[0]), let count = Int(p[1]) else {
|
||||
return nil
|
||||
}
|
||||
return (cap, count)
|
||||
}
|
||||
completion(array)
|
||||
completion(data.map(Int.init))
|
||||
}
|
||||
}
|
||||
|
||||
func names(completion: @escaping ([String]?) -> Void) {
|
||||
let query = "Download of server database"
|
||||
session.startTaskExpectingString(with: serverNameListUrl, query: query) { string in
|
||||
completion(string?.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n"))
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
||||
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) {
|
||||
let query = "Server classifier version"
|
||||
let task = session.dataTask(with: serverClassifierVersionUrl) { data, response, error in
|
||||
let int = self.convertIntResponse(to: query, data, response, error)
|
||||
completion(int)
|
||||
}
|
||||
task.resume()
|
||||
session.startTaskExpectingInt(with: serverClassifierVersionUrl, query: query, completion: completion)
|
||||
}
|
||||
|
||||
func classifierSize(completion: @escaping (Int64?) -> Void) {
|
||||
@ -326,3 +292,68 @@ final class Download {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 { }
|
@ -43,8 +43,12 @@ final class Storage {
|
||||
baseUrl.appendingPathComponent("\(cap)-thumb.jpg")
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
private func tileImageUrl(for image: String) -> URL {
|
||||
baseUrl.appendingPathComponent(image.clean + ".tile")
|
||||
}
|
||||
|
||||
// MARK: Storage
|
||||
|
||||
/**
|
||||
Save an image to disk
|
||||
- parameter url: The url where the downloaded image is stored
|
||||
@ -160,6 +164,20 @@ final class Storage {
|
||||
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
|
||||
|
||||
/**
|
||||
@ -171,6 +189,15 @@ final class Storage {
|
||||
fm.fileExists(atPath: localImageUrl(for: cap, version: 0).path)
|
||||
}
|
||||
|
||||
/**
|
||||
Check if a thumbnail exists for a cap
|
||||
- parameter cap: The id of the cap
|
||||
- returns: True, if a thumbnail exists
|
||||
*/
|
||||
func hasThumbnail(for cap: Int) -> Bool {
|
||||
fm.fileExists(atPath: thumbnailUrl(for: cap).path)
|
||||
}
|
||||
|
||||
func existingImageUrl(for cap: Int, version: Int = 0) -> URL? {
|
||||
let url = localImageUrl(for: cap, version: version)
|
||||
return fm.fileExists(atPath: url.path) ? url : nil
|
||||
@ -212,21 +239,6 @@ final class Storage {
|
||||
return image
|
||||
}
|
||||
|
||||
/**
|
||||
Get the image for a cap.
|
||||
If the image exists on disk, it is returned.
|
||||
If no image exists locally, then this function returns nil.
|
||||
- parameter cap: The id of the cap
|
||||
- parameter version: The image version
|
||||
- returns: The image, or `nil`
|
||||
*/
|
||||
func ciImage(for cap: Int, version: Int = 0) -> CIImage? {
|
||||
guard let url = existingImageUrl(for: cap, version: version) else {
|
||||
return nil
|
||||
}
|
||||
return CIImage(contentsOf: url)
|
||||
}
|
||||
|
||||
/**
|
||||
Get the thumbnail data for a cap.
|
||||
If the image exists on disk, it is returned.
|
||||
@ -268,15 +280,18 @@ final class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
func averageColor(for cap: Int, version: Int = 0) -> UIColor? {
|
||||
guard let inputImage = ciImage(for: cap, version: version) else {
|
||||
func ciImage(for cap: Int) -> CIImage? {
|
||||
let url = thumbnailUrl(for: cap)
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
}
|
||||
guard let image = CIImage(contentsOf: url) else {
|
||||
error("Failed to read CIImage for main image of cap \(cap)")
|
||||
return nil
|
||||
}
|
||||
return inputImage.averageColor
|
||||
return image
|
||||
}
|
||||
|
||||
|
||||
private func readData(from url: URL) -> Data? {
|
||||
guard fm.fileExists(atPath: url.path) else {
|
||||
return nil
|
||||
@ -304,14 +319,25 @@ final class Storage {
|
||||
|
||||
@discardableResult
|
||||
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
|
||||
}
|
||||
do {
|
||||
try fm.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
log("Failed to delete image \(version) for cap \(cap): \(error)")
|
||||
log("Failed to delete file \(url.lastPathComponent): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
134
CapCollector/Data/TileImage.swift
Normal file
134
CapCollector/Data/TileImage.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@ -90,6 +90,7 @@ struct Upload {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
@ -134,44 +135,35 @@ struct Upload {
|
||||
|
||||
/**
|
||||
Sets the main image for a cap to a different version.
|
||||
|
||||
- Parameter cap: The id of the cap
|
||||
- Parameter version: The version to set as the main version.
|
||||
- Parameter completion: A callback with the new average color on completion.
|
||||
- Parameter completion: A callback with the result on completion.
|
||||
*/
|
||||
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ averageColor: UIColor?) -> Void) {
|
||||
guard let averageColor = app.storage.averageColor(for: cap, version: version) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
func setMainImage(for cap: Int, to version: Int, completion: @escaping (_ success: Bool) -> Void) {
|
||||
let url = serverChangeMainImageUrl(for: cap, to: version)
|
||||
var request = URLRequest(url: url)
|
||||
let averageRGB = averageColor.rgb
|
||||
request.addValue("\(averageRGB.red)", forHTTPHeaderField: "r")
|
||||
request.addValue("\(averageRGB.green)", forHTTPHeaderField: "g")
|
||||
request.addValue("\(averageRGB.blue)", forHTTPHeaderField: "b")
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
if let error = error {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): \(error)")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard let response = response else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): No response")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard let urlResponse = response as? HTTPURLResponse else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): \(response)")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
guard urlResponse.statusCode == 200 else {
|
||||
self.log("Failed to set main image of cap \(cap) to \(version): Response \(urlResponse.statusCode)")
|
||||
completion(nil)
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
completion(averageColor)
|
||||
completion(true)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
|
@ -10,13 +10,13 @@ import UIKit
|
||||
|
||||
extension UIColor {
|
||||
|
||||
var rgb: (red: Int, green: Int, blue: Int) {
|
||||
var rgb: (red: Double, green: Double, blue: Double) {
|
||||
var fRed: CGFloat = 0
|
||||
var fGreen: CGFloat = 0
|
||||
var fBlue: CGFloat = 0
|
||||
var fAlpha: CGFloat = 0
|
||||
getRed(&fRed, green: &fGreen, blue: &fBlue, alpha: &fAlpha)
|
||||
return (Int(fRed * 255.0), Int(fGreen * 255.0), Int(fBlue * 255.0))
|
||||
return (Double(fRed), Double(fGreen), Double(fBlue))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -89,6 +89,35 @@ extension UIImage {
|
||||
|
||||
extension CIImage {
|
||||
|
||||
func averageColor(context: CIContext) -> UIColor? {
|
||||
let extentVector = CIVector(
|
||||
x: extent.origin.x,
|
||||
y: extent.origin.y,
|
||||
z: extent.size.width,
|
||||
w: extent.size.height)
|
||||
|
||||
guard let filter = CIFilter(name: "CIAreaAverage", parameters: [kCIInputImageKey: self, kCIInputExtentKey: extentVector]) else {
|
||||
log("Failed to create filter")
|
||||
return nil
|
||||
}
|
||||
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? {
|
||||
let extentVector = CIVector(
|
||||
x: extent.origin.x,
|
||||
@ -106,8 +135,13 @@ extension CIImage {
|
||||
}
|
||||
|
||||
var bitmap = [UInt8](repeating: 0, count: 4)
|
||||
let context = CIContext(options: [.workingColorSpace: kCFNull!])
|
||||
context.render(outputImage, toBitmap: &bitmap, rowBytes: 4, bounds: CGRect(x: 0, y: 0, width: 1, height: 1), format: .RGBA8, colorSpace: nil)
|
||||
guard let null = kCFNull else {
|
||||
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(
|
||||
red: saturate(bitmap[0]),
|
||||
|
@ -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 {
|
||||
|
||||
static var logToken: String {
|
||||
@ -35,10 +49,28 @@ extension Logger {
|
||||
}
|
||||
|
||||
private static func addToFile(_ message: String) {
|
||||
logFile += "\n" + message
|
||||
Log.write("\n" + 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?
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ class CapCell: UITableViewCell {
|
||||
|
||||
@IBOutlet private weak var nameLabel: UILabel!
|
||||
|
||||
@IBOutlet weak var countLabel: UILabel!
|
||||
@IBOutlet private weak var countLabel: UILabel!
|
||||
|
||||
var id: Int = 0
|
||||
|
||||
@ -25,24 +25,15 @@ class CapCell: UITableViewCell {
|
||||
capImage.image = image ?? UIImage(named: "launch")
|
||||
}
|
||||
|
||||
func set(cap: Cap, match: Float?) {
|
||||
id = cap.id
|
||||
if let image = cap.image {
|
||||
set(image: image)
|
||||
|
||||
} else {
|
||||
capImage.image = UIImage(named: "launch")
|
||||
cap.downloadMainImage() { image in
|
||||
self.set(image: image)
|
||||
}
|
||||
}
|
||||
|
||||
//capImage.borderColor = AppDelegate.tintColor
|
||||
|
||||
matchLabel.text = cap.matchDescription(match: match)
|
||||
nameLabel.text = cap.name
|
||||
countLabel.text = cap.subtitle
|
||||
func set(name: String) {
|
||||
self.nameLabel.text = name
|
||||
}
|
||||
|
||||
|
||||
func set(matchLabel: String) {
|
||||
self.matchLabel.text = matchLabel
|
||||
}
|
||||
|
||||
func set(countLabel: String) {
|
||||
self.countLabel.text = countLabel
|
||||
}
|
||||
}
|
||||
|
@ -10,10 +10,7 @@ import UIKit
|
||||
|
||||
class GridViewController: UIViewController {
|
||||
|
||||
/// The number of caps horizontally.
|
||||
private let columns = 40
|
||||
|
||||
/// The number of hroizontal pixels for each cap.
|
||||
/// The number of horizontal pixels for each cap.
|
||||
static let len: CGFloat = 60
|
||||
|
||||
private lazy var rowHeight = GridViewController.len * 0.866
|
||||
@ -27,12 +24,20 @@ class GridViewController: UIViewController {
|
||||
@IBOutlet weak var scrollView: UIScrollView!
|
||||
|
||||
/// 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 changedTiles = Set<Int>()
|
||||
|
||||
private var selectedTile: Int? = nil
|
||||
|
||||
private weak var selectionView: RoundedButton!
|
||||
@ -47,34 +52,51 @@ class GridViewController: UIViewController {
|
||||
|
||||
private var isShowingColors = false
|
||||
|
||||
private var capCount = 0
|
||||
|
||||
@IBAction func toggleAverageColor(_ sender: Any) {
|
||||
isShowingColors = !isShowingColors
|
||||
for (tile, view) in installedTiles {
|
||||
if isShowingColors {
|
||||
view.image = nil
|
||||
view.backgroundColor = tileColor(tile: tile)
|
||||
} else {
|
||||
if let image = tiles[tile].thumbnail {
|
||||
let id = tiles[tile]
|
||||
if let image = app.storage.thumbnail(for: id) {
|
||||
view.image = image
|
||||
continue
|
||||
}
|
||||
tiles[tile].downloadMainImage() { image in
|
||||
view.image = image
|
||||
}
|
||||
self.downloadImage(cap: id, tile: tile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func load(tileImage: Database.TileImage) {
|
||||
let totalCount = app.database.capCount
|
||||
let firstNewId = tileImage.caps.count + 1
|
||||
if totalCount >= firstNewId {
|
||||
self.tiles = tileImage.caps + (firstNewId...totalCount)
|
||||
} else {
|
||||
self.tiles = tileImage.caps
|
||||
}
|
||||
self.columns = tileImage.width
|
||||
self.name = tileImage.name
|
||||
}
|
||||
|
||||
private func saveTileImage() {
|
||||
let tileImage = Database.TileImage(name: name, width: columns, caps: tiles)
|
||||
guard app.database.save(tileImage: tileImage) else {
|
||||
log("Failed to save tile image")
|
||||
return
|
||||
}
|
||||
log("Tile image saved")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
app.database.add(listener: self)
|
||||
capCount = app.database.capCount
|
||||
tiles = app.database.caps.sorted { $0.tile < $1.tile }
|
||||
colors = app.database.colors
|
||||
|
||||
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)
|
||||
myView = UIView(frame: CGRect(origin: .zero, size: canvasSize))
|
||||
|
||||
@ -103,20 +125,14 @@ class GridViewController: UIViewController {
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
saveChangedTiles()
|
||||
saveTileImage()
|
||||
}
|
||||
|
||||
// MARK: Tiles
|
||||
|
||||
private func tileColor(tile: Int) -> UIColor {
|
||||
return tiles[tile].color
|
||||
}
|
||||
|
||||
private func saveChangedTiles() {
|
||||
for tile in changedTiles {
|
||||
let cap = tiles[tile]
|
||||
app.database.update(tile: tile, for: cap.id)
|
||||
}
|
||||
private func tileColor(tile: Int) -> UIColor? {
|
||||
let id = tiles[tile]
|
||||
return colors[id]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -126,8 +142,6 @@ class GridViewController: UIViewController {
|
||||
let temp = tiles[rhs]
|
||||
tiles[rhs] = tiles[lhs]
|
||||
tiles[lhs] = temp
|
||||
changedTiles.insert(lhs)
|
||||
changedTiles.insert(rhs)
|
||||
return true
|
||||
}
|
||||
|
||||
@ -193,22 +207,53 @@ class GridViewController: UIViewController {
|
||||
private func makeTile(_ tile: Int) {
|
||||
let view = RoundedImageView(frame: frame(for: tile))
|
||||
myView.addSubview(view)
|
||||
view.backgroundColor = tileColor(tile: tile)
|
||||
defer {
|
||||
installedTiles[tile] = view
|
||||
}
|
||||
// Only set image if images are shown
|
||||
guard !isShowingColors else {
|
||||
view.backgroundColor = tileColor(tile: tile)
|
||||
return
|
||||
|
||||
}
|
||||
if let image = tiles[tile].thumbnail {
|
||||
if let image = app.storage.thumbnail(for: tiles[tile]) {
|
||||
view.image = image
|
||||
return
|
||||
}
|
||||
|
||||
tiles[tile].downloadMainImage() { image in
|
||||
view.image = image
|
||||
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
|
||||
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) {
|
||||
for tile in 0..<capCount {
|
||||
for tile in 0..<tiles.count {
|
||||
refresh(tile: tile, inVisibleRect: rect)
|
||||
}
|
||||
}
|
||||
@ -313,30 +358,3 @@ private extension Int {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
@ -96,25 +96,32 @@ class ImageSelector: UIViewController {
|
||||
private func downloadImages() {
|
||||
images = [UIImage?](repeating: nil, count: cap.count)
|
||||
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.collection.reloadItems(at: [IndexPath(row: 0, section: 0)])
|
||||
} 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.collection.reloadItems(at: [IndexPath(row: 0, section: 0)])
|
||||
DispatchQueue.main.async {
|
||||
self.collection.reloadItems(at: [IndexPath(row: 0, section: 0)])
|
||||
}
|
||||
}
|
||||
}
|
||||
guard cap.count > 0 else {
|
||||
return
|
||||
}
|
||||
for number in 1..<cap.count {
|
||||
_ = cap.downloadImage(number) { image in
|
||||
guard let image = image else {
|
||||
app.database.downloadImage(for: cap.id, version: number) { success in
|
||||
guard success, let image = app.storage.image(for: self.cap.id, version: number) else {
|
||||
return
|
||||
}
|
||||
self.images[number] = image
|
||||
self.collection.reloadItems(at: [IndexPath(row: number, section: 0)])
|
||||
DispatchQueue.main.async {
|
||||
self.collection.reloadItems(at: [IndexPath(row: number, section: 0)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
@ -70,7 +70,7 @@ class SearchAndDisplayAccessory: PassthroughView {
|
||||
weak var currentBlurContraint: NSLayoutConstraint?
|
||||
|
||||
weak var delegate: CapAccessoryDelegate?
|
||||
|
||||
|
||||
var currentImage: UIImage? {
|
||||
capImage.image
|
||||
}
|
||||
@ -122,7 +122,7 @@ class SearchAndDisplayAccessory: PassthroughView {
|
||||
|
||||
cameraButton.setImage(UIImage.templateImage(named: "camera_square"), for: .normal)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Search bar
|
||||
|
||||
func dismissAndClearSearchBar() {
|
||||
@ -132,14 +132,22 @@ class SearchAndDisplayAccessory: PassthroughView {
|
||||
|
||||
// MARK: Cap image
|
||||
|
||||
func showImageView(with image: UIImage) {
|
||||
func showImageView(with image: UIImage, isUnlocked: Bool) {
|
||||
capImage.image = image
|
||||
|
||||
saveButton.isHidden = !isUnlocked
|
||||
saveButton.isEnabled = isUnlocked
|
||||
let text = isUnlocked ? "Delete" : "Clear image"
|
||||
deleteButton.setTitle(text, for: .normal)
|
||||
|
||||
showImageView()
|
||||
}
|
||||
|
||||
func discardImage() {
|
||||
dismissAndClearSearchBar()
|
||||
hideImageView()
|
||||
DispatchQueue.main.async {
|
||||
self.dismissAndClearSearchBar()
|
||||
self.hideImageView()
|
||||
}
|
||||
delegate?.capAccessoryDidDiscardImage()
|
||||
}
|
||||
|
||||
|
@ -13,68 +13,101 @@ enum SortCriteria: Int {
|
||||
case name = 1
|
||||
case count = 2
|
||||
case match = 3
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .id:
|
||||
return "Id"
|
||||
case .name:
|
||||
return "Name"
|
||||
case .count:
|
||||
return "Count"
|
||||
case .match:
|
||||
return "Match"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol SortControllerDelegate: class {
|
||||
|
||||
var sortControllerShouldIncludeMatchOption: Bool { get }
|
||||
|
||||
|
||||
func sortController(didSelect sortType: SortCriteria, ascending: Bool)
|
||||
}
|
||||
|
||||
class SortController: UITableViewController {
|
||||
|
||||
var selected: SortCriteria = .count
|
||||
@IBOutlet weak var thirdRowLabel: UILabel!
|
||||
|
||||
var ascending: Bool = true
|
||||
var selected: SortCriteria = .id
|
||||
|
||||
private var includeMatches: Bool {
|
||||
delegate?.sortControllerShouldIncludeMatchOption ?? false
|
||||
}
|
||||
var ascending: Bool = false
|
||||
|
||||
weak var delegate: SortControllerDelegate?
|
||||
|
||||
var options = [SortCriteria]()
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
let height = includeMatches ? 298 : 258
|
||||
preferredContentSize = CGSize(width: 200, height: height)
|
||||
preferredContentSize = CGSize(width: 200, height: 139 + options.count * 40)
|
||||
}
|
||||
|
||||
private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
UIImpactFeedbackGenerator(style: style).impactOccurred()
|
||||
}
|
||||
|
||||
private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
generator.impactOccurred()
|
||||
private func sortCriteria(for index: Int) -> SortCriteria {
|
||||
index < options.count ? options[index] : .match
|
||||
}
|
||||
|
||||
// MARK: - Table view data source
|
||||
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 2
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
section == 0 ? "Sort order" : "Sort by"
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
section == 0 ? 1 : options.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "SortCell")!
|
||||
|
||||
guard indexPath.section != 0 else {
|
||||
cell.accessoryType = ascending ? .checkmark : .none
|
||||
cell.textLabel?.text = "Ascending"
|
||||
return cell
|
||||
}
|
||||
let select = sortCriteria(for: indexPath.row)
|
||||
cell.textLabel?.text = select.text
|
||||
guard select == selected else {
|
||||
cell.accessoryType = .none
|
||||
return cell
|
||||
}
|
||||
cell.accessoryType = .checkmark
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
40
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard indexPath.section == 1 else {
|
||||
ascending = !ascending
|
||||
tableView.reloadData()
|
||||
tableView.reloadRows(at: [indexPath], with: .automatic)
|
||||
delegate?.sortController(didSelect: selected, ascending: ascending)
|
||||
giveFeedback(.light)
|
||||
return
|
||||
}
|
||||
giveFeedback(.medium)
|
||||
selected = SortCriteria(rawValue: indexPath.row)!
|
||||
selected = sortCriteria(for: indexPath.row)
|
||||
tableView.reloadRows(at: [indexPath], with: .automatic)
|
||||
delegate?.sortController(didSelect: selected, ascending: ascending)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,10 @@ class TableView: UITableViewController {
|
||||
/// This will be set to a cap id when adding a cap to it
|
||||
private var capToAddImageTo: Int?
|
||||
|
||||
private var isUnlocked = false
|
||||
|
||||
private var processingScreenHud: JGProgressHUD?
|
||||
|
||||
// MARK: Computed properties
|
||||
|
||||
private var titleText: String {
|
||||
@ -54,7 +58,7 @@ class TableView: UITableViewController {
|
||||
|
||||
private var subtitleText: String {
|
||||
let capCount = app.database.capCount
|
||||
guard capCount > 0 else {
|
||||
guard capCount > 0, isUnlocked else {
|
||||
return ""
|
||||
}
|
||||
let allImages = app.database.imageCount
|
||||
@ -76,18 +80,11 @@ class TableView: UITableViewController {
|
||||
// MARK: - Actions
|
||||
|
||||
@IBAction func updateInfo(_ sender: UIBarButtonItem) {
|
||||
downloadNewestClassifierIfNeeded()
|
||||
downloadImageCounts()
|
||||
checkIfCapImagesNeedDownload()
|
||||
downloadCapNames()
|
||||
}
|
||||
|
||||
@IBAction func showMosaic(_ sender: UIBarButtonItem) {
|
||||
let vc = app.mainStoryboard.instantiateViewController(withIdentifier: "GridView") as! GridViewController
|
||||
guard let nav = navigationController as? NavigationController else {
|
||||
return
|
||||
}
|
||||
nav.pushViewController(vc, animated: true)
|
||||
nav.allowLandscape = true
|
||||
checkThumbnailsAndColorsBeforShowingGrid()
|
||||
}
|
||||
|
||||
func showCameraView() {
|
||||
@ -104,6 +101,10 @@ class TableView: UITableViewController {
|
||||
controller.ascending = sortAscending
|
||||
controller.delegate = self
|
||||
|
||||
controller.options = [.id, .name]
|
||||
if isUnlocked { controller.options.append(.count) }
|
||||
if matches != nil { controller.options.append(.match) }
|
||||
|
||||
let presentationController = AlwaysPresentAsPopover.configurePresentation(forController: controller)
|
||||
|
||||
presentationController.sourceView = navigationItem.titleView!
|
||||
@ -116,26 +117,30 @@ class TableView: UITableViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
app.database.add(listener: self)
|
||||
tableView.rowHeight = 100
|
||||
|
||||
accessory = SearchAndDisplayAccessory(width: self.view.frame.width)
|
||||
accessory?.delegate = self
|
||||
|
||||
reloadCapsFromDatabase()
|
||||
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()
|
||||
loadClassifier()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
(navigationController as? NavigationController)?.allowLandscape = false
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
checkDatabaseIsDownloaded()
|
||||
checkClassifierIsDownloaded()
|
||||
isUnlocked = app.isUnlocked
|
||||
log(isUnlocked ? "App is unlocked" : "App is locked")
|
||||
}
|
||||
|
||||
override func didMove(toParent parent: UIViewController?) {
|
||||
@ -168,6 +173,9 @@ class TableView: UITableViewController {
|
||||
let recognizer = UITapGestureRecognizer(target: self, action: #selector(titleWasTapped))
|
||||
stackView.isUserInteractionEnabled = true
|
||||
stackView.addGestureRecognizer(recognizer)
|
||||
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(attemptChangeOfUserPermissions))
|
||||
stackView.addGestureRecognizer(longPress)
|
||||
}
|
||||
|
||||
private func updateNavigationItemTitleView() {
|
||||
@ -179,8 +187,167 @@ class TableView: UITableViewController {
|
||||
|
||||
// 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() {
|
||||
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() {
|
||||
@ -198,86 +365,150 @@ class TableView: UITableViewController {
|
||||
let detail = "Choose a new name for the cap"
|
||||
askUserForText("Enter new name", detail: detail, existingText: cap.name, yesText: "Save") { text in
|
||||
guard app.database.update(name: text, for: cap.id) else {
|
||||
self.showAlert("Name could not be set.", title: "Update failed")
|
||||
return
|
||||
}
|
||||
guard let newCap = app.database.cap(for: cap.id) else {
|
||||
return
|
||||
}
|
||||
self.shownCaps[indexPath.row] = newCap
|
||||
self.tableView.reloadRows(at: [indexPath], with: .none)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveNewCap(for image: UIImage) {
|
||||
let detail = "Choose a name for the image"
|
||||
askUserForText("Enter name", detail: detail, existingText: accessory!.searchBar.text, yesText: "Save") { text in
|
||||
guard app.database.createCap(image: image, name: text) else {
|
||||
self.showAlert("Cap not added")
|
||||
return
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
guard app.database.createCap(image: image, name: text) else {
|
||||
self.showAlert("Cap not added", title: "Database error")
|
||||
return
|
||||
}
|
||||
self.accessory!.discardImage()
|
||||
}
|
||||
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
|
||||
|
||||
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 {
|
||||
downloadNewestClassifierIfNeeded()
|
||||
return
|
||||
}
|
||||
classifier = Classifier(model: model)
|
||||
}
|
||||
|
||||
private func checkDatabaseIsDownloaded() {
|
||||
guard app.needsDownload else {
|
||||
return
|
||||
}
|
||||
log("Server database not available, getting database size")
|
||||
app.database.getServerDatabaseSize { size in
|
||||
DispatchQueue.main.async {
|
||||
self.askUserToDownloadServerDatabase(size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func askUserToDownloadServerDatabase(size: Int64?) {
|
||||
let detail = "The server database needs to be downloaded for the app to function properly. Would you like to download it now?"
|
||||
let sizeText = size != nil ? " (\(ByteCountFormatter.string(fromByteCount: size!, countStyle: .file)))" : ""
|
||||
presentUserBinaryChoice("Download server database", detail: detail + sizeText, yesText: "Download", noText: "Later") {
|
||||
self.downloadServerDatabase()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkIfCapImagesNeedDownload() {
|
||||
let count = app.database.capsWithoutImages
|
||||
guard count > 0 else {
|
||||
log("No cap images to download")
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.askUserToDownload(capImages: count)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func askUserToDownload(capImages: Int) {
|
||||
let detail = "\(capImages) caps have no image. Would you like to download them now? (\(ByteCountFormatter.string(fromByteCount: Int64(capImages * 10000), countStyle: .file)))"
|
||||
presentUserBinaryChoice("New classifier", detail: detail, yesText: "Download", noText: "Later") {
|
||||
presentUserBinaryChoice("Download images", detail: detail, yesText: "Download", noText: "Later", dismissed: {
|
||||
self.downloadNewestClassifierIfNeeded()
|
||||
}) {
|
||||
self.downloadAllCapImages()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func askUserToDownload(classifier version: Int, size: Int64?) {
|
||||
let detail = "Version \(version) of the classifier is available for download (You have version \(app.database.classifierVersion)). Would you like to download it now?"
|
||||
let oldVersion = app.database.classifierVersion
|
||||
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") {
|
||||
self.downloadClassifier()
|
||||
}
|
||||
}
|
||||
|
||||
private func askUserForText(_ title: String, detail: String, existingText: String? = nil, _ placeholder: String? = "Cap name", yesText: String, noText: String = "Cancel", confirmed: @escaping (_ text: String) -> Void) {
|
||||
DispatchQueue.main.sync {
|
||||
private func askUserToDownloadFirst(classifier version: Int, sizeText: String) {
|
||||
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(
|
||||
title: title,
|
||||
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 confirm = UIAlertAction(title: yesText, style: .default) { _ in
|
||||
confirmed()
|
||||
}
|
||||
let cancel = UIAlertAction(title: noText, style: .cancel)
|
||||
let cancel = UIAlertAction(title: noText, style: .cancel) { _ in
|
||||
dismissed?()
|
||||
}
|
||||
alert.addAction(confirm)
|
||||
alert.addAction(cancel)
|
||||
self.present(alert, animated: true)
|
||||
@ -320,11 +553,12 @@ class TableView: UITableViewController {
|
||||
// MARK: Starting downloads
|
||||
|
||||
private func downloadClassifier() {
|
||||
let hud = JGProgressHUD(style: .dark)
|
||||
hud.vibrancyEnabled = true
|
||||
let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light
|
||||
let hud = JGProgressHUD(style: style)
|
||||
//hud.vibrancyEnabled = true
|
||||
hud.indicatorView = JGProgressHUDPieIndicatorView()
|
||||
hud.detailTextLabel.text = "0 % complete"
|
||||
hud.textLabel.text = "Downloading classifier"
|
||||
hud.textLabel.text = "Downloading image classifier"
|
||||
hud.show(in: self.view)
|
||||
|
||||
app.database.downloadClassifier(progress: { progress, received, total in
|
||||
@ -332,7 +566,7 @@ class TableView: UITableViewController {
|
||||
hud.progress = progress
|
||||
let t = ByteCountFormatter.string(fromByteCount: total, countStyle: .file)
|
||||
let r = ByteCountFormatter.string(fromByteCount: received, countStyle: .file)
|
||||
hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % complete (\(r) / \(t))"
|
||||
hud.detailTextLabel.text = String(format: "%.0f", progress * 100) + " % (\(r) / \(t))"
|
||||
}
|
||||
}) { success in
|
||||
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() {
|
||||
let hud = JGProgressHUD(style: .dark)
|
||||
hud.vibrancyEnabled = true
|
||||
let style: JGProgressHUDStyle = traitCollection.userInterfaceStyle == .dark ? .dark : .light
|
||||
let hud = JGProgressHUD(style: style)
|
||||
//hud.vibrancyEnabled = true
|
||||
hud.indicatorView = JGProgressHUDPieIndicatorView()
|
||||
hud.detailTextLabel.text = "0 % complete"
|
||||
hud.textLabel.text = "Downloading cap images"
|
||||
hud.show(in: self.view)
|
||||
|
||||
app.database.downloadMainCapImages { (done, total) in
|
||||
app.database.downloadMainCapImages { done, total in
|
||||
let progress = Float(done) / Float(total)
|
||||
let percent = Int((progress * 100).rounded())
|
||||
hud.detailTextLabel.text = "\(percent) % complete (\(done) / \(total))"
|
||||
hud.detailTextLabel.text = "\(percent) % (\(done) / \(total))"
|
||||
hud.progress = progress
|
||||
|
||||
if done >= total {
|
||||
hud.dismiss(afterDelay: 1.0)
|
||||
self.downloadNewestClassifierIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -412,7 +611,6 @@ class TableView: UITableViewController {
|
||||
|
||||
private func classify(image: UIImage) {
|
||||
guard let classifier = self.classifier else {
|
||||
|
||||
return
|
||||
}
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
@ -428,6 +626,28 @@ class TableView: UITableViewController {
|
||||
self.sortType = .match
|
||||
self.sortAscending = false
|
||||
self.showAllCapsAndScrollToTop()
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
app.database.update(recognizedCaps: Set(matches.keys))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func classifyDummyImage() {
|
||||
guard let classifier = self.classifier else {
|
||||
return
|
||||
}
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
classifier.recognize(image: UIImage(named: "launch")!) { matches in
|
||||
guard let matches = matches else {
|
||||
self.log("Failed to classify dummy image")
|
||||
self.matches = nil
|
||||
return
|
||||
}
|
||||
self.log("Dummy classification finished")
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
app.database.update(recognizedCaps: Set(matches.keys))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -439,8 +659,10 @@ class TableView: UITableViewController {
|
||||
self.log("Failed to download classifier")
|
||||
return
|
||||
}
|
||||
loadClassifier()
|
||||
self.log("Classifier was downloaded.")
|
||||
guard let image = accessory!.currentImage else {
|
||||
classifyDummyImage()
|
||||
return
|
||||
}
|
||||
classify(image: image)
|
||||
@ -530,7 +752,7 @@ class TableView: UITableViewController {
|
||||
Scroll the table view to the top
|
||||
*/
|
||||
private func tableViewScrollToTop() {
|
||||
guard caps.count > 0 else { return }
|
||||
guard shownCaps.count > 0 else { return }
|
||||
let path = IndexPath(row: 0, section: 0)
|
||||
DispatchQueue.main.async {
|
||||
self.tableView.scrollToRow(at: path, at: .top, animated: true)
|
||||
@ -550,10 +772,6 @@ extension TableView: SortControllerDelegate {
|
||||
}
|
||||
showAllCapsAndScrollToTop()
|
||||
}
|
||||
|
||||
var sortControllerShouldIncludeMatchOption: Bool {
|
||||
matches != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CameraControllerDelegate
|
||||
@ -562,7 +780,7 @@ extension TableView: CameraControllerDelegate {
|
||||
|
||||
func didCapture(image: UIImage) {
|
||||
guard let cap = capToAddImageTo else {
|
||||
accessory!.showImageView(with: image)
|
||||
accessory!.showImageView(with: image, isUnlocked: isUnlocked)
|
||||
classify(image: image)
|
||||
return
|
||||
}
|
||||
@ -587,9 +805,29 @@ extension TableView {
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "cap") as! CapCell
|
||||
let cap = shownCaps[indexPath.row]
|
||||
cell.set(cap: cap, match: match(for: cap.id))
|
||||
|
||||
configure(cell: cell, for: cap)
|
||||
return cell
|
||||
}
|
||||
|
||||
private func configure(cell: CapCell, for cap: Cap) {
|
||||
let matchText = cap.matchLabelText(match: match(for: cap.id), appIsUnlocked: self.isUnlocked)
|
||||
let countText = cap.countLabelText(appIsUnlocked: self.isUnlocked)
|
||||
|
||||
cell.id = cap.id
|
||||
cell.set(name: cap.name)
|
||||
cell.set(matchLabel: matchText)
|
||||
cell.set(countLabel: countText)
|
||||
|
||||
if let image = 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 {
|
||||
return 1
|
||||
@ -608,11 +846,19 @@ extension TableView {
|
||||
self.capToAddImageTo = cap
|
||||
showCameraView()
|
||||
}
|
||||
|
||||
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
// Prevent unauthorized users from selecting caps
|
||||
isUnlocked ? indexPath : nil
|
||||
}
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
defer {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
}
|
||||
// Prevent unauthorized users from making changes
|
||||
guard isUnlocked else {
|
||||
return
|
||||
}
|
||||
let cap = shownCaps[indexPath.row]
|
||||
guard let image = accessory?.capImage.image else {
|
||||
self.giveFeedback(.medium)
|
||||
@ -630,11 +876,15 @@ extension TableView {
|
||||
}
|
||||
|
||||
private func giveFeedback(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {
|
||||
let generator = UIImpactFeedbackGenerator(style: style)
|
||||
generator.impactOccurred()
|
||||
UIImpactFeedbackGenerator(style: style).impactOccurred()
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
// Prevent unauthorized users from making changes
|
||||
guard isUnlocked else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let cap = shownCaps[indexPath.row]
|
||||
|
||||
let rename = UIContextualAction(style: .normal, title: "Rename\ncap") { (_, _, success) in
|
||||
@ -660,27 +910,24 @@ extension TableView {
|
||||
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let cap = shownCaps[indexPath.row]
|
||||
|
||||
let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in
|
||||
self.giveFeedback(.medium)
|
||||
success(true)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
app.database.download.imageCount(for: cap.id) { count in
|
||||
guard let count = count else {
|
||||
return
|
||||
}
|
||||
guard app.database.update(count: count, for: cap.id) else {
|
||||
return
|
||||
}
|
||||
// Delegate call will update the cell
|
||||
var actions = [UIContextualAction]()
|
||||
// Prevent unauthorized users from making changes
|
||||
if isUnlocked {
|
||||
let count = UIContextualAction(style: .normal, title: "Update\ncount") { (_, _, success) in
|
||||
self.giveFeedback(.medium)
|
||||
success(true)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
app.database.downloadImageCount(for: cap.id)
|
||||
}
|
||||
}
|
||||
count.backgroundColor = .orange
|
||||
actions.append(count)
|
||||
}
|
||||
count.backgroundColor = .orange
|
||||
|
||||
let similar = UIContextualAction(style: .normal, title: "Similar\ncaps") { (_, _, success) in
|
||||
self.giveFeedback(.medium)
|
||||
self.accessory?.hideImageView()
|
||||
guard let image = cap.image else {
|
||||
guard let image = app.storage.image(for: cap.id) else {
|
||||
success(false)
|
||||
return
|
||||
}
|
||||
@ -688,8 +935,9 @@ extension TableView {
|
||||
success(true)
|
||||
}
|
||||
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 {
|
||||
|
||||
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) {
|
||||
caps.append(cap)
|
||||
updateNavigationItemTitleView()
|
||||
@ -737,31 +966,46 @@ extension TableView: DatabaseDelegate {
|
||||
updateShownCaps(newList, insertedId: cap.id)
|
||||
}
|
||||
|
||||
private func updateShownCaps(_ newList: [Cap], insertedId id: Int) {
|
||||
guard shownCaps.count == newList.count - 1 else {
|
||||
log("Cap list refresh mismatch: was \(shownCaps.count), is \(newList.count)")
|
||||
show(sortedCaps: newList)
|
||||
func database(didChangeCap id: Int) {
|
||||
updateNavigationItemTitleView()
|
||||
guard let cap = app.database.cap(for: id) else {
|
||||
log("Changed cap \(id) not found in database")
|
||||
return
|
||||
}
|
||||
guard let index = newList.firstIndex(where: { $0.id == id}) else {
|
||||
log("Cap list refresh without new cap \(id)")
|
||||
show(sortedCaps: newList)
|
||||
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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
configure(cell: cell, for: cap)
|
||||
}
|
||||
|
||||
func databaseRequiresFullRefresh() {
|
||||
updateNavigationItemTitleView()
|
||||
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()
|
||||
}
|
||||
|
||||
private func visibleCell(for cap: Int) -> CapCell? {
|
||||
tableView.visibleCells
|
||||
.map { $0 as! CapCell }
|
||||
.first { $0.id == cap }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Protocol CapSearchDelegate
|
||||
|
Loading…
Reference in New Issue
Block a user