Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: #162 사진 Sync 로직 변경 #176

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions PicCharge/LocalStorageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ protocol LocalStorageService {
/// - Throws: 저장 과정에서 오류 발생 시 예외를 던짐
func updatePhotos(_ photos: [Photo]) async throws

func updatePhoto(_ photo: Photo) async throws

/// 로컬 스토리지에서 일치하는 ID의 사진을 삭제합니다.
/// - Parameter photoId: 삭제할 사진의 ID
/// - Throws: 삭제 과정에서 오류 발생 시 예외를 던짐
Expand Down
126 changes: 74 additions & 52 deletions PicCharge/PhotoViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,67 @@ final class PhotoViewModel {
}

extension PhotoViewModel {
/// 로컬과 원격 저장소에 저장된 사진 데이터를 동기화합니다.
///
/// 1. 현재 원격, 로컬 데이터를 Fetch합니다.
/// 2. 원격에서 reaction이 업데이트 된 Photo를 확인합니다.
/// 3. 원격에서 새로 추가된 Photo를 확인합니다.
/// 4. 원격에서 삭제된 Photo를 확인합니다.
/// 5. 변경된 2,3,4 Photo 데이터를 로컬에 저장합니다.
/// 6. 로컬 데이터를 다시 fetch합니다.
///
/// - Parameter userName: 유저의 이름
func syncPhoto(of userName: String?) async {
guard let userName else { return }

do {
// 원격 및 로컬 데이터 가져오기
async let remoteData = remoteStorageService.fetchPhotos(userName)
async let localData = localStorageService.fetchPhotos()

let remotePhotos = try await remoteData
let localPhotos = await localData

// remoteSet과 localSet을 ID 기반 딕셔너리로 변환
let remoteMap = Dictionary(uniqueKeysWithValues: remotePhotos.map { ($0.id, $0) })
let localMap = Dictionary(uniqueKeysWithValues: localPhotos.map { ($0.id, $0) })

// (1) 업데이트할 항목: 동일한 ID를 가진 항목 중 reaction이 다른 항목
let photosToUpdate = localMap.filter { remoteMap[$0]?.reaction != $1.reaction }.map { $0.value }

// (2) 추가할 항목: remoteMap에만 존재하는 항목
let photosToAdd = remoteMap.filter { !localMap.keys.contains($0.key) }.map { $0.value }

// (3) 삭제할 항목: localMap에만 존재하는 항목
let photoIdsToDelete = localMap.filter { !remoteMap.keys.contains($0.key) }.map { $0.key }

// 동일 Context 직렬 처리
try await localStorageService.updatePhotos(photosToUpdate)
try await localStorageService.addPhotos(photosToAdd)
try await localStorageService.deletePhotos(photoIdsToDelete)

print("총 \(remotePhotos.count) 개의 이미지")
print("\(photosToUpdate.count + photosToAdd.count + photoIdsToDelete.count) 개의 이미지 동기화함")
print("\(photosToUpdate.count) 개의 사진 업데이트됨")
print("\(photosToAdd.count) 개의 사진 추가됨")
print("\(photoIdsToDelete.count) 개의 사진 삭제됨")

// 최종적으로 로컬 데이터를 가져와 UI 업데이트
let photos = await localStorageService.fetchPhotos()

await MainActor.run {
self.photos = photos
}

} catch {
await GlobalAlert.shared.show(message: "동기화 실패")
}
}

/// 원격 저장소에 사진을 업로드합니다.
/// - Parameters:
/// - user: 업로드하는 유저 정보
/// - imgData: 업로드할 사진 데이터
func uploadPhoto(of user: User, imgData: Data) async throws {

let photo = Photo(of: user, imgData: imgData)
Expand Down Expand Up @@ -59,67 +120,27 @@ extension PhotoViewModel {
}
}

func syncPhoto(of userName: String?) async {
guard let userName else { return }

do {
let remoteData = try await remoteStorageService.fetchPhotos(userName)
let localData = await localStorageService.fetchPhotos()

// 3. 원격 데이터를 기준으로 로컬 데이터 업데이트
let remoteSet = Set(remoteData)
let localSet = Set(localData)

// (1) 업데이트할 항목: 동일한 ID를 가진 항목 중 데이터가 다른 항목
let photosToUpdate = Array(localSet.intersection(remoteSet))
.filter { localItem in
guard let remoteItem = remoteSet.first(where: { $0.id == localItem.id }) else { return false }

return false
// return localItem.likeCount != remoteItem.likeCount
}

try await localStorageService.updatePhotos(photosToUpdate)

// (2) 추가할 항목: 원격에만 있는 데이터
var photosToAdd = Array(remoteSet.subtracting(localSet))

for i in 0..<photosToAdd.count {
guard let urlString = photosToAdd[i].urlString else { continue }

photosToAdd[i].imgData = try await remoteStorageService.downloadPhotoData(of: urlString)
}

try await localStorageService.addPhotos(photosToAdd)

// (3) 삭제할 항목: 로컬에만 있는 데이터
let photosToDelete = Array(localSet.subtracting(remoteSet))
try await localStorageService.deletePhotos(photosToDelete.map { $0.id })

print("총\(remoteData.count) 개의 이미지")
print("\(photosToUpdate.count + photosToAdd.count + photosToDelete.count) 개의 이미지 동기화함")
print("\(photosToUpdate.count) 개의 사진 업데이트됨")
print("\(photosToAdd.count) 개의 사진 추가됨")
print("\(photosToDelete.count) 개의 사진 삭제됨")

let photos = await localStorageService.fetchPhotos()
await MainActor.run {
self.photos = photos
}

} catch {
await GlobalAlert.shared.show(message: "동기화 실패")
}
/// 원격 저장소에 저장된 사진을 다운로드합니다.
/// - Parameter urlString: urlString
/// - Returns: 사진 Data
func downloadPhoto(of urlString: String) async throws -> Data {
try await remoteStorageService.downloadPhotoData(of: urlString)
}

/// 로컬 사진을 업데이트 합니다.
/// - Parameter photo: 업데이트할 사진 데이터
func updateLocal(of photo: Photo) async throws {
try await localStorageService.updatePhoto(photo)
}

/// 해당 사진을 삭제합니다.
///
/// 1. photos에서 우선 사진을 삭제해 UI에 반영합니다.
/// 2. 비동기적으로 원격 / 로컬에서 사진을 삭제합니다.
/// 3. 에러 발생 시 삭제한 UI를 복구합니다.
///
/// - Parameter photo: 삭제할 photo
func deletePhoto(_ photo: Photo) async throws {
func delete(_ photo: Photo) async throws {

guard let idx = photos.firstIndex(where: { $0.id == photo.id }) else { return }

Expand All @@ -142,6 +163,7 @@ extension PhotoViewModel {
}
}

/// 모든 로컬 사진을 삭제합니다.
func deleteAllLocal() async throws {
try await localStorageService.deleteAllPhotos()
}
Expand Down
8 changes: 4 additions & 4 deletions PicCharge/PicCharge.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
420E48F22D36E5C0008944A4 /* String+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420E48F12D36E5C0008944A4 /* String+.swift */; };
420E48F32D36E5C0008944A4 /* String+.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420E48F12D36E5C0008944A4 /* String+.swift */; };
420E48FB2D376D66008944A4 /* SignUpEmailPasswordView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420E48FA2D376D66008944A4 /* SignUpEmailPasswordView.swift */; };
42141E192D2ECFD200E2609A /* SquareImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42141E182D2ECFD200E2609A /* SquareImage.swift */; };
420E492A2D4134DA008944A4 /* AsyncSquareImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 420E49292D4134DA008944A4 /* AsyncSquareImage.swift */; };
4214D9EE2C0330260096D48B /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 4214D9ED2C0330260096D48B /* FirebaseFirestore */; };
4214D9F02C0330260096D48B /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 4214D9EF2C0330260096D48B /* FirebaseFirestoreSwift */; };
4214D9F22C0330260096D48B /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 4214D9F12C0330260096D48B /* FirebaseStorage */; };
Expand Down Expand Up @@ -177,7 +177,7 @@
420E48ED2D36C56A008944A4 /* FilledBtn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilledBtn.swift; sourceTree = "<group>"; };
420E48F12D36E5C0008944A4 /* String+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+.swift"; sourceTree = "<group>"; };
420E48FA2D376D66008944A4 /* SignUpEmailPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpEmailPasswordView.swift; sourceTree = "<group>"; };
42141E182D2ECFD200E2609A /* SquareImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SquareImage.swift; sourceTree = "<group>"; };
420E49292D4134DA008944A4 /* AsyncSquareImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncSquareImage.swift; sourceTree = "<group>"; };
4214D9FD2C0387D40096D48B /* ChildWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildWidget.swift; sourceTree = "<group>"; };
4214D9FF2C0387F20096D48B /* UIImage+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+.swift"; sourceTree = "<group>"; };
4214DA132C0392080096D48B /* UserDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDTO.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -470,7 +470,7 @@
E33AFF632BFC389800BFB665 /* ChildLoadingView.swift */,
518752062C00A8CE00005AF8 /* BuggungLoadingView.swift */,
E33AFF6B2BFC3ACA00BFB665 /* LottieView.swift */,
42141E182D2ECFD200E2609A /* SquareImage.swift */,
420E49292D4134DA008944A4 /* AsyncSquareImage.swift */,
420E48ED2D36C56A008944A4 /* FilledBtn.swift */,
);
path = Components;
Expand Down Expand Up @@ -798,7 +798,6 @@
420E48F22D36E5C0008944A4 /* String+.swift in Sources */,
4280467E2BF5DF270097EB0B /* ChildSendGalleryView.swift in Sources */,
428046862BF5DFFF0097EB0B /* ParentAlbumView.swift in Sources */,
42141E192D2ECFD200E2609A /* SquareImage.swift in Sources */,
552E24E32BF9CB7500E5CF24 /* FirestoreService.swift in Sources */,
421A8C672BFA358A003C7001 /* Color+.swift in Sources */,
428046722BF5DDA80097EB0B /* ConnectUserView.swift in Sources */,
Expand Down Expand Up @@ -828,6 +827,7 @@
55B5E3642BF5A13E00E413C6 /* PicChargeApp.swift in Sources */,
425B442C2D1FEA02001E23FA /* UserViewModel.swift in Sources */,
4214DA172C0392310096D48B /* PhotoDTO_V0.swift in Sources */,
420E492A2D4134DA008944A4 /* AsyncSquareImage.swift in Sources */,
4280467C2BF5DEF40097EB0B /* ChildSelectGalleryView.swift in Sources */,
4214DA212C0393830096D48B /* AppDelgate.swift in Sources */,
42CF6FDC2D22F1D40054F244 /* SwiftDataRepository.swift in Sources */,
Expand Down
40 changes: 35 additions & 5 deletions PicCharge/PicCharge/Model/Photo/Photo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,32 @@

