Add grid, camera focus

This commit is contained in:
Christoph Hagen 2022-06-21 19:38:51 +02:00
parent 2b3ab859fc
commit 4b91ebcd02
21 changed files with 895 additions and 104 deletions

View File

@ -8,6 +8,13 @@
/* Begin PBXBuildFile section */
88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBE72D285495B100D1573B /* FancyTextField.swift */; };
E20D104A285612AF0019BD91 /* ImageGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D1049285612AF0019BD91 /* ImageGrid.swift */; };
E20D104C28563DB10019BD91 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D104B28563DB10019BD91 /* ImageCache.swift */; };
E20D104E28574C7C0019BD91 /* CachedCapImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D104D28574C7C0019BD91 /* CachedCapImage.swift */; };
E20D105028574E190019BD91 /* CapImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D104F28574E190019BD91 /* CapImage.swift */; };
E20D105228589AAC0019BD91 /* FileManager+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */; };
E20D10562858CDFA0019BD91 /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D10552858CDFA0019BD91 /* View+Extensions.swift */; };
E20D10582858CEBD0019BD91 /* IconButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20D10572858CEBD0019BD91 /* IconButton.swift */; };
E25AAC7C283D855D006E9E7F /* CapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7B283D855D006E9E7F /* CapsApp.swift */; };
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7D283D855D006E9E7F /* ContentView.swift */; };
E25AAC80283D855F006E9E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E25AAC7F283D855F006E9E7F /* Assets.xcassets */; };
@ -19,7 +26,6 @@
E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC93283D88A4006E9E7F /* Cap.swift */; };
E25AAC96283E14DF006E9E7F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC95283E14DF006E9E7F /* Database.swift */; };
E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC9A283E3395006E9E7F /* CapRowView.swift */; };
E27E15E1283E418600F6804A /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = E27E15E0283E418600F6804A /* CachedAsyncImage */; };
E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2EA00C2283E672A00F7B269 /* SFSafeSymbols */; };
E2EA00C5283EA72000F7B269 /* SortCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C4283EA72000F7B269 /* SortCriteria.swift */; };
E2EA00C7283EAA0100F7B269 /* SortSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */; };
@ -45,6 +51,13 @@
/* Begin PBXFileReference section */
88DBE72D285495B100D1573B /* FancyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyTextField.swift; sourceTree = "<group>"; };
E20D1049285612AF0019BD91 /* ImageGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrid.swift; sourceTree = "<group>"; };
E20D104B28563DB10019BD91 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
E20D104D28574C7C0019BD91 /* CachedCapImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedCapImage.swift; sourceTree = "<group>"; };
E20D104F28574E190019BD91 /* CapImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapImage.swift; sourceTree = "<group>"; };
E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Extensions.swift"; sourceTree = "<group>"; };
E20D10552858CDFA0019BD91 /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
E20D10572858CEBD0019BD91 /* IconButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconButton.swift; sourceTree = "<group>"; };
E25AAC78283D855D006E9E7F /* Caps.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Caps.app; sourceTree = BUILT_PRODUCTS_DIR; };
E25AAC7B283D855D006E9E7F /* CapsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapsApp.swift; sourceTree = "<group>"; };
E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@ -85,7 +98,6 @@
files = (
E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */,
E2EA00CA283EACB200F7B269 /* BottomSheet in Frameworks */,
E27E15E1283E418600F6804A /* CachedAsyncImage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -114,11 +126,11 @@
E25AAC7B283D855D006E9E7F /* CapsApp.swift */,
E25AAC7D283D855D006E9E7F /* ContentView.swift */,
E2EA00CF283EDD2C00F7B269 /* Camera */,
E25AAC97283E337C006E9E7F /* Views */,
E25AAC89283D8666006E9E7F /* Data */,
E25AAC7F283D855F006E9E7F /* Assets.xcassets */,
E25AAC97283E337C006E9E7F /* Views */,
E25AAC8E283D870F006E9E7F /* Extensions */,
E25AAC8C283D86CF006E9E7F /* Logger.swift */,
E25AAC7F283D855F006E9E7F /* Assets.xcassets */,
E25AAC81283D855F006E9E7F /* Preview Content */,
);
path = Caps;
@ -135,11 +147,14 @@
E25AAC89283D8666006E9E7F /* Data */ = {
isa = PBXGroup;
children = (
E25AAC8A283D868D006E9E7F /* Classifier.swift */,
E2EA00C4283EA72000F7B269 /* SortCriteria.swift */,
E25AAC93283D88A4006E9E7F /* Cap.swift */,
E25AAC91283D8808006E9E7F /* CapData.swift */,
E25AAC8A283D868D006E9E7F /* Classifier.swift */,
E25AAC95283E14DF006E9E7F /* Database.swift */,
E20D104B28563DB10019BD91 /* ImageCache.swift */,
E20D1049285612AF0019BD91 /* ImageGrid.swift */,
E2EA00C4283EA72000F7B269 /* SortCriteria.swift */,
E20D104F28574E190019BD91 /* CapImage.swift */,
);
path = Data;
sourceTree = "<group>";
@ -152,6 +167,8 @@
E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */,
E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */,
E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */,
E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */,
E20D10552858CDFA0019BD91 /* View+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -159,15 +176,17 @@
E25AAC97283E337C006E9E7F /* Views */ = {
isa = PBXGroup;
children = (
E25AAC9A283E3395006E9E7F /* CapRowView.swift */,
E2EA00E2283F662800F7B269 /* GridView.swift */,
E2EA00E0283F658E00F7B269 /* SettingsView.swift */,
E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */,
E2EA00CD283EBEB600F7B269 /* SearchField.swift */,
88DBE72D285495B100D1573B /* FancyTextField.swift */,
E20D104D28574C7C0019BD91 /* CachedCapImage.swift */,
E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */,
E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */,
E25AAC9A283E3395006E9E7F /* CapRowView.swift */,
88DBE72D285495B100D1573B /* FancyTextField.swift */,
E2EA00E2283F662800F7B269 /* GridView.swift */,
E20D10572858CEBD0019BD91 /* IconButton.swift */,
E2EA00CD283EBEB600F7B269 /* SearchField.swift */,
E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */,
E2EA00E0283F658E00F7B269 /* SettingsView.swift */,
E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */,
E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */,
);
path = Views;
sourceTree = "<group>";
@ -175,13 +194,13 @@
E2EA00CF283EDD2C00F7B269 /* Camera */ = {
isa = PBXGroup;
children = (
E2EA00D2283EDDF700F7B269 /* CameraError.swift */,
E2EA00D0283EDD6300F7B269 /* CameraManager.swift */,
E2EA00D8283F5BB900F7B269 /* CameraView.swift */,
E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */,
E2EA00DC283F5C6A00F7B269 /* FrameView.swift */,
E2EA00D4283EDFA200F7B269 /* FrameManager.swift */,
E2EA00D2283EDDF700F7B269 /* CameraError.swift */,
E2EA00DE283F5CA000F7B269 /* ErrorView.swift */,
E2EA00D4283EDFA200F7B269 /* FrameManager.swift */,
E2EA00DC283F5C6A00F7B269 /* FrameView.swift */,
);
path = Camera;
sourceTree = "<group>";
@ -203,7 +222,6 @@
);
name = Caps;
packageProductDependencies = (
E27E15E0283E418600F6804A /* CachedAsyncImage */,
E2EA00C2283E672A00F7B269 /* SFSafeSymbols */,
E2EA00C9283EACB200F7B269 /* BottomSheet */,
);
@ -236,7 +254,6 @@
);
mainGroup = E25AAC6F283D855D006E9E7F;
packageReferences = (
E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */,
);
@ -266,14 +283,18 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E20D10582858CEBD0019BD91 /* IconButton.swift in Sources */,
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */,
E2EA00F328438E6B00F7B269 /* CapNameEntryView.swift in Sources */,
E25AAC8B283D868D006E9E7F /* Classifier.swift in Sources */,
E20D10562858CDFA0019BD91 /* View+Extensions.swift in Sources */,
E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */,
E2EA00D9283F5BB900F7B269 /* CameraView.swift in Sources */,
E2EA00E3283F662800F7B269 /* GridView.swift in Sources */,
E2EA00EB284109CC00F7B269 /* CGImage+Extensions.swift in Sources */,
E20D104E28574C7C0019BD91 /* CachedCapImage.swift in Sources */,
E2EA00DF283F5CA000F7B269 /* ErrorView.swift in Sources */,
E20D104A285612AF0019BD91 /* ImageGrid.swift in Sources */,
E2EA00D5283EDFA200F7B269 /* FrameManager.swift in Sources */,
E25AAC90283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift in Sources */,
E2EA00CE283EBEB600F7B269 /* SearchField.swift in Sources */,
@ -289,10 +310,13 @@
E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */,
E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */,
E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */,
E20D104C28563DB10019BD91 /* ImageCache.swift in Sources */,
E25AAC92283D8808006E9E7F /* CapData.swift in Sources */,
E25AAC96283E14DF006E9E7F /* Database.swift in Sources */,
E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */,
E20D105028574E190019BD91 /* CapImage.swift in Sources */,
E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */,
E20D105228589AAC0019BD91 /* FileManager+Extensions.swift in Sources */,
E2EA00E5283F69DF00F7B269 /* SettingsStatisticRow.swift in Sources */,
E2EA00E1283F658E00F7B269 /* SettingsView.swift in Sources */,
);
@ -427,6 +451,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Export cap grids to Photos";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -458,6 +483,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Export cap grids to Photos";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -501,14 +527,6 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/lorenzofiamingo/swiftui-cached-async-image";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
@ -528,11 +546,6 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
E27E15E0283E418600F6804A /* CachedAsyncImage */ = {
isa = XCSwiftPackageProductDependency;
package = E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
productName = CachedAsyncImage;
};
E2EA00C2283E672A00F7B269 /* SFSafeSymbols */ = {
isa = XCSwiftPackageProductDependency;
package = E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;

View File

@ -17,15 +17,6 @@
"revision" : "c8c33d947d8a1c883aa19fd24e14fd738b06e369",
"version" : "3.3.2"
}
},
{
"identity" : "swiftui-cached-async-image",
"kind" : "remoteSourceControl",
"location" : "https://github.com/lorenzofiamingo/swiftui-cached-async-image",
"state" : {
"revision" : "eeb1565d780d1b75d045e21b5ca2a1e3650b0fc2",
"version" : "2.1.0"
}
}
],
"version" : 2

