diff --git a/Caps/Data/Database.swift b/Caps/Data/Database.swift index 906b94d..ca6417c 100644 --- a/Caps/Data/Database.swift +++ b/Caps/Data/Database.swift @@ -597,6 +597,76 @@ final class Database: ObservableObject { } } + func setMainImage(_ version: Int, for capId: Int) async -> Cap? { + guard hasServerAuthentication else { + log("No authorization to set main image") + return nil + } + guard var cap = cap(for: capId) else { + log("No cap \(capId) to set main image") + return nil + } + guard version < cap.imageCount else { + log("Invalid main image \(version) for \(capId) with only \(cap.imageCount) images") + return nil + } + cap.mainImage = version + let finalCap = cap + DispatchQueue.main.async { + self.caps[capId] = finalCap + log("Set main image \(version) for \(capId)") + } + return finalCap + } + + func delete(image: CapImage) async -> Bool { + guard hasServerAuthentication else { + log("No authorization to set main image") + return false + } + guard var cap = cap(for: image.cap) else { + log("No cap \(image.cap) to set main image") + return false + } + guard image.version < cap.imageCount else { + log("Invalid main image \(image.version) for \(cap.id) with only \(cap.imageCount) images") + return false + } + + let url = serverUrl + .appendingPathComponent("delete/\(cap.id)/\(image.version)") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue(serverAuthenticationKey, forHTTPHeaderField: "key") + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + log("Unexpected response deleting image \(image.version) of cap \(cap.id): \(response)") + return false + } + guard httpResponse.statusCode == 200 else { + log("Failed to delete image \(image.version) of cap \(cap.id): Response \(httpResponse.statusCode)") + return false + } + let newCap: Cap + do { + newCap = try JSONDecoder().decode(Cap.self, from: data) + } catch { + log("Invalid response data deleting image \(image.version) of cap \(cap.id): \(data)") + return false + } + // Delete cached images + images.removeCachedImages(for: cap.id) + // Update cap + caps[newCap.id] = newCap + log("Deleted image \(image.version) of cap \(cap.id)") + return true + } catch { + log("Failed to delete image \(image.version) of cap \(cap.id): \(error)") + return false + } + } + // MARK: Classification /// The compiled recognition model on disk diff --git a/Caps/Data/ImageCache.swift b/Caps/Data/ImageCache.swift index 7e419db..aa625e5 100644 --- a/Caps/Data/ImageCache.swift +++ b/Caps/Data/ImageCache.swift @@ -106,6 +106,30 @@ final class ImageCache { return saveImage(image, at: downloadedImageUrl) } + @discardableResult + func removeCachedImages(for cap: Int) -> Bool { + let prefix = String(format: "%04d-", cap) + let files: [URL] + do { + files = try fm.contentsOfDirectory(atPath: folder.path) + .filter { $0.hasPrefix(prefix) } + .map { folder.appendingPathComponent($0) } + } catch { + log("Failed to get image cache file list: \(error)") + return false + } + var isSuccessful = true + for url in files { + do { + try fm.removeItem(at: url) + } catch { + isSuccessful = false + log("Failed to remove image \(url.lastPathComponent) from cache: \(error)") + } + } + return isSuccessful + } + private func loadRemoteImage(_ image: CapImage) async -> URL? { let remoteURL = remoteImageUrl(image) return await loadRemoteImage(at: remoteURL) diff --git a/Caps/Views/CapImagesView.swift b/Caps/Views/CapImagesView.swift index a926ad5..8f790fb 100644 --- a/Caps/Views/CapImagesView.swift +++ b/Caps/Views/CapImagesView.swift @@ -9,6 +9,10 @@ struct CapImagesView: View { @Binding var isPresented: Bool + + @State + private var selectedCap: CapImage? + init(cap: Binding, database: Database, isPresented: Binding) { self.database = database @@ -50,21 +54,62 @@ struct CapImagesView: View { ScrollView(.vertical) { LazyVGrid(columns: [gridItem, gridItem, gridItem, gridItem]) { ForEach(images) { item in - CachedCapImage( - item, - check: { database.images.cachedImage(item) }, - fetch: { await database.images.image(item) }, - content: { $0.resizable() }, - placeholder: { ProgressView() }) - .frame(width: imageSize, - height: imageSize) - .clipShape(Circle()) + ZStack(alignment: .topLeading) { + CachedCapImage( + item, + check: { database.images.cachedImage(item) }, + fetch: { await database.images.image(item) }, + content: { $0.resizable() }, + placeholder: { ProgressView() }) + .frame(width: imageSize, + height: imageSize) + .clipShape(Circle()) + .onLongPressGesture { selectedCap = item } + if item.version == cap?.mainImage { + Image(systemSymbol: .checkmarkCircleFill) + .foregroundColor(.green) + .background(Color.white) + .clipShape(Circle()) + } + } + } } } } .padding(.horizontal) } + .actionSheet(item: $selectedCap) { item in + ActionSheet(title: Text("Image \(item.version)"), buttons: [ + .default(Text("Set as main image")) { setMainImage(item) }, + .destructive(Text("Delete image")) { delete(image: item) }, + .cancel() + ]) + } + } + + private func delete(image: CapImage) { + Task { + guard let cap = await database.setMainImage(image.version, for: image.cap) else { + return + } + DispatchQueue.main.async { + self.cap = cap + } + } + selectedCap = nil + } + + private func setMainImage(_ image: CapImage) { + Task { + guard let cap = await database.setMainImage(image.version, for: image.cap) else { + return + } + DispatchQueue.main.async { + self.cap = cap + } + } + self.selectedCap = nil } }