import Foundation

struct Reaction {
struct Reaction: Equatable {
var love: Int
var fire: Int
var star: Int
var like: Int
}

struct Photo: Hashable, Identifiable {
@Observable
class Photo: Hashable, Identifiable {
let id: UUID
var uploadBy: String
var uploadDate: Date
var imgData: Data?
var urlString: String?
var reaction: Reaction
var sharedWith: [String]

init(id: UUID, uploadBy: String, uploadDate: Date, imgData: Data? = nil, urlString: String? = nil, reaction: Reaction, sharedWith: [String]) {
self.id = id
self.uploadBy = uploadBy
self.uploadDate = uploadDate
self.imgData = imgData
self.urlString = urlString
self.reaction = reaction
self.sharedWith = sharedWith
}
}

extension Photo {
Expand All @@ -35,7 +46,7 @@ extension Photo {
}

extension Photo {
init(of user: User, imgData: Data) {
convenience init(of user: User, imgData: Data) {
self.init(
id: UUID(),
uploadBy: user.name,
Expand All @@ -47,7 +58,6 @@ extension Photo {
}
}

#if DEBUG
import UIKit

extension Photo {
Expand All @@ -72,8 +82,28 @@ extension Photo {
reaction: .init(love: 0, fire: 0, star: 0, like: 0),
sharedWith: []
)

static let dataInRemote: Photo = Photo(
id: UUID(),
uploadBy: "Mock",
uploadDate: .now.addingTimeInterval(Double.random(in: 0...10) * 3600),
imgData: nil,
urlString:
"https://firebasestorage.googleapis.com:443/v0/b/piccharge-afbc7.appspot.com/o/photos%2F%EC%97%90%EC%9D%B4%EC%8A%A4%2F008373A3-1175-42F2-8C73-F381C363A57D.jpg?alt=media&token=7849bedc-28a2-4316-bfc4-81c86fa284a8",
reaction: .init(love: 0, fire: 0, star: 0, like: 0),
sharedWith: []
)

static let dataInLocal: Photo = Photo(
id: UUID(),
uploadBy: "Mock",
uploadDate: .now.addingTimeInterval(Double.random(in: 0...10) * 3600),
imgData: UIImage(resource: .logoLarge).pngData()!,
urlString: nil,
reaction: .init(love: 0, fire: 0, star: 0, like: 0),
sharedWith: []
)
}
#endif

enum PhotoSortOption {
case uploadDate
Expand Down
6 changes: 6 additions & 0 deletions PicCharge/PicCharge/Model/Photo/PhotoEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ final class PhotoEntity {
@Attribute(.unique) var id: UUID
var uploadBy: String // 유저 닉네임
var uploadDate: Date // 업로드 날짜
var urlString: String?
var loveCount: Int // 좋아요 개수
var fireCount: Int // 좋아요 개수
var starCount: Int // 좋아요 개수
Expand All @@ -25,6 +26,7 @@ final class PhotoEntity {
id: UUID = UUID(),
uploadBy: String,
uploadDate: Date = Date(),
urlString: String?,
loveCount: Int = 0,
fireCount: Int = 0,
starCount: Int = 0,
Expand All @@ -35,6 +37,7 @@ final class PhotoEntity {
self.id = id
self.uploadBy = uploadBy
self.uploadDate = uploadDate
self.urlString = urlString
self.loveCount = loveCount
self.fireCount = fireCount
self.starCount = starCount
Expand All @@ -49,6 +52,7 @@ final class PhotoEntity {
id: UUID(uuidString: photo.id ?? UUID().uuidString) ?? UUID(),
uploadBy: photo.uploadBy,
uploadDate: photo.uploadDate,
urlString: photo.urlString,
loveCount: 0,
fireCount: 0,
starCount: 0,
Expand All @@ -75,6 +79,7 @@ extension PhotoEntity: DomainConvertible {
id: domain.id,
uploadBy: domain.uploadBy,
uploadDate: domain.uploadDate,
urlString: domain.urlString,
loveCount: domain.reaction.love,
fireCount: domain.reaction.fire,
starCount: domain.reaction.star,
Expand All @@ -90,6 +95,7 @@ extension PhotoEntity: DomainConvertible {
uploadBy: uploadBy,
uploadDate: uploadDate,
imgData: imgData,
urlString: urlString,
reaction: .init(
love: loveCount,
fire: fireCount,
Expand Down
2 changes: 1 addition & 1 deletion PicCharge/PicCharge/Model/Photo/PhotoShareDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct PhotoShareDTO: Transferable {
}

init(imgData: Data, uploadDate: Date) {
self.image = Image(uiImage: UIImage(data: imgData)!)
self.image = Image(uiImage: UIImage(data: imgData) ?? UIImage(resource: .child))
self.caption = "\(uploadDate.toKR()) 자식사진"
}

Expand Down
4 changes: 4 additions & 0 deletions PicCharge/PicCharge/Service/SwiftDataRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ extension SwiftDataRepository {
try photoStorage.create(photos.map { PhotoEntity($0) })
}

func updatePhoto(_ photo: Photo) async throws {
try photoStorage.update(PhotoEntity(photo))
}

func updatePhotos(_ photos: [Photo]) async throws {
try photoStorage.update(photos.map { PhotoEntity($0) })
}
Expand Down
4 changes: 2 additions & 2 deletions PicCharge/PicCharge/View/Child/ChildAlbumDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct ChildAlbumDetailView: View {
TabView(selection: $photo) {
ForEach(photoVM.photos) { photo in
VStack {
SquareImage(data: photo.imgData)
AsyncSquareImage(photo: photo)
.zoomable(isZooming: $isZooming)
.padding(.top, 72)

Expand Down Expand Up @@ -119,7 +119,7 @@ struct ChildAlbumDetailView: View {
Task.detached {
do {
// 1. 사진 삭제
try await photoVM.deletePhoto(photo)
try await photoVM.delete(photo)
// 2. 위젯 리로드
WidgetCenter.shared.reloadAllTimelines()
// 3. 남은 Photo 없다면 이전 화면으로
Expand Down
Loading