Add grid, camera focus
This commit is contained in:
parent
2b3ab859fc
commit
4b91ebcd02
@ -8,6 +8,13 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBE72D285495B100D1573B /* FancyTextField.swift */; };
|
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 */; };
|
E25AAC7C283D855D006E9E7F /* CapsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7B283D855D006E9E7F /* CapsApp.swift */; };
|
||||||
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7D283D855D006E9E7F /* ContentView.swift */; };
|
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC7D283D855D006E9E7F /* ContentView.swift */; };
|
||||||
E25AAC80283D855F006E9E7F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E25AAC7F283D855F006E9E7F /* Assets.xcassets */; };
|
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 */; };
|
E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC93283D88A4006E9E7F /* Cap.swift */; };
|
||||||
E25AAC96283E14DF006E9E7F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC95283E14DF006E9E7F /* Database.swift */; };
|
E25AAC96283E14DF006E9E7F /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC95283E14DF006E9E7F /* Database.swift */; };
|
||||||
E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E25AAC9A283E3395006E9E7F /* CapRowView.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 */; };
|
E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = E2EA00C2283E672A00F7B269 /* SFSafeSymbols */; };
|
||||||
E2EA00C5283EA72000F7B269 /* SortCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C4283EA72000F7B269 /* SortCriteria.swift */; };
|
E2EA00C5283EA72000F7B269 /* SortCriteria.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C4283EA72000F7B269 /* SortCriteria.swift */; };
|
||||||
E2EA00C7283EAA0100F7B269 /* SortSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */; };
|
E2EA00C7283EAA0100F7B269 /* SortSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */; };
|
||||||
@ -45,6 +51,13 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
88DBE72D285495B100D1573B /* FancyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyTextField.swift; sourceTree = "<group>"; };
|
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; };
|
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>"; };
|
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>"; };
|
E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||||
@ -85,7 +98,6 @@
|
|||||||
files = (
|
files = (
|
||||||
E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */,
|
E2EA00C3283E672A00F7B269 /* SFSafeSymbols in Frameworks */,
|
||||||
E2EA00CA283EACB200F7B269 /* BottomSheet in Frameworks */,
|
E2EA00CA283EACB200F7B269 /* BottomSheet in Frameworks */,
|
||||||
E27E15E1283E418600F6804A /* CachedAsyncImage in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -114,11 +126,11 @@
|
|||||||
E25AAC7B283D855D006E9E7F /* CapsApp.swift */,
|
E25AAC7B283D855D006E9E7F /* CapsApp.swift */,
|
||||||
E25AAC7D283D855D006E9E7F /* ContentView.swift */,
|
E25AAC7D283D855D006E9E7F /* ContentView.swift */,
|
||||||
E2EA00CF283EDD2C00F7B269 /* Camera */,
|
E2EA00CF283EDD2C00F7B269 /* Camera */,
|
||||||
E25AAC97283E337C006E9E7F /* Views */,
|
|
||||||
E25AAC89283D8666006E9E7F /* Data */,
|
E25AAC89283D8666006E9E7F /* Data */,
|
||||||
E25AAC7F283D855F006E9E7F /* Assets.xcassets */,
|
E25AAC97283E337C006E9E7F /* Views */,
|
||||||
E25AAC8E283D870F006E9E7F /* Extensions */,
|
E25AAC8E283D870F006E9E7F /* Extensions */,
|
||||||
E25AAC8C283D86CF006E9E7F /* Logger.swift */,
|
E25AAC8C283D86CF006E9E7F /* Logger.swift */,
|
||||||
|
E25AAC7F283D855F006E9E7F /* Assets.xcassets */,
|
||||||
E25AAC81283D855F006E9E7F /* Preview Content */,
|
E25AAC81283D855F006E9E7F /* Preview Content */,
|
||||||
);
|
);
|
||||||
path = Caps;
|
path = Caps;
|
||||||
@ -135,11 +147,14 @@
|
|||||||
E25AAC89283D8666006E9E7F /* Data */ = {
|
E25AAC89283D8666006E9E7F /* Data */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E25AAC8A283D868D006E9E7F /* Classifier.swift */,
|
|
||||||
E2EA00C4283EA72000F7B269 /* SortCriteria.swift */,
|
|
||||||
E25AAC93283D88A4006E9E7F /* Cap.swift */,
|
E25AAC93283D88A4006E9E7F /* Cap.swift */,
|
||||||
E25AAC91283D8808006E9E7F /* CapData.swift */,
|
E25AAC91283D8808006E9E7F /* CapData.swift */,
|
||||||
|
E25AAC8A283D868D006E9E7F /* Classifier.swift */,
|
||||||
E25AAC95283E14DF006E9E7F /* Database.swift */,
|
E25AAC95283E14DF006E9E7F /* Database.swift */,
|
||||||
|
E20D104B28563DB10019BD91 /* ImageCache.swift */,
|
||||||
|
E20D1049285612AF0019BD91 /* ImageGrid.swift */,
|
||||||
|
E2EA00C4283EA72000F7B269 /* SortCriteria.swift */,
|
||||||
|
E20D104F28574E190019BD91 /* CapImage.swift */,
|
||||||
);
|
);
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -152,6 +167,8 @@
|
|||||||
E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */,
|
E2EA00E6283F6D0800F7B269 /* URL+Extensions.swift */,
|
||||||
E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */,
|
E2EA00EA284109CC00F7B269 /* CGImage+Extensions.swift */,
|
||||||
E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */,
|
E2EA00EE28420AA000F7B269 /* Data+Extensions.swift */,
|
||||||
|
E20D105128589AAC0019BD91 /* FileManager+Extensions.swift */,
|
||||||
|
E20D10552858CDFA0019BD91 /* View+Extensions.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -159,15 +176,17 @@
|
|||||||
E25AAC97283E337C006E9E7F /* Views */ = {
|
E25AAC97283E337C006E9E7F /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E25AAC9A283E3395006E9E7F /* CapRowView.swift */,
|
E20D104D28574C7C0019BD91 /* CachedCapImage.swift */,
|
||||||
E2EA00E2283F662800F7B269 /* GridView.swift */,
|
|
||||||
E2EA00E0283F658E00F7B269 /* SettingsView.swift */,
|
|
||||||
E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */,
|
|
||||||
E2EA00CD283EBEB600F7B269 /* SearchField.swift */,
|
|
||||||
88DBE72D285495B100D1573B /* FancyTextField.swift */,
|
|
||||||
E2EA00F228438E6B00F7B269 /* CapNameEntryView.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 */,
|
E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */,
|
||||||
|
E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -175,13 +194,13 @@
|
|||||||
E2EA00CF283EDD2C00F7B269 /* Camera */ = {
|
E2EA00CF283EDD2C00F7B269 /* Camera */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E2EA00D2283EDDF700F7B269 /* CameraError.swift */,
|
||||||
E2EA00D0283EDD6300F7B269 /* CameraManager.swift */,
|
E2EA00D0283EDD6300F7B269 /* CameraManager.swift */,
|
||||||
E2EA00D8283F5BB900F7B269 /* CameraView.swift */,
|
E2EA00D8283F5BB900F7B269 /* CameraView.swift */,
|
||||||
E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */,
|
E2EA00DA283F5C0600F7B269 /* ContentViewModel.swift */,
|
||||||
E2EA00DC283F5C6A00F7B269 /* FrameView.swift */,
|
|
||||||
E2EA00D4283EDFA200F7B269 /* FrameManager.swift */,
|
|
||||||
E2EA00D2283EDDF700F7B269 /* CameraError.swift */,
|
|
||||||
E2EA00DE283F5CA000F7B269 /* ErrorView.swift */,
|
E2EA00DE283F5CA000F7B269 /* ErrorView.swift */,
|
||||||
|
E2EA00D4283EDFA200F7B269 /* FrameManager.swift */,
|
||||||
|
E2EA00DC283F5C6A00F7B269 /* FrameView.swift */,
|
||||||
);
|
);
|
||||||
path = Camera;
|
path = Camera;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -203,7 +222,6 @@
|
|||||||
);
|
);
|
||||||
name = Caps;
|
name = Caps;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
E27E15E0283E418600F6804A /* CachedAsyncImage */,
|
|
||||||
E2EA00C2283E672A00F7B269 /* SFSafeSymbols */,
|
E2EA00C2283E672A00F7B269 /* SFSafeSymbols */,
|
||||||
E2EA00C9283EACB200F7B269 /* BottomSheet */,
|
E2EA00C9283EACB200F7B269 /* BottomSheet */,
|
||||||
);
|
);
|
||||||
@ -236,7 +254,6 @@
|
|||||||
);
|
);
|
||||||
mainGroup = E25AAC6F283D855D006E9E7F;
|
mainGroup = E25AAC6F283D855D006E9E7F;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
|
|
||||||
E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */,
|
||||||
E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */,
|
E2EA00C8283EACB200F7B269 /* XCRemoteSwiftPackageReference "bottom-sheet" */,
|
||||||
);
|
);
|
||||||
@ -266,14 +283,18 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
E20D10582858CEBD0019BD91 /* IconButton.swift in Sources */,
|
||||||
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */,
|
E25AAC7E283D855D006E9E7F /* ContentView.swift in Sources */,
|
||||||
E2EA00F328438E6B00F7B269 /* CapNameEntryView.swift in Sources */,
|
E2EA00F328438E6B00F7B269 /* CapNameEntryView.swift in Sources */,
|
||||||
E25AAC8B283D868D006E9E7F /* Classifier.swift in Sources */,
|
E25AAC8B283D868D006E9E7F /* Classifier.swift in Sources */,
|
||||||
|
E20D10562858CDFA0019BD91 /* View+Extensions.swift in Sources */,
|
||||||
E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */,
|
E25AAC94283D88A4006E9E7F /* Cap.swift in Sources */,
|
||||||
E2EA00D9283F5BB900F7B269 /* CameraView.swift in Sources */,
|
E2EA00D9283F5BB900F7B269 /* CameraView.swift in Sources */,
|
||||||
E2EA00E3283F662800F7B269 /* GridView.swift in Sources */,
|
E2EA00E3283F662800F7B269 /* GridView.swift in Sources */,
|
||||||
E2EA00EB284109CC00F7B269 /* CGImage+Extensions.swift in Sources */,
|
E2EA00EB284109CC00F7B269 /* CGImage+Extensions.swift in Sources */,
|
||||||
|
E20D104E28574C7C0019BD91 /* CachedCapImage.swift in Sources */,
|
||||||
E2EA00DF283F5CA000F7B269 /* ErrorView.swift in Sources */,
|
E2EA00DF283F5CA000F7B269 /* ErrorView.swift in Sources */,
|
||||||
|
E20D104A285612AF0019BD91 /* ImageGrid.swift in Sources */,
|
||||||
E2EA00D5283EDFA200F7B269 /* FrameManager.swift in Sources */,
|
E2EA00D5283EDFA200F7B269 /* FrameManager.swift in Sources */,
|
||||||
E25AAC90283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift in Sources */,
|
E25AAC90283D871E006E9E7F /* CGImagePropertyOrientation+Extensions.swift in Sources */,
|
||||||
E2EA00CE283EBEB600F7B269 /* SearchField.swift in Sources */,
|
E2EA00CE283EBEB600F7B269 /* SearchField.swift in Sources */,
|
||||||
@ -289,10 +310,13 @@
|
|||||||
E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */,
|
E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */,
|
||||||
E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */,
|
E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */,
|
||||||
E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */,
|
E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */,
|
||||||
|
E20D104C28563DB10019BD91 /* ImageCache.swift in Sources */,
|
||||||
E25AAC92283D8808006E9E7F /* CapData.swift in Sources */,
|
E25AAC92283D8808006E9E7F /* CapData.swift in Sources */,
|
||||||
E25AAC96283E14DF006E9E7F /* Database.swift in Sources */,
|
E25AAC96283E14DF006E9E7F /* Database.swift in Sources */,
|
||||||
E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */,
|
E25AAC8D283D86CF006E9E7F /* Logger.swift in Sources */,
|
||||||
|
E20D105028574E190019BD91 /* CapImage.swift in Sources */,
|
||||||
E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */,
|
E2EA00ED2841170100F7B269 /* UIImage+Extensions.swift in Sources */,
|
||||||
|
E20D105228589AAC0019BD91 /* FileManager+Extensions.swift in Sources */,
|
||||||
E2EA00E5283F69DF00F7B269 /* SettingsStatisticRow.swift in Sources */,
|
E2EA00E5283F69DF00F7B269 /* SettingsStatisticRow.swift in Sources */,
|
||||||
E2EA00E1283F658E00F7B269 /* SettingsView.swift in Sources */,
|
E2EA00E1283F658E00F7B269 /* SettingsView.swift in Sources */,
|
||||||
);
|
);
|
||||||
@ -427,6 +451,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -458,6 +483,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "Take images to identify matching caps and register new ones";
|
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_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
@ -501,14 +527,6 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference 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" */ = {
|
E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
repositoryURL = "https://github.com/SFSafeSymbols/SFSafeSymbols";
|
||||||
@ -528,11 +546,6 @@
|
|||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
E27E15E0283E418600F6804A /* CachedAsyncImage */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = E27E15DF283E418600F6804A /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
|
|
||||||
productName = CachedAsyncImage;
|
|
||||||
};
|
|
||||||
E2EA00C2283E672A00F7B269 /* SFSafeSymbols */ = {
|
E2EA00C2283E672A00F7B269 /* SFSafeSymbols */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
package = E2EA00C1283E672A00F7B269 /* XCRemoteSwiftPackageReference "SFSafeSymbols" */;
|
||||||
|
@ -17,15 +17,6 @@
|
|||||||
"revision" : "c8c33d947d8a1c883aa19fd24e14fd738b06e369",
|
"revision" : "c8c33d947d8a1c883aa19fd24e14fd738b06e369",
|
||||||
"version" : "3.3.2"
|
"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
|
"version" : 2
|
||||||
|
Binary file not shown.
@ -20,6 +20,8 @@ class CameraManager: ObservableObject {
|
|||||||
private let photoOutput = AVCapturePhotoOutput()
|
private let photoOutput = AVCapturePhotoOutput()
|
||||||
private var status = Status.unconfigured
|
private var status = Status.unconfigured
|
||||||
|
|
||||||
|
private var camera: AVCaptureDevice?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
configure()
|
configure()
|
||||||
}
|
}
|
||||||
@ -67,11 +69,11 @@ class CameraManager: ObservableObject {
|
|||||||
session.commitConfiguration()
|
session.commitConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
let device = AVCaptureDevice.default(
|
self.camera = AVCaptureDevice.default(
|
||||||
.builtInWideAngleCamera,
|
.builtInWideAngleCamera,
|
||||||
for: .video,
|
for: .video,
|
||||||
position: .back)
|
position: .back)
|
||||||
guard let camera = device else {
|
guard let camera = camera else {
|
||||||
set(error: .cameraUnavailable)
|
set(error: .cameraUnavailable)
|
||||||
status = .failed
|
status = .failed
|
||||||
return
|
return
|
||||||
@ -169,4 +171,28 @@ class CameraManager: ObservableObject {
|
|||||||
self.photoOutput.capturePhoto(with: photoSettings, delegate: delegate)
|
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)
|
.overlay(RoundedRectangle(cornerRadius: circleSize/2)
|
||||||
.stroke(lineWidth: circleStrength)
|
.stroke(lineWidth: circleStrength)
|
||||||
.foregroundColor(circleColor))
|
.foregroundColor(circleColor))
|
||||||
|
.onTapGesture(perform: didTapCircle)
|
||||||
|
.background(Color(white: 1, opacity: 0.01))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@ -116,6 +117,10 @@ struct CameraView: View {
|
|||||||
private func capture() {
|
private func capture() {
|
||||||
model.captureImage()
|
model.captureImage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func didTapCircle() {
|
||||||
|
model.continuouslyFocusOnMiddle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CameraView_Previews: PreviewProvider {
|
struct CameraView_Previews: PreviewProvider {
|
||||||
|
@ -54,4 +54,8 @@ class ContentViewModel: ObservableObject {
|
|||||||
func captureImage() {
|
func captureImage() {
|
||||||
cameraManager.capturePhoto(delegate: frameManager)
|
cameraManager.capturePhoto(delegate: frameManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func continuouslyFocusOnMiddle() {
|
||||||
|
cameraManager.continuouslyFocusOnMiddle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
#warning("TODO: Add colors")
|
#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
|
@main
|
||||||
struct CapsApp: App {
|
struct CapsApp: App {
|
||||||
|
|
||||||
|
static let thumbnailImageSize: CGFloat = 60
|
||||||
|
|
||||||
let database = Database(server: URL(string: "https://christophhagen.de/caps")!)
|
let database = Database(server: URL(string: "https://christophhagen.de/caps")!)
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
@ -245,7 +245,7 @@ struct ContentView: View {
|
|||||||
SettingsView(isPresented: $showSettingsSheet)
|
SettingsView(isPresented: $showSettingsSheet)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showGridView) {
|
.sheet(isPresented: $showGridView) {
|
||||||
GridView()
|
GridView(isPresented: $showGridView)
|
||||||
}.alert(isPresented: $showNewClassifierAlert) {
|
}.alert(isPresented: $showNewClassifierAlert) {
|
||||||
Alert(title: Text("New classifier available"),
|
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?"),
|
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)
|
String(format: "images/%04d/%04d-%02d.jpg", id, id, mainImage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var image: CapImage {
|
||||||
|
.init(cap: id, version: mainImage)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Create a new cap.
|
Create a new cap.
|
||||||
- Parameter id: The unique id of the 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 {
|
final class Database: ObservableObject {
|
||||||
|
|
||||||
static let imageCacheMemory = 10_000_000
|
|
||||||
|
|
||||||
static let imageCacheStorage = 200_000_000
|
|
||||||
|
|
||||||
private let imageCompressionQuality: CGFloat = 0.3
|
private let imageCompressionQuality: CGFloat = 0.3
|
||||||
|
|
||||||
private static var documentDirectory: URL {
|
@AppStorage("classifier")
|
||||||
try! FileManager.default.url(
|
private(set) var classifierVersion = 0
|
||||||
for: .documentDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil, create: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fm: FileManager {
|
@AppStorage("serverClassifier")
|
||||||
.default
|
private(set) var serverClassifierVersion = 0
|
||||||
}
|
|
||||||
|
|
||||||
private var localDbUrl: URL {
|
let images: ImageCache
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
private let encoder = JSONEncoder()
|
private let encoder = JSONEncoder()
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
let serverUrl: URL
|
let serverUrl: URL
|
||||||
|
|
||||||
|
let folderUrl: URL
|
||||||
|
|
||||||
@AppStorage("authKey")
|
@AppStorage("authKey")
|
||||||
private var serverAuthenticationKey: String = ""
|
private var serverAuthenticationKey: String = ""
|
||||||
|
|
||||||
@ -108,22 +79,68 @@ final class Database: ObservableObject {
|
|||||||
*/
|
*/
|
||||||
private var nextSaveTime: Date?
|
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.serverUrl = server
|
||||||
|
self.folderUrl = folder
|
||||||
self.caps = [:]
|
self.caps = [:]
|
||||||
|
|
||||||
let cacheDirectory = Database.documentDirectory.appendingPathComponent("images")
|
let imageFolder = folder.appendingPathComponent("images")
|
||||||
self.imageCache = URLCache(
|
self.images = try! ImageCache(
|
||||||
memoryCapacity: Database.imageCacheMemory,
|
folder: imageFolder,
|
||||||
diskCapacity: Database.imageCacheStorage,
|
server: server,
|
||||||
directory: cacheDirectory)
|
thumbnailSize: CapsApp.thumbnailImageSize)
|
||||||
|
|
||||||
|
ensureFolderExistence(gridStorageFolder)
|
||||||
loadCaps()
|
loadCaps()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published
|
func mainImage(for cap: Int) -> Int {
|
||||||
var isUploading = false
|
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
|
// MARK: Disk storage
|
||||||
|
|
||||||
@ -185,6 +202,7 @@ final class Database: ObservableObject {
|
|||||||
print("Database saved")
|
print("Database saved")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
private func ensureFolderExistence(_ url: URL) -> Bool {
|
private func ensureFolderExistence(_ url: URL) -> Bool {
|
||||||
guard !fm.fileExists(atPath: url.path) else {
|
guard !fm.fileExists(atPath: url.path) else {
|
||||||
return true
|
return true
|
||||||
@ -395,6 +413,14 @@ final class Database: ObservableObject {
|
|||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isUploading = true
|
self.isUploading = true
|
||||||
}
|
}
|
||||||
|
defer {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.isUploading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !changedCaps.isEmpty || pendingImageUploadCount > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
log("Starting uploads")
|
log("Starting uploads")
|
||||||
let uploaded = await uploadAllChangedCaps()
|
let uploaded = await uploadAllChangedCaps()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@ -402,9 +428,6 @@ final class Database: ObservableObject {
|
|||||||
}
|
}
|
||||||
await uploadAllImages()
|
await uploadAllImages()
|
||||||
log("Uploads finished")
|
log("Uploads finished")
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isUploading = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -595,6 +618,82 @@ final class Database: ObservableObject {
|
|||||||
return Classifier(model: model)
|
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
|
// MARK: Statistics
|
||||||
|
|
||||||
var numberOfCaps: Int {
|
var numberOfCaps: Int {
|
||||||
@ -609,19 +708,13 @@ final class Database: ObservableObject {
|
|||||||
Float(numberOfImages) / Float(numberOfCaps)
|
Float(numberOfImages) / Float(numberOfCaps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@AppStorage("classifier")
|
|
||||||
private(set) var classifierVersion = 0
|
|
||||||
|
|
||||||
@AppStorage("serverClassifier")
|
|
||||||
private(set) var serverClassifierVersion = 0
|
|
||||||
|
|
||||||
var classifierClassCount: Int {
|
var classifierClassCount: Int {
|
||||||
let version = classifierVersion
|
let version = classifierVersion
|
||||||
return caps.values.filter { $0.classifiable(by: version) }.count
|
return caps.values.filter { $0.classifiable(by: version) }.count
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageCacheSize: Int {
|
var imageCacheSize: Int {
|
||||||
imageCache.currentDiskUsage
|
fm.directorySize(images.folder)
|
||||||
}
|
}
|
||||||
|
|
||||||
var databaseSize: Int {
|
var databaseSize: Int {
|
||||||
@ -648,4 +741,13 @@ extension Database {
|
|||||||
db.image = UIImage(systemSymbol: .photo)
|
db.image = UIImage(systemSymbol: .photo)
|
||||||
return db
|
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 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? {
|
var creationDate: Date? {
|
||||||
return attributes?[.creationDate] as? 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 SwiftUI
|
||||||
import CachedAsyncImage
|
|
||||||
|
|
||||||
struct CapRowView: View {
|
struct CapRowView: View {
|
||||||
|
|
||||||
@ -56,7 +55,7 @@ struct CapRowView: View {
|
|||||||
}
|
}
|
||||||
}//.padding(.vertical)
|
}//.padding(.vertical)
|
||||||
Spacer()
|
Spacer()
|
||||||
CachedAsyncImage(url: imageUrl, urlCache: database.imageCache) { image in
|
CachedCapImage(cap, cap.image, cache: database.images) { image in
|
||||||
image.resizable()
|
image.resizable()
|
||||||
} placeholder: {
|
} placeholder: {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
|
@ -1,13 +1,171 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SFSafeSymbols
|
||||||
|
|
||||||
struct GridView: View {
|
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 {
|
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 {
|
struct GridView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
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