Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Modules/Core/Sources/Core.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions Modules/Core/Sources/Models/Item.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions Modules/Core/Sources/Models/Video.swift
Original file line number Diff line number Diff line change
@@ -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
}
101 changes: 101 additions & 0 deletions Modules/Core/Sources/Repositories/VideoRepositoryImpl.swift
Original file line number Diff line number Diff line change
@@ -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..<videos.count {
do {
let data = try await storage.loadVideo(for: videos[i].id)
videos[i].data = data
} catch {
print("Failed to load video data for \(videos[i].id): \(error)")
}
}

return videos
}

public func loadVideo(id: UUID) async throws -> 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)
}
}
}
46 changes: 46 additions & 0 deletions Modules/Items/Sources/Internal/PhotoEditor.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions Modules/Items/Sources/Public/ItemsModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
3 changes: 3 additions & 0 deletions Modules/Items/Sources/Public/ItemsModuleAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Modules/Items/Sources/Public/ItemsModuleFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -28,6 +29,7 @@ public struct ItemsModuleFactory {
locationRepository: locationRepository,
itemTemplateRepository: itemTemplateRepository,
photoRepository: photoRepository,
videoRepository: videoRepository,
barcodeLookupService: barcodeLookupService,
collectionRepository: collectionRepository,
tagRepository: tagRepository,
Expand All @@ -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,
Expand All @@ -66,6 +69,7 @@ public struct ItemsModuleFactory {
locationRepository: locationRepository,
itemTemplateRepository: itemTemplateRepository,
photoRepository: photoRepository,
videoRepository: videoRepository,
barcodeLookupService: barcodeLookupService,
collectionRepository: collectionRepository,
tagRepository: tagRepository,
Expand Down
Loading
Loading