Add grid, camera focus
This commit is contained in:
parent
2b3ab859fc
commit
4b91ebcd02
@ -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" */;
|
||||
|
@ -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
|
||||
|
Binary file not shown.
@ -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 {
|
||||
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -54,4 +54,8 @@ class ContentViewModel: ObservableObject {
|
||||
func captureImage() {
|
||||
cameraManager.capturePhoto(delegate: frameManager)
|
||||
}
|
||||
|
||||
func continuouslyFocusOnMiddle() {
|
||||
cameraManager.continuouslyFocusOnMiddle()
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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?"),
|
||||
|
@ -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
8
Caps/Data/CapImage.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
struct CapImage: Codable, Equatable, Hashable {
|
||||
|
||||
let cap: Int
|
||||
|
||||
let version: Int
|
||||
}
|
@ -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
209
Caps/Data/ImageCache.swift
Normal 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
82
Caps/Data/ImageGrid.swift
Normal 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))
|
||||
}
|
||||
}
|
26
Caps/Extensions/FileManager+Extensions.swift
Normal file
26
Caps/Extensions/FileManager+Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
19
Caps/Extensions/View+Extensions.swift
Normal file
19
Caps/Extensions/View+Extensions.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
79
Caps/Views/CachedCapImage.swift
Normal file
79
Caps/Views/CachedCapImage.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
41
Caps/Views/IconButton.swift
Normal file
41
Caps/Views/IconButton.swift
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user