View File

@ -20,6 +20,8 @@ class CameraManager: ObservableObject {
private let photoOutput = AVCapturePhotoOutput()
private var status = Status.unconfigured
private var camera: AVCaptureDevice?
private init() {
configure()
}
@ -67,11 +69,11 @@ class CameraManager: ObservableObject {
session.commitConfiguration()
}
let device = AVCaptureDevice.default(
self.camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back)
guard let camera = device else {
guard let camera = camera else {
set(error: .cameraUnavailable)
status = .failed
return
@ -169,4 +171,28 @@ class CameraManager: ObservableObject {
self.photoOutput.capturePhoto(with: photoSettings, delegate: delegate)
}
}
// MARK: Focus
func continuouslyFocusOnMiddle() {
guard let device = camera else {
return
}
do {
try device.lockForConfiguration()
if device.isFocusPointOfInterestSupported {
device.focusPointOfInterest = CGPoint(x: 0.5, y: 0.5)
device.focusMode = .continuousAutoFocus
}
print("Enabled continuous autofocus")
device.unlockForConfiguration()
} catch {
self.error("Could not lock device for configuration: \(error)")
}
}
}
extension CameraManager: Logger {
}

View File

@ -87,7 +87,8 @@ struct CameraView: View {
.overlay(RoundedRectangle(cornerRadius: circleSize/2)
.stroke(lineWidth: circleStrength)
.foregroundColor(circleColor))
.onTapGesture(perform: didTapCircle)
.background(Color(white: 1, opacity: 0.01))
Spacer()
}
Spacer()
@ -116,6 +117,10 @@ struct CameraView: View {
private func capture() {
model.captureImage()
}
private func didTapCircle() {
model.continuouslyFocusOnMiddle()
}
}
struct CameraView_Previews: PreviewProvider {

View File

@ -54,4 +54,8 @@ class ContentViewModel: ObservableObject {
func captureImage() {
cameraManager.capturePhoto(delegate: frameManager)
}
func continuouslyFocusOnMiddle() {
cameraManager.continuouslyFocusOnMiddle()
}
}

View File

@ -1,11 +1,15 @@
import SwiftUI
#warning("TODO: Add colors")
#warning("TODO: Grid view")
#warning("TODO: Rearrange caps in grid view")
#warning("TODO: Change main image")
#warning("TODO: Load/save grid images")
@main
struct CapsApp: App {
static let thumbnailImageSize: CGFloat = 60
let database = Database(server: URL(string: "https://christophhagen.de/caps")!)
var body: some Scene {

View File

@ -245,7 +245,7 @@ struct ContentView: View {
SettingsView(isPresented: $showSettingsSheet)
}
.sheet(isPresented: $showGridView) {
GridView()
GridView(isPresented: $showGridView)
}.alert(isPresented: $showNewClassifierAlert) {
Alert(title: Text("New classifier available"),
message: Text("Classifier \(database.serverClassifierVersion) is available. You have version \(database.classifierVersion). Do you want to download it now?"),

View File

@ -27,6 +27,10 @@ struct Cap {
String(format: "images/%04d/%04d-%02d.jpg", id, id, mainImage)
}
var image: CapImage {
.init(cap: id, version: mainImage)
}
/**
Create a new cap.
- Parameter id: The unique id of the cap

8
Caps/Data/CapImage.swift Normal file
View File

@ -0,0 +1,8 @@
import Foundation
struct CapImage: Codable, Equatable, Hashable {
let cap: Int
let version: Int
}

View File

@ -5,52 +5,23 @@ import CryptoKit
final class Database: ObservableObject {
static let imageCacheMemory = 10_000_000
static let imageCacheStorage = 200_000_000
private let imageCompressionQuality: CGFloat = 0.3
private static var documentDirectory: URL {
try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
@AppStorage("classifier")
private(set) var classifierVersion = 0
private var fm: FileManager {
.default
}
@AppStorage("serverClassifier")
private(set) var serverClassifierVersion = 0
private var localDbUrl: URL {
Database.documentDirectory.appendingPathComponent("db.json")
}
private var localClassifierUrl: URL {
Database.documentDirectory.appendingPathComponent("classifier.mlmodel")
}
private var imageUploadFolderUrl: URL {
Database.documentDirectory.appendingPathComponent("uploads")
}
private var serverDbUrl: URL {
serverUrl.appendingPathComponent("caps.json")
}
private var serverClassifierUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
let images: ImageCache
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
let serverUrl: URL
let folderUrl: URL
@AppStorage("authKey")
private var serverAuthenticationKey: String = ""
@ -108,22 +79,68 @@ final class Database: ObservableObject {
*/
private var nextSaveTime: Date?
let imageCache: URLCache
@Published
var isUploading = false
init(server: URL) {
init(server: URL, folder: URL = FileManager.default.documentDirectory) {
self.serverUrl = server
self.folderUrl = folder
self.caps = [:]
let cacheDirectory = Database.documentDirectory.appendingPathComponent("images")
self.imageCache = URLCache(
memoryCapacity: Database.imageCacheMemory,
diskCapacity: Database.imageCacheStorage,
directory: cacheDirectory)
let imageFolder = folder.appendingPathComponent("images")
self.images = try! ImageCache(
folder: imageFolder,
server: server,
thumbnailSize: CapsApp.thumbnailImageSize)
ensureFolderExistence(gridStorageFolder)
loadCaps()
}
@Published
var isUploading = false
func mainImage(for cap: Int) -> Int {
caps[cap]?.mainImage ?? 0
}
// MARK: URLs
private var fm: FileManager {
.default
}
private var localDbUrl: URL {
folderUrl.appendingPathComponent("db.json")
}
private var localClassifierUrl: URL {
folderUrl.appendingPathComponent("classifier.mlmodel")
}
private var imageUploadFolderUrl: URL {
folderUrl.appendingPathComponent("uploads")
}
private var serverDbUrl: URL {
serverUrl.appendingPathComponent("caps.json")
}
private var serverClassifierUrl: URL {
serverUrl.appendingPathComponent("classifier.mlmodel")
}
private var serverClassifierVersionUrl: URL {
serverUrl.appendingPathComponent("classifier.version")
}
private var gridStorageFolder: URL {
folderUrl.appendingPathComponent("grids")
}
func mainImageUrl(for cap: Int) -> URL? {
guard let path = caps[cap]?.mainImagePath else {
return nil
}
return serverUrl.appendingPathComponent(path)
}
// MARK: Disk storage
@ -185,6 +202,7 @@ final class Database: ObservableObject {
print("Database saved")
}
@discardableResult
private func ensureFolderExistence(_ url: URL) -> Bool {
guard !fm.fileExists(atPath: url.path) else {
return true
@ -395,6 +413,14 @@ final class Database: ObservableObject {
DispatchQueue.main.async {
self.isUploading = true
}
defer {
DispatchQueue.main.async {
self.isUploading = false
}
}
guard !changedCaps.isEmpty || pendingImageUploadCount > 0 else {
return
}
log("Starting uploads")
let uploaded = await uploadAllChangedCaps()
DispatchQueue.main.async {
@ -402,9 +428,6 @@ final class Database: ObservableObject {
}
await uploadAllImages()
log("Uploads finished")
DispatchQueue.main.async {
self.isUploading = false
}
}
/**
@ -595,6 +618,82 @@ final class Database: ObservableObject {
return Classifier(model: model)
}
// MARK: Grid
var availableGrids: [String] {
do {
return try fm.contentsOfDirectory(at: gridStorageFolder, includingPropertiesForKeys: nil)
.filter { $0.pathExtension == "caps" }
.map { $0.deletingPathExtension().lastPathComponent }
} catch {
print("Failed to load available grids: \(error)")
return []
}
}
private func gridFileUrl(_ grid: String) -> URL {
gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("caps")
}
private func gridImageUrl(_ grid: String) -> URL {
gridStorageFolder.appendingPathComponent(grid).appendingPathExtension("jpg")
}
func load(grid: String) -> ImageGrid? {
let url = gridFileUrl(grid)
guard fm.fileExists(atPath: url.path) else {
return nil
}
do {
let data = try Data(contentsOf: url)
var loaded = try decoder.decode(ImageGrid.self, from: data)
// Add all missing caps to the end of the image
let newCaps = Set(caps.keys).subtracting(loaded.capPlacements).sorted()
loaded.capPlacements += newCaps
print("Grid \(grid) loaded (\(newCaps.count) new caps)")
return loaded
} catch {
print("Failed to load grid \(grid): \(error)")
return nil
}
}
@discardableResult
func save(_ grid: ImageGrid, named name: String) -> Bool {
let url = gridFileUrl(name)
do {
let data = try encoder.encode(grid)
try data.write(to: url)
print("Grid \(name) saved")
return true
} catch {
print("Failed to save grid \(name): \(error)")
return false
}
}
// MARK: Grid images
func load(gridImage: String) -> UIImage? {
let url = gridImageUrl(gridImage)
return UIImage(at: url)
}
@discardableResult
func save(gridImage: UIImage, for grid: String) -> Bool {
guard let data = gridImage.jpegData(compressionQuality: 0.9) else {
return false
}
let url = gridImageUrl(grid)
do {
try data.write(to: url)
return true
} catch {
print("Failed to save grid image \(grid): \(error)")
return false
}
}
// MARK: Statistics
var numberOfCaps: Int {
@ -609,19 +708,13 @@ final class Database: ObservableObject {
Float(numberOfImages) / Float(numberOfCaps)
}
@AppStorage("classifier")
private(set) var classifierVersion = 0
@AppStorage("serverClassifier")
private(set) var serverClassifierVersion = 0
var classifierClassCount: Int {
let version = classifierVersion
return caps.values.filter { $0.classifiable(by: version) }.count
}
var imageCacheSize: Int {
imageCache.currentDiskUsage
fm.directorySize(images.folder)
}
var databaseSize: Int {
@ -648,4 +741,13 @@ extension Database {
db.image = UIImage(systemSymbol: .photo)
return db
}
static var largeMock: Database {
let db = Database(server: URL(string: "https://christophhagen.de/caps")!)
db.caps = (1..<500)
.map { Cap(id: $0, name: "Cap \($0)", classifier: nil)}
.reduce(into: [:]) { $0[$1.id] = $1 }
db.image = UIImage(systemSymbol: .photo)
return db
}
}

209
Caps/Data/ImageCache.swift Normal file
View File

@ -0,0 +1,209 @@
import Foundation
import UIKit
final class ImageCache {
let folder: URL
let server: URL
let thumbnailSize: CGFloat
private let fm: FileManager = .default
private let session: URLSession = .shared
private let thumbnailQuality: CGFloat = 0.7
init(folder: URL, server: URL, thumbnailSize: CGFloat) throws {
self.folder = folder
self.server = server
self.thumbnailSize = thumbnailSize * UIScreen.main.scale
if !fm.fileExists(atPath: folder.path) {
try fm.createDirectory(at: folder, withIntermediateDirectories: true)
}
}
private func localImageUrl(_ image: CapImage) -> URL {
folder.appendingPathComponent(String(format: "%04d-%02d.jpg", image.cap, image.cap, image.version))
}
private func remoteImageUrl(_ image: CapImage) -> URL {
server.appendingPathComponent(String(format: "images/%04d/%04d-%02d.jpg", image.cap, image.cap, image.version))
}
func image(_ image: CapImage, completion: @escaping (UIImage?) -> ()) {
Task {
let image = await self.image(image)
completion(image)
}
}
func image(_ image: CapImage, download: Bool = true) async -> UIImage? {
if let localUrl = existingLocalImageUrl(image) {
return UIImage(at: localUrl)
}
guard download else {
return nil
}
guard let downloadedImageUrl = await loadRemoteImage(image) else {
return nil
}
guard saveImage(image, at: downloadedImageUrl) else {
return UIImage(at: downloadedImageUrl)
}
let localUrl = localImageUrl(image)
return UIImage(at: localUrl)
}
func cachedImage(_ image: CapImage) -> UIImage? {
guard let localUrl = existingLocalImageUrl(image) else {
return nil
}
return UIImage(at: localUrl)
}
@discardableResult
func removeImage(_ image: CapImage) -> Bool {
let localUrl = localImageUrl(image)
return removePossibleFile(localUrl)
}
@discardableResult
func refreshImage(_ image: CapImage) async -> Bool {
guard let downloadedImageUrl = await loadRemoteImage(image) else {
return false
}
return saveImage(image, at: downloadedImageUrl)
}
private func loadRemoteImage(_ image: CapImage) async -> URL? {
let remoteURL = remoteImageUrl(image)
return await loadRemoteImage(at: remoteURL)
}
private func loadRemoteImage(at url: URL) async -> URL? {
let tempUrl: URL
let response: URLResponse
do {
(tempUrl, response) = try await session.download(from: url)
} catch {
print("Failed to download image \(url.lastPathComponent): \(error)")
return nil
}
guard let httpResponse = response as? HTTPURLResponse else {
print("Failed to download image \(url.lastPathComponent): Not a HTTP response: \(response)")
return nil
}
guard httpResponse.statusCode == 200 else {
print("Failed to download image \(url.path): Response \(httpResponse.statusCode)")
return nil
}
return tempUrl
}
private func saveImage(_ image: CapImage, at tempUrl: URL) -> Bool {
let localUrl = localImageUrl(image)
guard removePossibleFile(localUrl) else {
return false
}
do {
try fm.moveItem(at: tempUrl, to: localUrl)
return true
} catch {
print("failed to save image \(localUrl.lastPathComponent): \(error)")
return false
}
}
private func existingLocalImageUrl(_ image: CapImage) -> URL? {
let localFile = localImageUrl(image)
guard exists(localFile) else {
return nil
}
return localFile
}
private func exists(_ url: URL) -> Bool {
fm.fileExists(atPath: url.path)
}
@discardableResult
private func removePossibleFile(_ file: URL) -> Bool {
guard exists(file) else {
return true
}
return remove(file)
}
@discardableResult
private func remove(_ url: URL) -> Bool {
do {
try fm.removeItem(at: url)
return true
} catch {
print("Failed to remove \(url.lastPathComponent): \(error)")
return false
}
}
// MARK: Thumbnails
private func localThumbnailUrl(cap: Int) -> URL {
folder.appendingPathComponent(String(format: "%04d.jpg", cap))
}
func thumbnail(for image: CapImage, download: Bool = true) async -> UIImage? {
let localUrl = localThumbnailUrl(cap: image.cap)
if exists(localUrl) {
return UIImage(at: localUrl)
}
guard let mainImage = await self.image(image, download: download) else {
return nil
}
let thumbnail = await createThumbnail(mainImage)
save(thumbnail: thumbnail, for: image.cap)
return thumbnail
}
func cachedThumbnail(for image: CapImage) -> UIImage? {
let localUrl = localThumbnailUrl(cap: image.cap)
guard exists(localUrl) else {
return nil
}
return UIImage(at: localUrl)
}
@discardableResult
func createThumbnail(for image: CapImage, download: Bool = false) async -> Bool {
await thumbnail(for: image, download: download) != nil
}
private func createThumbnail(_ image: UIImage) async -> UIImage {
let size = thumbnailSize
return await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .background).async {
let small = image.resize(to: CGSize(width: size, height: size))
continuation.resume(returning: small)
}
}
}
@discardableResult
private func save(thumbnail: UIImage, for cap: Int) -> Bool {
guard let data = thumbnail.jpegData(compressionQuality: thumbnailQuality) else {
print("Failed to get thumbnail JPEG data")
return false
}
let localUrl = localThumbnailUrl(cap: cap)
do {
try data.write(to: localUrl)
return true
} catch {
print("Failed to save thumbnail \(cap): \(error)")
return false
}
}
}

82
Caps/Data/ImageGrid.swift Normal file
View File

@ -0,0 +1,82 @@
import Foundation
struct ImageGrid: Codable {
struct Position {
let x: Int
let y: Int
}
struct Item: Identifiable {
let id: Int
let cap: Int
}
let columns: Int
/**
The place of each cap.
The index is the position in the image,
where `x = index % columns` and `y = index / columns`
*/
var capPlacements: [Int]
/// All caps currently present in the image
var caps: Set<Int> {
Set(capPlacements)
}
var items: [Item] {
capPlacements.enumerated().map {
.init(id: $0, cap: $1)
}
}
var capCount: Int {
capPlacements.count
}
func index(of position: Position) -> Int? {
return index(x: position.x, y: position.y)
}
func index(x: Int, y: Int) -> Int? {
let index = y * columns + y
guard index < capCount else {
return nil
}
return capPlacements[index]
}
mutating func switchCaps(at x: Int, _ y: Int, with otherX: Int, _ otherY: Int) {
guard let other = index(x: x, y: y), let index = index(x: otherX, y: otherY) else {
return
}
switchCaps(at: index, with: other)
}
mutating func switchCaps(at position: Position, with other: Position) {
guard let other = index(of: other), let index = index(of: position) else {
return
}
switchCaps(at: index, with: other)
}
mutating func switchCaps(at index: Int, with other: Int) {
guard index < capCount, other < capCount else {
return
}
let temp = capPlacements[index]
capPlacements[index] = capPlacements[other]
capPlacements[other] = temp
}
static func mock(columns: Int, count: Int) -> ImageGrid {
.init(columns: columns, capPlacements: Array(0..<count))
}
}

View File

@ -0,0 +1,26 @@
import Foundation
extension FileManager {
var documentDirectory: URL {
try! url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
private func fileSizeEnumerator(at directory: URL) -> DirectoryEnumerator? {
enumerator(at: directory,
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey],
options: []) { (_, error) -> Bool in
print(error)
return false
}
}
func directorySize(_ directory: URL) -> Int {
return fileSizeEnumerator(at: directory)?
.compactMap { $0 as? URL }
.reduce(0) { $0 + $1.fileSize } ?? 0
}
}

View File

@ -147,3 +147,14 @@ private func saturate(_ component: UInt8) -> CGFloat {
}
extension CIImage: Logger { }
extension UIImage {
convenience init?(at url: URL) {
guard let data = try? Data(contentsOf: url) else {
return nil
}
self.init(data: data, scale: UIScreen.main.scale)
}
}

View File

@ -22,4 +22,14 @@ extension URL {
var creationDate: Date? {
return attributes?[.creationDate] as? Date
}
var fileSizeAlt: Int? {
do {
let val = try self.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
return val.totalFileAllocatedSize ?? val.fileAllocatedSize
} catch {
print(error)
return nil
}
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
extension View {
func snapshot() -> UIImage {
let controller = UIHostingController(rootView: self)
let view = controller.view
let targetSize = controller.view.intrinsicContentSize
view?.bounds = CGRect(origin: .zero, size: targetSize)
view?.backgroundColor = .clear
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { _ in
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
}
}
}

View File

@ -0,0 +1,79 @@
import SwiftUI
struct CachedCapImage<Content, T>: View where Content: View, T: Equatable {
@State private var phase: AsyncImagePhase
let id: T
let check: () -> UIImage?
let fetch: () async -> UIImage?
private let transaction: Transaction
private let content: (AsyncImagePhase) -> Content
var body: some View {
content(phase)
.task(id: id, load)
}
init<I, P>(_ id: T, _ image: CapImage, cache: ImageCache, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
self.init(id, image: image, cache: cache) { phase in
if let image = phase.image {
content(image)
} else {
placeholder()
}
}
}
init(_ id: T, image: CapImage, cache: ImageCache, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
self.init(id,
check: { cache.cachedImage(image) },
fetch: { await cache.image(image) },
transaction: transaction,
content: content)
}
init<I, P>(_ id: T, check: @escaping () -> UIImage?, fetch: @escaping () async -> UIImage?, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
self.init(id, check: check, fetch: fetch) { phase in
if let image = phase.image {
content(image)
} else {
placeholder()
}
}
}
init(_ id: T, check: @escaping () -> UIImage?, fetch: @escaping () async -> UIImage?, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncImagePhase) -> Content) {
self.id = id
self.check = check
self.fetch = fetch
self.transaction = transaction
self.content = content
self._phase = State(wrappedValue: .empty)
guard let image = check() else {
return
}
let wrapped = Image(uiImage: image)
self._phase = State(wrappedValue: .success(wrapped))
}
@Sendable
private func load() async {
guard let image = await fetch() else {
withAnimation(transaction.animation) {
phase = .empty
}
return
}
let wrapped = Image(uiImage: image)
withAnimation(transaction.animation) {
phase = .success(wrapped)
}
}
}

View File

@ -1,5 +1,4 @@
import SwiftUI
import CachedAsyncImage
struct CapRowView: View {
@ -56,7 +55,7 @@ struct CapRowView: View {
}
}//.padding(.vertical)
Spacer()
CachedAsyncImage(url: imageUrl, urlCache: database.imageCache) { image in
CachedCapImage(cap, cap.image, cache: database.images) { image in
image.resizable()
} placeholder: {
ProgressView()

View File

@ -1,13 +1,171 @@
import SwiftUI
import SFSafeSymbols
struct GridView: View {
@EnvironmentObject
var database: Database
@AppStorage("currentGridName")
private(set) var currentGridName = "default"
private var defaultImageGrid: ImageGrid {
.init(columns: 40, capPlacements: database.caps.keys.sorted())
}
var imageSize: CGFloat {
CapsApp.thumbnailImageSize
}
private let verticalInsetFactor: CGFloat = cos(.pi / 6)
private let minScale: CGFloat = 1.0
private let maxScale: CGFloat = 0.5
private let cancelButtonSize: CGFloat = 75
private let cancelIconSize: CGFloat = 25
@Binding
var isPresented: Bool
var image: ImageGrid
@State var scale: CGFloat = 1.0
@State var lastScaleValue: CGFloat = 1.0
init(isPresented: Binding<Bool>) {
self._isPresented = isPresented
self.image = .init(columns: 1, capPlacements: [])
if let image = database.load(grid: currentGridName) {
self.image = image
} else {
self.image = defaultImageGrid
currentGridName = "default"
}
}
var columnCount: Int {
image.columns
}
var capCount: Int {
image.capCount
}
var imageHeight: CGFloat {
(CGFloat(capCount) / CGFloat(columnCount)).rounded(.up) * verticalInsetFactor * imageSize + (1-verticalInsetFactor) * imageSize
}
var imageWidth: CGFloat {
imageSize * (CGFloat(columnCount) + 0.5)
}
var magnificationGesture: some Gesture {
MagnificationGesture()
.onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
self.scale = max(min(self.scale * delta, minScale), maxScale)
}
.onEnded { val in
// without this the next gesture will be broken
self.lastScaleValue = 1.0
}
}
var gridView: some View {
let gridItems = Array(repeating: GridItem(.fixed(imageSize), spacing: 0), count: columnCount)
return LazyVGrid(columns: gridItems, alignment: .leading, spacing: 0) {
ForEach(image.items) { item in
CachedCapImage(
item.id,
check: { cachedImage(item.cap) },
fetch: { await fetchImage(item.cap) },
content: { $0.resizable() },
placeholder: { ProgressView() })
.frame(width: imageSize,
height: imageSize)
.clipShape(Circle())
.offset(x: isEvenRow(item.id) ? 0 : imageSize / 2)
.frame(width: imageSize,
height: imageSize * verticalInsetFactor)
}
}
.frame(width: imageWidth)
}
var body: some View {
Text("Grid view")
ZStack {
ScrollView([.vertical, .horizontal]) {
gridView
.scaleEffect(scale)
.frame(
width: imageWidth * scale,
height: imageHeight * scale
)
}
.gesture(magnificationGesture)
.onDisappear {
database.save(image, named: currentGridName)
}
VStack {
Spacer()
HStack {
Spacer()
IconButton(action: saveScreenshot,
icon: .squareAndArrowDown,
iconSize: cancelIconSize,
buttonSize: cancelButtonSize)
.padding()
IconButton(action: dismiss,
icon: .xmark,
iconSize: cancelIconSize,
buttonSize: cancelButtonSize)
.padding()
}
}
}
}
func isEvenRow(_ idx: Int) -> Bool {
(idx / columnCount) % 2 == 0
}
private func cachedImage(_ cap: Int) -> UIImage? {
let image = CapImage(
cap: cap,
version: database
.mainImage(for: cap))
return database.images.cachedImage(image)
}
private func fetchImage(_ cap: Int) async -> UIImage? {
let image = CapImage(
cap: cap,
version: database
.mainImage(for: cap))
return await database.images.thumbnail(for: image)
}
private func dismiss() {
isPresented = false
}
private func saveScreenshot() {
let image = gridView.snapshot()
UIImageWriteToSavedPhotosAlbum(image, nil, nil, nil)
}
}
struct GridView_Previews: PreviewProvider {
static var previews: some View {
GridView()
GridView(isPresented: .constant(true))
.environmentObject(Database.largeMock)
}
}

View File

@ -0,0 +1,41 @@
import SwiftUI
import SFSafeSymbols
struct IconButton: View {
let action: () -> Void
let icon: SFSymbol
let iconSize: CGFloat
let buttonSize: CGFloat
private var padding: CGFloat {
(buttonSize - iconSize) / 2
}
private var cornerRadius: CGFloat {
buttonSize / 2
}
var body: some View {
Button(action: action) {
Image(systemSymbol: icon)
.resizable()
.frame(width: iconSize, height: iconSize)
.padding(padding)
.background(.thinMaterial)
.cornerRadius(cornerRadius)
}
}
}
struct IconButton_Previews: PreviewProvider {
static var previews: some View {
IconButton(action: { },
icon: .xmark,
iconSize: 20,
buttonSize: 25)
}
}