diff --git a/Modules/Core/Sources/Core.swift b/Modules/Core/Sources/Core.swift index e7c875c5..626107be 100644 --- a/Modules/Core/Sources/Core.swift +++ b/Modules/Core/Sources/Core.swift @@ -60,6 +60,11 @@ public struct CoreModule { let storage = try FilePhotoStorage() return PhotoRepositoryImpl(storage: storage) } + + public static func makeVideoRepository() throws -> any VideoRepository { + let storage = try FileVideoStorage() + return VideoRepositoryImpl(storage: storage) + } public static func makeCloudDocumentStorage() throws -> CloudDocumentStorageProtocol { return try ICloudDocumentStorage() diff --git a/Modules/Core/Sources/Models/Item.swift b/Modules/Core/Sources/Models/Item.swift index 10e54d23..6ac7bb37 100644 --- a/Modules/Core/Sources/Models/Item.swift +++ b/Modules/Core/Sources/Models/Item.swift @@ -68,6 +68,7 @@ public struct Item: Identifiable, Codable, Equatable { public var serialNumber: String? public var tags: [String] public var imageIds: [UUID] + public var videoIds: [UUID] public var locationId: UUID? public var storageUnitId: UUID? public var warrantyId: UUID? @@ -92,6 +93,7 @@ public struct Item: Identifiable, Codable, Equatable { serialNumber: String? = nil, tags: [String] = [], imageIds: [UUID] = [], + videoIds: [UUID] = [], locationId: UUID? = nil, storageUnitId: UUID? = nil, warrantyId: UUID? = nil, @@ -115,6 +117,7 @@ public struct Item: Identifiable, Codable, Equatable { self.serialNumber = serialNumber self.tags = tags self.imageIds = imageIds + self.videoIds = videoIds self.locationId = locationId self.storageUnitId = storageUnitId self.warrantyId = warrantyId diff --git a/Modules/Core/Sources/Models/Video.swift b/Modules/Core/Sources/Models/Video.swift new file mode 100644 index 00000000..2f903240 --- /dev/null +++ b/Modules/Core/Sources/Models/Video.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Video model for item video attachments +public struct Video: Identifiable, Codable, Equatable { + public let id: UUID + public let itemId: UUID + public var name: String? + public var sortOrder: Int + public let createdAt: Date + public var updatedAt: Date + + /// Transient data when loading video + public var data: Data? + + public init( + id: UUID = UUID(), + itemId: UUID, + name: String? = nil, + sortOrder: Int = 0, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.itemId = itemId + self.name = name + self.sortOrder = sortOrder + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +// MARK: - Video Storage Protocol +public protocol VideoStorageProtocol { + func saveVideo(_ data: Data, for videoId: UUID) async throws -> URL + func loadVideo(for videoId: UUID) async throws -> Data + func deleteVideo(for videoId: UUID) async throws +} + +// MARK: - Video Repository Protocol +public protocol VideoRepository { + func saveVideo(_ video: Video, data: Data) async throws + func loadVideos(for itemId: UUID) async throws -> [Video] + func loadVideo(id: UUID) async throws -> Video? + func deleteVideo(id: UUID) async throws + func updateVideoOrder(itemId: UUID, videoIds: [UUID]) async throws +} diff --git a/Modules/Core/Sources/Repositories/VideoRepositoryImpl.swift b/Modules/Core/Sources/Repositories/VideoRepositoryImpl.swift new file mode 100644 index 00000000..c6b4847b --- /dev/null +++ b/Modules/Core/Sources/Repositories/VideoRepositoryImpl.swift @@ -0,0 +1,101 @@ +import Foundation +import AVFoundation + +/// Concrete implementation of VideoRepository +public final class VideoRepositoryImpl: VideoRepository { + private let storage: VideoStorageProtocol + private var videoCache: [UUID: Video] = [:] + private let cacheQueue = DispatchQueue(label: "com.modularhome.videoCache", attributes: .concurrent) + + public init(storage: VideoStorageProtocol) { + self.storage = storage + } + + public func saveVideo(_ video: Video, data: Data) async throws { + _ = try await storage.saveVideo(data, for: video.id) + cacheQueue.async(flags: .barrier) { + self.videoCache[video.id] = video + } + } + + public func loadVideos(for itemId: UUID) async throws -> [Video] { + var videos = cacheQueue.sync { + videoCache.values.filter { $0.itemId == itemId }.sorted { $0.sortOrder < $1.sortOrder } + } + + for i in 0.. Video? { + var video = cacheQueue.sync { videoCache[id] } + if video != nil { + do { + let data = try await storage.loadVideo(for: id) + video?.data = data + } catch { + print("Failed to load video data for \(id): \(error)") + } + } + return video + } + + public func deleteVideo(id: UUID) async throws { + try await storage.deleteVideo(for: id) + cacheQueue.async(flags: .barrier) { + self.videoCache.removeValue(forKey: id) + } + } + + public func updateVideoOrder(itemId: UUID, videoIds: [UUID]) async throws { + cacheQueue.async(flags: .barrier) { + for (index, videoId) in videoIds.enumerated() { + if var vid = self.videoCache[videoId], vid.itemId == itemId { + vid.sortOrder = index + vid.updatedAt = Date() + self.videoCache[videoId] = vid + } + } + } + } +} + +/// File-based storage for videos +public final class FileVideoStorage: VideoStorageProtocol { + private let videosDirectory: URL + + public init() throws { + let documents = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + videosDirectory = documents.appendingPathComponent("Videos") + try FileManager.default.createDirectory(at: videosDirectory, withIntermediateDirectories: true) + } + + public func saveVideo(_ data: Data, for videoId: UUID) async throws -> URL { + let url = videosDirectory.appendingPathComponent("\(videoId.uuidString).mov") + try data.write(to: url) + return url + } + + public func loadVideo(for videoId: UUID) async throws -> Data { + let url = videosDirectory.appendingPathComponent("\(videoId.uuidString).mov") + guard FileManager.default.fileExists(atPath: url.path) else { + throw NSError(domain: "VideoStorage", code: 1, userInfo: [NSLocalizedDescriptionKey: "Video not found"]) + } + return try Data(contentsOf: url) + } + + public func deleteVideo(for videoId: UUID) async throws { + let url = videosDirectory.appendingPathComponent("\(videoId.uuidString).mov") + if FileManager.default.fileExists(atPath: url.path) { + try FileManager.default.removeItem(at: url) + } + } +} diff --git a/Modules/Items/Sources/Internal/PhotoEditor.swift b/Modules/Items/Sources/Internal/PhotoEditor.swift new file mode 100644 index 00000000..a23efb17 --- /dev/null +++ b/Modules/Items/Sources/Internal/PhotoEditor.swift @@ -0,0 +1,46 @@ +import UIKit + +struct PhotoEditor { + static func processImage(_ image: UIImage, rotationDegrees: CGFloat = 0, cropRect: CGRect? = nil) -> Data? { + var edited = image + if rotationDegrees != 0 { + edited = edited.rotated(by: rotationDegrees) + } + let rect: CGRect + if let cropRect = cropRect { + rect = CGRect(x: cropRect.origin.x * edited.scale, + y: cropRect.origin.y * edited.scale, + width: cropRect.size.width * edited.scale, + height: cropRect.size.height * edited.scale) + } else { + let length = min(edited.size.width, edited.size.height) * edited.scale + rect = CGRect(x: (edited.size.width*edited.scale - length)/2, + y: (edited.size.height*edited.scale - length)/2, + width: length, + height: length) + } + edited = edited.cropped(to: rect) + return edited.jpegData(compressionQuality: 0.9) + } +} + +private extension UIImage { + func rotated(by degrees: CGFloat) -> UIImage { + let radians = degrees * .pi / 180 + var newSize = CGRect(origin: .zero, size: size) + .applying(CGAffineTransform(rotationAngle: radians)).integral.size + UIGraphicsBeginImageContextWithOptions(newSize, false, scale) + guard let context = UIGraphicsGetCurrentContext() else { return self } + context.translateBy(x: newSize.width/2, y: newSize.height/2) + context.rotate(by: radians) + draw(in: CGRect(x: -size.width/2, y: -size.height/2, width: size.width, height: size.height)) + let rotated = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return rotated ?? self + } + + func cropped(to rect: CGRect) -> UIImage { + guard let cgImage = self.cgImage?.cropping(to: rect) else { return self } + return UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) + } +} diff --git a/Modules/Items/Sources/Public/ItemsModule.swift b/Modules/Items/Sources/Public/ItemsModule.swift index 88ef89ec..bc53586d 100644 --- a/Modules/Items/Sources/Public/ItemsModule.swift +++ b/Modules/Items/Sources/Public/ItemsModule.swift @@ -58,6 +58,7 @@ public final class ItemsModule: ItemsModuleAPI { locationRepository: dependencies.locationRepository, itemTemplateRepository: dependencies.itemTemplateRepository, photoRepository: dependencies.photoRepository, + videoRepository: dependencies.videoRepository, barcodeLookupService: dependencies.barcodeLookupService, completion: completion ) diff --git a/Modules/Items/Sources/Public/ItemsModuleAPI.swift b/Modules/Items/Sources/Public/ItemsModuleAPI.swift index 16905eec..545bed34 100644 --- a/Modules/Items/Sources/Public/ItemsModuleAPI.swift +++ b/Modules/Items/Sources/Public/ItemsModuleAPI.swift @@ -85,6 +85,7 @@ public struct ItemsModuleDependencies { public let locationRepository: any LocationRepository public let itemTemplateRepository: any ItemTemplateRepository public let photoRepository: any PhotoRepository + public let videoRepository: any VideoRepository public let barcodeLookupService: any BarcodeLookupService public let collectionRepository: any CollectionRepository public let tagRepository: any TagRepository @@ -108,6 +109,7 @@ public struct ItemsModuleDependencies { locationRepository: any LocationRepository, itemTemplateRepository: any ItemTemplateRepository, photoRepository: any PhotoRepository, + videoRepository: any VideoRepository, barcodeLookupService: any BarcodeLookupService, collectionRepository: any CollectionRepository, tagRepository: any TagRepository, @@ -130,6 +132,7 @@ public struct ItemsModuleDependencies { self.locationRepository = locationRepository self.itemTemplateRepository = itemTemplateRepository self.photoRepository = photoRepository + self.videoRepository = videoRepository self.barcodeLookupService = barcodeLookupService self.collectionRepository = collectionRepository self.tagRepository = tagRepository diff --git a/Modules/Items/Sources/Public/ItemsModuleFactory.swift b/Modules/Items/Sources/Public/ItemsModuleFactory.swift index 734bab06..eb5c74b9 100644 --- a/Modules/Items/Sources/Public/ItemsModuleFactory.swift +++ b/Modules/Items/Sources/Public/ItemsModuleFactory.swift @@ -13,6 +13,7 @@ public struct ItemsModuleFactory { let locationRepository = LocationRepositoryImplementation() let itemTemplateRepository = ItemTemplateRepositoryImplementation() let photoRepository = try! CoreModule.makePhotoRepository() + let videoRepository = try! CoreModule.makeVideoRepository() let barcodeLookupService = DefaultBarcodeLookupService() let collectionRepository = DefaultCollectionRepository() let tagRepository = Core.DefaultTagRepository() @@ -28,6 +29,7 @@ public struct ItemsModuleFactory { locationRepository: locationRepository, itemTemplateRepository: itemTemplateRepository, photoRepository: photoRepository, + videoRepository: videoRepository, barcodeLookupService: barcodeLookupService, collectionRepository: collectionRepository, tagRepository: tagRepository, @@ -51,6 +53,7 @@ public struct ItemsModuleFactory { locationRepository: any LocationRepository, itemTemplateRepository: any ItemTemplateRepository, photoRepository: any PhotoRepository, + videoRepository: any VideoRepository, barcodeLookupService: any BarcodeLookupService, collectionRepository: any CollectionRepository, tagRepository: any TagRepository, @@ -66,6 +69,7 @@ public struct ItemsModuleFactory { locationRepository: locationRepository, itemTemplateRepository: itemTemplateRepository, photoRepository: photoRepository, + videoRepository: videoRepository, barcodeLookupService: barcodeLookupService, collectionRepository: collectionRepository, tagRepository: tagRepository, diff --git a/Modules/Items/Sources/Views/AddItemView.swift b/Modules/Items/Sources/Views/AddItemView.swift index 3077d6d8..3d34ecfd 100644 --- a/Modules/Items/Sources/Views/AddItemView.swift +++ b/Modules/Items/Sources/Views/AddItemView.swift @@ -3,6 +3,7 @@ import Core import SharedUI import BarcodeScanner import PhotosUI +import UIKit struct AddItemView: View { @StateObject private var viewModel: AddItemViewModel @@ -267,6 +268,70 @@ struct AddItemView: View { } } } + + // Videos Section + Section { + if viewModel.selectedVideos.isEmpty { + Button(action: { viewModel.showVideoPicker = true }) { + HStack { + Image(systemName: "video") + .foregroundStyle(AppColors.primary) + Text("Add Videos") + Spacer() + } + } + } else { + VStack(spacing: AppSpacing.md) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: AppSpacing.sm) { + ForEach(Array(viewModel.selectedVideos.enumerated()), id: \.offset) { index, url in + ZStack { + RoundedRectangle(cornerRadius: AppCornerRadius.small) + .fill(AppColors.surface) + .frame(width: 80, height: 80) + .overlay { + Image(systemName: "video") + .font(.system(size: 24)) + .foregroundStyle(AppColors.textSecondary) + } + .overlay(alignment: .topTrailing) { + Button(action: { viewModel.removeVideo(at: index) }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white, Color.black.opacity(0.5)) + .font(.system(size: 20)) + } + .padding(4) + } + } + } + + Button(action: { viewModel.showVideoPicker = true }) { + RoundedRectangle(cornerRadius: AppCornerRadius.small) + .fill(AppColors.surface) + .frame(width: 80, height: 80) + .overlay { + Image(systemName: "plus") + .font(.system(size: 24)) + .foregroundStyle(AppColors.textSecondary) + } + .overlay { + RoundedRectangle(cornerRadius: AppCornerRadius.small) + .strokeBorder(AppColors.border, style: StrokeStyle(lineWidth: 2, dash: [5])) + } + } + } + } + } + } + } header: { + HStack { + Text("Videos") + if viewModel.videoCount > 0 { + Text("(\(viewModel.videoCount))") + .foregroundStyle(AppColors.textSecondary) + } + } + } } .navigationTitle("Add Item") .navigationBarTitleDisplayMode(.inline) @@ -325,6 +390,9 @@ struct AddItemView: View { .sheet(isPresented: $viewModel.showPhotoPicker) { PhotoPickerView(selectedImages: $viewModel.selectedPhotos) } + .sheet(isPresented: $viewModel.showVideoPicker) { + VideoPickerView(selectedVideoURLs: $viewModel.selectedVideos) + } .sheet(isPresented: $viewModel.showCamera) { CameraCaptureView(capturedImage: .init( get: { nil }, @@ -346,6 +414,7 @@ final class AddItemViewModel: ObservableObject { private let locationRepository: any LocationRepository private let itemTemplateRepository: any ItemTemplateRepository private let photoRepository: any PhotoRepository + private let videoRepository: any VideoRepository private let barcodeLookupService: any BarcodeLookupService private let completion: (Item) -> Void weak var scannerModule: ScannerModuleAPI? @@ -382,10 +451,15 @@ final class AddItemViewModel: ObservableObject { } @Published var tags: [String] = [] @Published var selectedPhotos: [UIImage] = [] + @Published var selectedVideos: [URL] = [] var photoCount: Int { selectedPhotos.count } + + var videoCount: Int { + selectedVideos.count + } // UI State @Published var locations: [Location] = [] @@ -398,6 +472,7 @@ final class AddItemViewModel: ObservableObject { @Published var showPhotoOptions = false @Published var photoSource: PhotoSource? @Published var showPhotoPicker = false + @Published var showVideoPicker = false @Published var showCamera = false @Published var isLookingUpBarcode = false @@ -419,6 +494,7 @@ final class AddItemViewModel: ObservableObject { locationRepository: any LocationRepository, itemTemplateRepository: any ItemTemplateRepository, photoRepository: any PhotoRepository, + videoRepository: any VideoRepository, barcodeLookupService: any BarcodeLookupService, completion: @escaping (Item) -> Void ) { @@ -426,6 +502,7 @@ final class AddItemViewModel: ObservableObject { self.locationRepository = locationRepository self.itemTemplateRepository = itemTemplateRepository self.photoRepository = photoRepository + self.videoRepository = videoRepository self.barcodeLookupService = barcodeLookupService self.completion = completion @@ -467,6 +544,11 @@ final class AddItemViewModel: ObservableObject { guard index < selectedPhotos.count else { return } selectedPhotos.remove(at: index) } + + func removeVideo(at index: Int) { + guard index < selectedVideos.count else { return } + selectedVideos.remove(at: index) + } func saveItem() async { guard isValid else { return } @@ -486,6 +568,7 @@ final class AddItemViewModel: ObservableObject { serialNumber: serialNumber.isEmpty ? nil : serialNumber.trimmingCharacters(in: .whitespacesAndNewlines), tags: tags, imageIds: selectedPhotos.map { _ in UUID() }, // Generate IDs for each photo + videoIds: selectedVideos.map { _ in UUID() }, locationId: selectedLocationId, warrantyId: nil ) @@ -501,11 +584,18 @@ final class AddItemViewModel: ObservableObject { itemId: newItem.id, sortOrder: index ) - guard let imageData = photo.jpegData(compressionQuality: 0.8) else { + guard let imageData = PhotoEditor.processImage(photo) else { throw NSError(domain: "AddItemView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to data"]) } try await photoRepository.savePhoto(photoModel, imageData: imageData) } + + for (index, url) in selectedVideos.enumerated() { + let videoId = newItem.videoIds[index] + let videoModel = Video(id: videoId, itemId: newItem.id, sortOrder: index) + let data = try Data(contentsOf: url) + try await videoRepository.saveVideo(videoModel, data: data) + } await MainActor.run { completion(newItem) diff --git a/Modules/SharedUI/Sources/Views/VideoPickerView.swift b/Modules/SharedUI/Sources/Views/VideoPickerView.swift new file mode 100644 index 00000000..ee43cae1 --- /dev/null +++ b/Modules/SharedUI/Sources/Views/VideoPickerView.swift @@ -0,0 +1,35 @@ +import SwiftUI +import PhotosUI + +/// Video picker view for selecting multiple videos +public struct VideoPickerView: View { + @Binding var selectedVideoURLs: [URL] + @State private var selectedItems: [PhotosPickerItem] = [] + let maxSelectionCount: Int + + public init(selectedVideoURLs: Binding<[URL]>, maxSelectionCount: Int = 5) { + self._selectedVideoURLs = selectedVideoURLs + self.maxSelectionCount = maxSelectionCount + } + + public var body: some View { + PhotosPicker( + selection: $selectedItems, + maxSelectionCount: maxSelectionCount, + matching: .videos, + photoLibrary: .shared() + ) { + Label("Select Videos", systemImage: "video") + } + .onChange(of: selectedItems) { _, newItems in + Task { + selectedVideoURLs = [] + for item in newItems { + if let url = try? await item.loadTransferable(type: URL.self) { + selectedVideoURLs.append(url) + } + } + } + } + } +}