diff --git a/Caps.xcodeproj/project.pbxproj b/Caps.xcodeproj/project.pbxproj index 1f8e577..8b463db 100644 --- a/Caps.xcodeproj/project.pbxproj +++ b/Caps.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DBE72D285495B100D1573B /* FancyTextField.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 */; }; @@ -43,6 +44,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 88DBE72D285495B100D1573B /* FancyTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FancyTextField.swift; sourceTree = ""; }; 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 = ""; }; E25AAC7D283D855D006E9E7F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -162,6 +164,7 @@ E2EA00E0283F658E00F7B269 /* SettingsView.swift */, E2EA00E4283F69DF00F7B269 /* SettingsStatisticRow.swift */, E2EA00CD283EBEB600F7B269 /* SearchField.swift */, + 88DBE72D285495B100D1573B /* FancyTextField.swift */, E2EA00F228438E6B00F7B269 /* CapNameEntryView.swift */, E2EA00C6283EAA0100F7B269 /* SortSelectionView.swift */, E2EA00CB283EB43E00F7B269 /* SortCaseRowView.swift */, @@ -282,6 +285,7 @@ E2EA00D1283EDD6300F7B269 /* CameraManager.swift in Sources */, E25AAC9B283E3395006E9E7F /* CapRowView.swift in Sources */, E2EA00DB283F5C0600F7B269 /* ContentViewModel.swift in Sources */, + 88DBE72E285495B100D1573B /* FancyTextField.swift in Sources */, E2EA00CC283EB43E00F7B269 /* SortCaseRowView.swift in Sources */, E2EA00E7283F6D0800F7B269 /* URL+Extensions.swift in Sources */, E2EA00D3283EDDF700F7B269 /* CameraError.swift in Sources */, diff --git a/Caps.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate b/Caps.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..a4f6908 Binary files /dev/null and b/Caps.xcodeproj/project.xcworkspace/xcuserdata/imac.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Caps/Camera/FrameManager.swift b/Caps/Camera/FrameManager.swift index c4a8412..64a7d75 100644 --- a/Caps/Camera/FrameManager.swift +++ b/Caps/Camera/FrameManager.swift @@ -62,9 +62,6 @@ extension FrameManager: AVCapturePhotoCaptureDelegate { log("Could not mask image") return nil } - print(image.size) - print(masked.size) - print(masked.scale) return masked } } diff --git a/Caps/CapsApp.swift b/Caps/CapsApp.swift index cc11b99..7fe3d48 100644 --- a/Caps/CapsApp.swift +++ b/Caps/CapsApp.swift @@ -1,6 +1,5 @@ import SwiftUI -#warning("TODO: Create new caps") #warning("TODO: Add colors") #warning("TODO: Grid view") diff --git a/Caps/ContentView.swift b/Caps/ContentView.swift index bbe4863..333ed9a 100644 --- a/Caps/ContentView.swift +++ b/Caps/ContentView.swift @@ -241,7 +241,7 @@ struct ContentView: View { image: $database.image, capId: $capIdOfNextPhoto) } - .bottomSheet(isPresented: $showSettingsSheet, height: 360) { + .sheet(isPresented: $showSettingsSheet) { SettingsView(isPresented: $showSettingsSheet) } .sheet(isPresented: $showGridView) { @@ -266,6 +266,8 @@ struct ContentView: View { sortType = .match sortAscending = false } + }.onAppear { + database.startRegularUploads() } } diff --git a/Caps/Data/Cap.swift b/Caps/Data/Cap.swift index bd8d9f0..43a293d 100644 --- a/Caps/Data/Cap.swift +++ b/Caps/Data/Cap.swift @@ -49,6 +49,15 @@ struct Cap { self.mainImage = data.mainImage self.classifierVersion = data.classifierVersion } + + var data: CapData { + .init(id: id, + name: name, + count: imageCount, + mainImage: mainImage, + classifierVersion: classifierVersion, + color: color) + } mutating func update(with data: CapData) { self.name = data.name diff --git a/Caps/Data/Database.swift b/Caps/Data/Database.swift index e699b5a..c0504f5 100644 --- a/Caps/Data/Database.swift +++ b/Caps/Data/Database.swift @@ -52,10 +52,10 @@ final class Database: ObservableObject { let serverUrl: URL @AppStorage("authKey") - private var serverAuthenticationKey: String? + private var serverAuthenticationKey: String = "" var hasServerAuthentication: Bool { - serverAuthenticationKey != nil + serverAuthenticationKey != "" } @Published @@ -120,7 +120,6 @@ final class Database: ObservableObject { diskCapacity: Database.imageCacheStorage, directory: cacheDirectory) loadCaps() - } @Published @@ -323,7 +322,9 @@ final class Database: ObservableObject { func save(newCap name: String) -> Cap { let cap = Cap(id: nextCapId, name: name, classifier: serverClassifierVersion) caps[cap.id] = cap - #warning("Upload new cap") + DispatchQueue.main.async { + self.changedCaps.insert(cap.id) + } return cap } @@ -350,14 +351,9 @@ final class Database: ObservableObject { } log("Saved \(url.lastPathComponent) for upload") caps[capId]?.imageCount += 1 - updateImageUploadCounts() return true } - private func updateImageUploadCounts() { - - } - private func loadImageUploadCounts() -> [Int : Int] { var result = [Int : Int]() pendingImageUploads.forEach { url in @@ -376,9 +372,10 @@ final class Database: ObservableObject { // MARK: Uploads func startRegularUploads() { - guard uploadTimer != nil else { + guard uploadTimer == nil else { return } + log("Starting upload timer") DispatchQueue.main.async { self.uploadTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true, block: self.uploadTimerElapsed) } @@ -392,13 +389,19 @@ final class Database: ObservableObject { private func uploadAll() async { guard !isUploading else { + log("Already uploading") return } DispatchQueue.main.async { self.isUploading = true } - await uploadAllChangedCaps() + log("Starting uploads") + let uploaded = await uploadAllChangedCaps() + DispatchQueue.main.async { + self.changedCaps.subtract(uploaded) + } await uploadAllImages() + log("Uploads finished") DispatchQueue.main.async { self.isUploading = false } @@ -428,18 +431,22 @@ final class Database: ObservableObject { log("No server authentication to upload to server") return } - updateImageUploadCounts() for url in pendingImageUploads { guard let capId = capId(from: url) else { log("Unexpected image \(url.lastPathComponent) in upload folder") continue } - guard await upload(imageAt: url, for: capId) else { + guard fm.fileExists(atPath: url.path) else { + log("Missing image \(url.lastPathComponent) in upload folder") continue } + guard await upload(imageAt: url, for: capId) else { + log("Failed to upload image \(url.lastPathComponent)") + continue + } + log("Uploaded image \(url.lastPathComponent)") do { try fm.removeItem(at: url) - updateImageUploadCounts() } catch { log("Failed to remove uploaded image \(url.lastPathComponent): \(error)") } @@ -448,16 +455,20 @@ final class Database: ObservableObject { @discardableResult private func upload(imageAt url: URL, for cap: Int) async -> Bool { - guard let key = serverAuthenticationKey else { + guard hasServerAuthentication else { + return false + } + guard let data = try? Data(contentsOf: url) else { return false } let url = serverUrl .appendingPathComponent("images") - .appendingPathComponent("\(cap)?key=\(key)") + .appendingPathComponent("\(cap)") var request = URLRequest(url: url) + request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key") request.httpMethod = "POST" do { - let (_, response) = try await URLSession.shared.upload(for: request, fromFile: url) + let (_, response) = try await URLSession.shared.upload(for: request, from: data) guard let httpResponse = response as? HTTPURLResponse else { log("Unexpected response for upload of image \(url.lastPathComponent): \(response)") return false @@ -477,43 +488,45 @@ final class Database: ObservableObject { changedCaps.count } - private func uploadAllChangedCaps() async { + private func uploadAllChangedCaps() async -> Set { guard hasServerAuthentication else { log("No server authentication to upload to server") - return + return .init() } var uploaded = Set() for capId in changedCaps { guard let cap = caps[capId] else { + log("Missing cap \(capId) to upload") uploaded.insert(capId) continue } guard await upload(cap: cap) else { continue } + log("Uploaded cap \(capId)") uploaded.insert(capId) } - changedCaps.subtract(uploaded) + return uploaded } @discardableResult private func upload(cap: Cap) async -> Bool { - guard let key = serverAuthenticationKey else { + guard hasServerAuthentication else { return false } let data: Data do { /// `Cap` and `CapData` have equivalent JSON layout - data = try encoder.encode(cap) + data = try encoder.encode(cap.data) } catch { log("Failed to encode cap \(cap.id) for upload: \(error)") return false } let url = serverUrl - .appendingPathComponent("images") - .appendingPathComponent("\(cap)?key=\(key)") + .appendingPathComponent("cap") var request = URLRequest(url: url) request.httpMethod = "POST" + request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key") do { let (_, response) = try await URLSession.shared.upload(for: request, from: data) guard let httpResponse = response as? HTTPURLResponse else { @@ -524,7 +537,9 @@ final class Database: ObservableObject { log("Failed to upload cap \(cap.id): Response \(httpResponse.statusCode)") return false } - changedCaps.remove(cap.id) + DispatchQueue.main.async { + self.changedCaps.remove(cap.id) + } return true } catch { log("Failed to upload cap \(cap.id): \(error)") diff --git a/Caps/Views/FancyTextField.swift b/Caps/Views/FancyTextField.swift new file mode 100644 index 0000000..45d9a3c --- /dev/null +++ b/Caps/Views/FancyTextField.swift @@ -0,0 +1,47 @@ +import SwiftUI +import SFSafeSymbols + +struct FancyTextField: View { + + @Binding + var text: String + + let icon: SFSymbol + + let placeholder: String + + let showClearButton: Bool = true + + var body: some View { + TextField("Search", text: $text, prompt: Text(placeholder)) + .padding(7) + .padding(.horizontal, 25) + .background(Color(.systemGray5)) + .cornerRadius(8) + .overlay( + HStack { + Image(systemSymbol: icon) + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + if showClearButton && text != "" { + Button(action: { + self.text = "" + }) { + Image(systemSymbol: .multiplyCircleFill) + .foregroundColor(.gray) + .padding(.trailing, 8) + } + } + } + ) + } +} + +struct FancyTextField_Previews: PreviewProvider { + static var previews: some View { + FancyTextField(text: .constant("Text"), + icon: .magnifyingglass, + placeholder: "Enter text") + } +} diff --git a/Caps/Views/SearchField.swift b/Caps/Views/SearchField.swift index 03e716c..7bf78a9 100644 --- a/Caps/Views/SearchField.swift +++ b/Caps/Views/SearchField.swift @@ -7,28 +7,7 @@ struct SearchField: View { var searchString: String var body: some View { - TextField("Search", text: $searchString, prompt: Text("Search...")) - .padding(7) - .padding(.horizontal, 25) - .background(Color(.systemGray5)) - .cornerRadius(8) - .overlay( - HStack { - Image(systemSymbol: .magnifyingglass) - .foregroundColor(.gray) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - .padding(.leading, 8) - if searchString != "" { - Button(action: { - self.searchString = "" - }) { - Image(systemSymbol: .multiplyCircleFill) - .foregroundColor(.gray) - .padding(.trailing, 8) - } - } - } - ) + FancyTextField(text: $searchString, icon: .magnifyingglass, placeholder: "Search...") } } diff --git a/Caps/Views/SettingsView.swift b/Caps/Views/SettingsView.swift index f0aec12..b7b4df8 100644 --- a/Caps/Views/SettingsView.swift +++ b/Caps/Views/SettingsView.swift @@ -1,65 +1,68 @@ import SwiftUI +import SFSafeSymbols struct SettingsView: View { - + @EnvironmentObject var database: Database - + @Binding var isPresented: Bool - + + @AppStorage("authKey") + private var serverAuthenticationKey: String = "" + var body: some View { - VStack(alignment: .leading, spacing: 3) { - HStack { - Text("Settings") - .font(.title2) - .bold() + NavigationView { + VStack(alignment: .leading, spacing: 3) { + Text("Authentication") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + FancyTextField(text: $serverAuthenticationKey, icon: .key, placeholder: "Server key") + }.padding(.horizontal) + Text("Statistics") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + SettingsStatisticRow(label: "Caps", value: "\(database.numberOfCaps)") + SettingsStatisticRow(label: "Total images", value: "\(database.numberOfImages)") + SettingsStatisticRow(label: "Images per cap", value: String(format: "%.1f", database.averageImageCount)) + }.padding(.horizontal) + Text("Classifier") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + SettingsStatisticRow(label: "Version", value: "\(database.classifierVersion)") + SettingsStatisticRow(label: "Recognized caps", value: "\(database.classifierClassCount)") + }.padding(.horizontal) + Text("Storage") + .font(.footnote) + .textCase(.uppercase) + .foregroundColor(.secondary) + .padding(.top) + Group { + SettingsStatisticRow(label: "Image cache", value: byteString(database.imageCacheSize)) + SettingsStatisticRow(label: "Database", value: byteString(database.databaseSize)) + SettingsStatisticRow(label: "Classifier", value: byteString(database.classifierSize)) + }.padding(.horizontal) Spacer() - Button(action: hide) { - Image(systemSymbol: .xmarkCircleFill) - .foregroundColor(.gray) - .font(.system(size: 26)) - } } - Text("Statistics") - .font(.footnote) - .textCase(.uppercase) - .foregroundColor(.secondary) - .padding(.top) - Group { - SettingsStatisticRow(label: "Caps", value: "\(database.numberOfCaps)") - SettingsStatisticRow(label: "Total images", value: "\(database.numberOfImages)") - SettingsStatisticRow(label: "Images per cap", value: String(format: "%.1f", database.averageImageCount)) - }.padding(.horizontal) - Text("Classifier") - .font(.footnote) - .textCase(.uppercase) - .foregroundColor(.secondary) - .padding(.top) - Group { - SettingsStatisticRow(label: "Version", value: "\(database.classifierVersion)") - SettingsStatisticRow(label: "Recognized caps", value: "\(database.classifierClassCount)") - }.padding(.horizontal) - Text("Storage") - .font(.footnote) - .textCase(.uppercase) - .foregroundColor(.secondary) - .padding(.top) - Group { - SettingsStatisticRow(label: "Image cache", value: byteString(database.imageCacheSize)) - SettingsStatisticRow(label: "Database", value: byteString(database.databaseSize)) - SettingsStatisticRow(label: "Classifier", value: byteString(database.classifierSize)) - }.padding(.horizontal) - - Spacer() + .padding(.horizontal) + .navigationTitle("Settings") } - .padding(.horizontal) } - + private func hide() { isPresented = false } - + private func byteString(_ count: Int) -> String { ByteCountFormatter.string(fromByteCount: Int64(count), countStyle: .file) } @@ -69,6 +72,6 @@ struct SettingsView_Previews: PreviewProvider { static var previews: some View { SettingsView(isPresented: .constant(true)) .environmentObject(Database.mock) - .previewLayout(.fixed(width: 375, height: 330)) + //.previewLayout(.fixed(width: 375, height: 410)) } }