From 2250441f59805bcc24ffb23d20ab97bb5f58c59f Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 8 Feb 2026 22:50:04 +0900 Subject: [PATCH 01/21] =?UTF-8?q?refactor:=20=EC=9C=A0=EC=A0=80=20uid=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageService.swift | 40 +++++++++++++++-------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 1d88a3d..2033398 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -5,14 +5,19 @@ // Created by opfic on 6/3/25. // -import Foundation +import FirebaseAuth import FirebaseFirestore -class WebPageService { +final class WebPageService { private let store = Firestore.firestore() - - func requestWebPages(userId: String) async throws -> [WebPageInfo] { - let webPageInfoRef = store.document("users/\(userId)/userData/webPageInfos") + + /// 저장한 웹페이지를 모두 불러옴 + func fetchWebPages() async throws -> [WebPageInfo] { + guard let uid = Auth.auth().currentUser?.uid else { + throw AuthError.notAuthenticated + } + + let webPageInfoRef = store.document("users/\(uid)/userData/webPageInfos") let doc = try await webPageInfoRef.getDocument() if doc.exists, let data = doc.data() { @@ -36,17 +41,26 @@ class WebPageService { } throw URLError(.badServerResponse) } - - func upsertWebPage(webPageInfo: WebPageInfo, userId: String) async throws { - let webPageInfosRef = store.document("users/\(userId)/userData/webPageInfos") - try await webPageInfosRef.setData( - ["WebPageInfos": FieldValue.arrayUnion([webPageInfo.url.description])], + + /// 웹페이지를 추가 또는 업데이트 + func upsertWebPage(_ info: WebPageInfo) async throws { + guard let uid = Auth.auth().currentUser?.uid else { + throw AuthError.notAuthenticated + } + + let infosRef = store.document("users/\(uid)/userData/webPageInfos") + try await infosRef.setData( + ["WebPageInfos": FieldValue.arrayUnion([info.url.description])], merge: true ) } - func deleteWebPage(webPageInfo: WebPageInfo, userId: String) async throws { - let webPageInfosRef = store.document("users/\(userId)/userData/webPageInfos") - try await webPageInfosRef.updateData(["WebPageInfos": FieldValue.arrayRemove([webPageInfo.url.description])]) + func deleteWebPage(_ info: WebPageInfo) async throws { + guard let uid = Auth.auth().currentUser?.uid else { + throw AuthError.notAuthenticated + } + + let infosRef = store.document("users/\(uid)/userData/webPageInfos") + try await infosRef.updateData(["WebPageInfos": FieldValue.arrayRemove([info.url.description])]) } } From 4199a84654ef6fa1f61e981eb1f97d130963e2ef Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 8 Feb 2026 23:04:56 +0900 Subject: [PATCH 02/21] =?UTF-8?q?refactor:=20=ED=95=84=EC=9A=94=ED=95=9C?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A7=8C=EC=9D=84=20=EB=B0=9B?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageService.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 2033398..666acbf 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -43,24 +43,24 @@ final class WebPageService { } /// 웹페이지를 추가 또는 업데이트 - func upsertWebPage(_ info: WebPageInfo) async throws { + func upsertWebPage(_ urlString: String) async throws { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } let infosRef = store.document("users/\(uid)/userData/webPageInfos") try await infosRef.setData( - ["WebPageInfos": FieldValue.arrayUnion([info.url.description])], + ["WebPageInfos": FieldValue.arrayUnion([urlString])], merge: true ) } - func deleteWebPage(_ info: WebPageInfo) async throws { + func deleteWebPage(_ urlString: String) async throws { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } let infosRef = store.document("users/\(uid)/userData/webPageInfos") - try await infosRef.updateData(["WebPageInfos": FieldValue.arrayRemove([info.url.description])]) + try await infosRef.updateData(["WebPageInfos": FieldValue.arrayRemove([urlString])]) } } From 9a1a03654c631fd7c3490559584c07ad8dee82df Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 00:56:43 +0900 Subject: [PATCH 03/21] =?UTF-8?q?refactor:=20=EC=88=9C=EC=88=98=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=9E=AC?= =?UTF-8?q?=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Domain/Entity/WebPage.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 DevLog/Domain/Entity/WebPage.swift diff --git a/DevLog/Domain/Entity/WebPage.swift b/DevLog/Domain/Entity/WebPage.swift new file mode 100644 index 0000000..212bb0b --- /dev/null +++ b/DevLog/Domain/Entity/WebPage.swift @@ -0,0 +1,13 @@ +// +// WebPage.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +import Foundation + +struct WebPage { + let title: String + let url: URL +} From 1e3449ba35bb408235765341d26e2fff164e4e72 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 01:18:04 +0900 Subject: [PATCH 04/21] =?UTF-8?q?refactor:=20=EC=88=9C=EC=88=98=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/DTO/WebPageResponse.swift | 10 ++++++++++ DevLog/Infra/Service/WebPageService.swift | 20 +++----------------- 2 files changed, 13 insertions(+), 17 deletions(-) create mode 100644 DevLog/Infra/DTO/WebPageResponse.swift diff --git a/DevLog/Infra/DTO/WebPageResponse.swift b/DevLog/Infra/DTO/WebPageResponse.swift new file mode 100644 index 0000000..c940437 --- /dev/null +++ b/DevLog/Infra/DTO/WebPageResponse.swift @@ -0,0 +1,10 @@ +// +// WebPageResponse.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +struct WebPageResponse { + let urlString: String +} diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 666acbf..38b909a 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -12,7 +12,7 @@ final class WebPageService { private let store = Firestore.firestore() /// 저장한 웹페이지를 모두 불러옴 - func fetchWebPages() async throws -> [WebPageInfo] { + func fetchWebPages() async throws -> [WebPageResponse] { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } @@ -22,21 +22,7 @@ final class WebPageService { if doc.exists, let data = doc.data() { if let webPageInfos = data["webPageInfos"] as? [String] { - return try await withThrowingTaskGroup(of: WebPageInfo.self, returning: [WebPageInfo].self) { group in - for urlString in webPageInfos { - group.addTask { - let doc = try await WebPageInfo.fetch(from: urlString) - return doc - } - } - - var result = [WebPageInfo]() - for try await pageInfo in group { - result.append(pageInfo) - } - - return result - } + return webPageInfos.map { WebPageResponse(urlString: $0) } } } throw URLError(.badServerResponse) @@ -54,7 +40,7 @@ final class WebPageService { merge: true ) } - + func deleteWebPage(_ urlString: String) async throws { guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated From a68ce48004edb13f818432fe8a727887257d13f7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 10:57:20 +0900 Subject: [PATCH 05/21] =?UTF-8?q?fix:=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=ED=95=84=EB=93=9C=EB=AA=85=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 38b909a..833a4d9 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -36,7 +36,7 @@ final class WebPageService { let infosRef = store.document("users/\(uid)/userData/webPageInfos") try await infosRef.setData( - ["WebPageInfos": FieldValue.arrayUnion([urlString])], + ["webPageInfos": FieldValue.arrayUnion([urlString])], merge: true ) } From 152ab5fb1c8d06f424443379ea8a7824ceb38150 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 13:40:38 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20=EA=B0=81=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20DTO=20/=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Data/DTO/WebPageMetadata.swift | 24 ++++++ DevLog/Domain/Entity/WebPage.swift | 4 +- DevLog/Infra/DTO/WebPageInfo.swift | 76 ------------------- .../Presentation/Structure/WebPageItem.swift | 32 ++++++++ 4 files changed, 59 insertions(+), 77 deletions(-) create mode 100644 DevLog/Data/DTO/WebPageMetadata.swift delete mode 100644 DevLog/Infra/DTO/WebPageInfo.swift create mode 100644 DevLog/Presentation/Structure/WebPageItem.swift diff --git a/DevLog/Data/DTO/WebPageMetadata.swift b/DevLog/Data/DTO/WebPageMetadata.swift new file mode 100644 index 0000000..06c43f7 --- /dev/null +++ b/DevLog/Data/DTO/WebPageMetadata.swift @@ -0,0 +1,24 @@ +// +// WebPageMetadata.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +import Foundation + +struct WebPageMetadata: Hashable { + let title: String? + let url: URL + let displayURL: URL + let imageURL: URL? + + func toDomain() -> WebPage { + WebPage( + title: title, + url: url, + displayURL: displayURL, + imageURL: imageURL + ) + } +} diff --git a/DevLog/Domain/Entity/WebPage.swift b/DevLog/Domain/Entity/WebPage.swift index 212bb0b..fb1b530 100644 --- a/DevLog/Domain/Entity/WebPage.swift +++ b/DevLog/Domain/Entity/WebPage.swift @@ -8,6 +8,8 @@ import Foundation struct WebPage { - let title: String + let title: String? let url: URL + let displayURL: URL + let imageURL: URL? } diff --git a/DevLog/Infra/DTO/WebPageInfo.swift b/DevLog/Infra/DTO/WebPageInfo.swift deleted file mode 100644 index 3f31db7..0000000 --- a/DevLog/Infra/DTO/WebPageInfo.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// WebPageInfo.swift -// DevLog -// -// Created by opfic on 5/23/25. -// - -import SwiftUI -import LinkPresentation -import UniformTypeIdentifiers - -struct WebPageInfo: Identifiable, Hashable { - let id = UUID() - var image: UIImage? - var title: String - var url: URL - var urlString: String - - init(image: UIImage?, title: String, url: URL, urlString: String) { - self.image = image - self.title = title - self.url = url - self.urlString = urlString - } - - static func fetch(from urlString: String) async throws -> WebPageInfo { - guard let url = URL(string: urlString) else { - throw URLError(.badURL) - } - - return await fetch(from: url) - } - - static func fetch(from url: URL) async -> WebPageInfo { - let provider = LPMetadataProvider() - var image: UIImage? - var title: String = "" - var urlString: String = url.absoluteString - - do { - let metadata = try await provider.startFetchingMetadata(for: url) - image = try await convertToImage(metadata) - title = metadata.title ?? "웹페이지를 찾을 수 없습니다" - urlString = metadata.url?.host() ?? url.absoluteString - } catch { - print("Error fetching metadata: \(error.localizedDescription)") - } - - return WebPageInfo(image: image, title: title, url: url, urlString: urlString) - } - - static func convertToImage(_ metaData: LPLinkMetadata) async throws -> UIImage? { - let imageType = UTType.image.identifier - - if let imageProvider = metaData.imageProvider, - imageProvider.hasItemConformingToTypeIdentifier(imageType) { - let imageItem = try await imageProvider.loadItem(forTypeIdentifier: imageType) - - switch imageItem { - case let uiImage as UIImage: - return uiImage - case let url as URL: - if let data = try? Data(contentsOf: url) { - return UIImage(data: data) - } - case let data as Data: - return UIImage(data: data) - case let nsData as NSData: - return UIImage(data: nsData as Data) - default: - return nil - } - } - return nil - } -} diff --git a/DevLog/Presentation/Structure/WebPageItem.swift b/DevLog/Presentation/Structure/WebPageItem.swift new file mode 100644 index 0000000..2133a7b --- /dev/null +++ b/DevLog/Presentation/Structure/WebPageItem.swift @@ -0,0 +1,32 @@ +// +// WebPageItem.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +import SwiftUI + +struct WebPageItem: Identifiable, Hashable { + private let metadata: WebPage + + init(from metadata: WebPage) { + self.metadata = metadata + } + + var id: URL { metadata.url } + var title: String { metadata.title ?? "웹페이지를 찾을 수 없습니다" } + var url: URL { metadata.url } + var displayURL: String { metadata.displayURL.absoluteString } + var imageURL: URL? { metadata.imageURL } +} + +extension WebPageItem { + func hash(into hasher: inout Hasher) { + hasher.combine(metadata.url) + } + + static func == (lhs: WebPageItem, rhs: WebPageItem) -> Bool { + lhs.metadata.url == rhs.metadata.url + } +} From a3991534165b88f520924c81fcf1676affb56938 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 13:42:51 +0900 Subject: [PATCH 07/21] =?UTF-8?q?feat:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20=EB=A9=94=ED=83=80=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B0=9D=EC=B2=B4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/InfraAssembler.swift | 8 +++ .../Service/WebPageMetadataService.swift | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 DevLog/Infra/Service/WebPageMetadataService.swift diff --git a/DevLog/App/Assembler/InfraAssembler.swift b/DevLog/App/Assembler/InfraAssembler.swift index c0843f7..9a9d76e 100644 --- a/DevLog/App/Assembler/InfraAssembler.swift +++ b/DevLog/App/Assembler/InfraAssembler.swift @@ -43,5 +43,13 @@ final class InfraAssembler: Assembler { container.register(PushNotificationService.self) { PushNotificationService() } + + container.register(WebPageService.self) { + WebPageService() + } + + container.register(WebPageMetadataService.self) { + WebPageMetadataService() + } } } diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift new file mode 100644 index 0000000..c4b4828 --- /dev/null +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -0,0 +1,54 @@ +// +// WebPageMetadataService.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +import Foundation +import LinkPresentation + +final class WebPageMetadataService { + func fetchMetadata(from response: WebPageResponse) async throws -> WebPageMetadata { + guard let url = URL(string: response.urlString) else { + throw URLError(.badURL) + } + + let provider = LPMetadataProvider() + let metadata = try await provider.startFetchingMetadata(for: url) + + let imageURL = try await extractImageURL(from: metadata.imageProvider, url: url) + + return WebPageMetadata( + title: metadata.title, + url: url, + displayURL: metadata.url ?? url, + imageURL: imageURL + ) + } + + private func extractImageURL(from imageProvider: NSItemProvider?, url: URL) async throws -> URL? { + guard let imageProvider else { return nil } + + return try await withCheckedThrowingContinuation { continuation in + imageProvider.loadObject(ofClass: UIImage.self) { image, error in + guard let image = image as? UIImage, + let data = image.jpegData(compressionQuality: 1.0) else { + continuation.resume(returning: nil) + return + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(url.absoluteString) + .appendingPathExtension("jpeg") + + do { + try data.write(to: tempURL) + continuation.resume(returning: tempURL) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} From 46de91171c241c1fca841135f6514274785b6317 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 13:43:50 +0900 Subject: [PATCH 08/21] =?UTF-8?q?feat:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=9D=98=20=EB=A9=94=ED=83=80=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A0=88=EC=9D=B4=EC=96=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DataAssembler.swift | 7 +++ .../Repository/WebPageRepositoryImpl.swift | 50 +++++++++++++++++++ .../Domain/Protocol/WebPageRepository.swift | 12 +++++ 3 files changed, 69 insertions(+) create mode 100644 DevLog/Data/Repository/WebPageRepositoryImpl.swift create mode 100644 DevLog/Domain/Protocol/WebPageRepository.swift diff --git a/DevLog/App/Assembler/DataAssembler.swift b/DevLog/App/Assembler/DataAssembler.swift index 8f84964..a918753 100644 --- a/DevLog/App/Assembler/DataAssembler.swift +++ b/DevLog/App/Assembler/DataAssembler.swift @@ -44,5 +44,12 @@ final class DataAssembler: Assembler { container.register(PushNotificationRepository.self) { PushNotificationRepositoryImpl(pushNotificationService: container.resolve(PushNotificationService.self)) } + + container.register(WebPageRepository.self) { + WebPageRepositoryImpl( + webPageService: container.resolve(WebPageService.self), + metadataService: container.resolve(WebPageMetadataService.self) + ) + } } } diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift new file mode 100644 index 0000000..fbe4933 --- /dev/null +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -0,0 +1,50 @@ +// +// WebPageRepositoryImpl.swift +// DevLog +// +// Created by 최윤진 on 2/8/26. +// + +final class WebPageRepositoryImpl: WebPageRepository { + private let webPageService: WebPageService + private let metadataService: WebPageMetadataService + + init( + webPageService: WebPageService, + metadataService: WebPageMetadataService + ) { + self.webPageService = webPageService + self.metadataService = metadataService + } + + func fetch() async throws -> [WebPageMetadata] { + let responses = try await webPageService.fetchWebPages() + + return try await withThrowingTaskGroup(of: WebPageMetadata?.self) { group in + for response in responses { + group.addTask { + try? await self.metadataService.fetchMetadata(from: response) + } + } + + var results: [WebPageMetadata] = [] + for try await metadata in group { + if let metadata { + results.append(metadata) + } + } + + return results + } + } + + func upsert(_ urlString: String) async throws -> WebPageMetadata { + try await webPageService.upsertWebPage(urlString) + let response = WebPageResponse(urlString: urlString) + return try await metadataService.fetchMetadata(from: response) + } + + func delete(_ urlString: String) async throws { + try await webPageService.deleteWebPage(urlString) + } +} diff --git a/DevLog/Domain/Protocol/WebPageRepository.swift b/DevLog/Domain/Protocol/WebPageRepository.swift new file mode 100644 index 0000000..bd9dea7 --- /dev/null +++ b/DevLog/Domain/Protocol/WebPageRepository.swift @@ -0,0 +1,12 @@ +// +// WebPageRepository.swift +// DevLog +// +// Created by 최윤진 on 2/8/26. +// + +protocol WebPageRepository { + func fetch() async throws -> [WebPageMetadata] + func upsert(_ urlString: String) async throws -> WebPageMetadata + func delete(_ urlString: String) async throws +} From 6a287fd7a48da7c495eca87f9c199a31979c4c9e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 13:44:37 +0900 Subject: [PATCH 09/21] =?UTF-8?q?feat:=20=EC=9B=B9=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8A=94=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/App/Assembler/DomainAssembler.swift | 12 ++++++++++++ .../WebPage/Fetch/FetchWebPagesUseCase.swift | 11 +++++++++++ .../Fetch/FetchWebPagesUseCaseImpl.swift | 18 ++++++++++++++++++ .../WebPage/Upsert/AddWebPageUseCase.swift | 11 +++++++++++ .../Upsert/AddWebPageUseCaseImpl.swift | 18 ++++++++++++++++++ .../WebPage/Upsert/DeleteWebPageUseCase.swift | 11 +++++++++++ .../Upsert/DeleteWebPageUseCaseImpl.swift | 19 +++++++++++++++++++ 7 files changed, 100 insertions(+) create mode 100644 DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCase.swift create mode 100644 DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCaseImpl.swift create mode 100644 DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift create mode 100644 DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift create mode 100644 DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift create mode 100644 DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index bc9f9c1..75b5f92 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -50,5 +50,17 @@ final class DomainAssembler: Assembler { container.register(FetchTodosByKindUseCase.self) { FetchTodosByKindUseCaseImpl(container.resolve(TodoRepository.self)) } + + container.register(FetchWebPagesUseCase.self) { + FetchWebPagesUseCaseImpl(container.resolve(WebPageRepository.self)) + } + + container.register(AddWebPageUseCase.self) { + AddWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) + } + + container.register(DeleteWebPageUseCase.self) { + DeleteWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) + } } } diff --git a/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCase.swift b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCase.swift new file mode 100644 index 0000000..1417cf1 --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCase.swift @@ -0,0 +1,11 @@ +// +// FetchWebPagesUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +protocol FetchWebPagesUseCase { + var repository: WebPageRepository { get } + func execute() async throws -> [WebPage] +} diff --git a/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCaseImpl.swift new file mode 100644 index 0000000..c40eb0f --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Fetch/FetchWebPagesUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// FetchWebUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +final class FetchWebPagesUseCaseImpl: FetchWebPagesUseCase { + let repository: WebPageRepository + + init(_ repository: WebPageRepository) { + self.repository = repository + } + + func execute() async throws -> [WebPage] { + return try await repository.fetch().map { $0.toDomain() } + } +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift new file mode 100644 index 0000000..1debca8 --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift @@ -0,0 +1,11 @@ +// +// AddWebPageUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/8/26. +// + +protocol AddWebPageUseCase { + var repository: WebPageRepository { get } + func execute(_ urlString: String) async throws -> WebPage +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift new file mode 100644 index 0000000..cfa5f16 --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// AddWebPageUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +final class AddWebPageUseCaseImpl: AddWebPageUseCase { + let repository: WebPageRepository + + init(_ repository: WebPageRepository) { + self.repository = repository + } + + func execute(_ urlString: String) async throws -> WebPage { + try await repository.upsert(urlString).toDomain() + } +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift b/DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift new file mode 100644 index 0000000..8ba03cf --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCase.swift @@ -0,0 +1,11 @@ +// +// DeleteWebPageUseCase.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +protocol DeleteWebPageUseCase { + var repository: WebPageRepository { get } + func execute(_ urlString: String) async throws +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift new file mode 100644 index 0000000..fe1b6a5 --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/DeleteWebPageUseCaseImpl.swift @@ -0,0 +1,19 @@ +// +// DeleteWebPageUseCaseImpl.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +final class DeleteWebPageUseCaseImpl: DeleteWebPageUseCase { + var repository: WebPageRepository + + init(_ repository: WebPageRepository) { + self.repository = repository + } + + func execute(_ urlString: String) async throws { + try await repository.delete(urlString) + } +} + From 1431ca306b01a4f8f94b117cb0b5dd9be5da2410 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 13:46:38 +0900 Subject: [PATCH 10/21] =?UTF-8?q?feat:=20SearchViewModel=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModel/SearchViewModel.swift | 165 +++++++ DevLog/Resource/Localizable.xcstrings | 12 + DevLog/UI/Common/MainView.swift | 30 +- DevLog/UI/Search/SearchView.swift | 417 +++++++++--------- 4 files changed, 413 insertions(+), 211 deletions(-) create mode 100644 DevLog/Presentation/ViewModel/SearchViewModel.swift diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift new file mode 100644 index 0000000..6a02df2 --- /dev/null +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -0,0 +1,165 @@ +// +// SearchViewModel.swift +// DevLog +// +// Created by 최윤진 on 2/8/26. +// + +import Foundation +import OrderedCollections + +final class SearchViewModel: Store { + struct State { + var alertMsg: String = "" + var isLoading: Bool = false + var isSearching: Bool = false + var newURL: String = "https://" + var searchQuery: String = "" + var selectedWebPage: WebPageItem? + var showAlert: Bool = false + var alertTitle: String = "" + var alertType: AlertType? + var alertMessage: String = "" + var webPages: OrderedSet = [] + var filteredWebPages: [WebPageItem] { + if searchQuery.isEmpty { + // TODO: 자주 방문한 것을 보여주는 기능으로 변경 필요 + return [] + } else { + return webPages.filter { + $0.title.localizedCaseInsensitiveContains(searchQuery) || + $0.displayURL.localizedCaseInsensitiveContains(searchQuery) + } + } + } + } + + enum Action { + case addWebPage(WebPageItem? = nil) + case deleteWebPage(item: WebPageItem, fromEffect: Bool = false) + case fetchWebPage([WebPageItem]? = nil) + case selectWebPage(WebPageItem) + case setAlert(isPresented: Bool, type: AlertType? = nil) + case setLoading(Bool) + case setNewURL(String = "https://") + case setSearching(Bool) + case setSearchQuery(String) + } + + enum SideEffect { + case fetch + case add(String) + case delete(WebPageItem) + } + + enum AlertType { + case addWebPage, error + } + + @Published private(set) var state: State = .init() + private let fetchWebPagesUseCase: FetchWebPagesUseCase + private let addWebPageUseCase: AddWebPageUseCase + private let deleteWebPageUseCase: DeleteWebPageUseCase + + init( + fetchWebPagesUseCase: FetchWebPagesUseCase, + addWebPageUseCase: AddWebPageUseCase, + deleteWebPageUseCase: DeleteWebPageUseCase + ) { + self.fetchWebPagesUseCase = fetchWebPagesUseCase + self.addWebPageUseCase = addWebPageUseCase + self.deleteWebPageUseCase = deleteWebPageUseCase + } + + func reduce(with action: Action) -> [SideEffect] { + var state = self.state + + switch action { + case .addWebPage(let item): + guard let item else { return [.add(state.newURL)] } + state.webPages.append(item) + case .deleteWebPage(let info, let fromEffect): + if !fromEffect { return [.delete(info)] } + state.webPages.removeAll { $0.url == info.url } + case .fetchWebPage(let items): + guard let items else { return [.fetch] } + // TODO: OrderedSet으로 바로 바꾸면 순서가 달라지는것 해결 + state.webPages = OrderedSet(items) + case .selectWebPage(let newValue): + state.selectedWebPage = newValue + case .setAlert(let isPresented, let type): + setAlert(isPresented: isPresented, for: type) + return [] + case .setLoading(let newValue): + state.isLoading = newValue + case .setNewURL(let newValue): + state.newURL = newValue + case .setSearching(let newValue): + state.isSearching = newValue + case .setSearchQuery(let newValue): + state.searchQuery = newValue + } + + self.state = state + return [] + } + + func run(_ effect: SideEffect) { + switch effect { + case .fetch: + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let items = try await fetchWebPagesUseCase.execute().map { WebPageItem(from: $0) } + send(.fetchWebPage(items)) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + case .add(let urlString): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + let metadata = try await addWebPageUseCase.execute(urlString) + let item = WebPageItem(from: metadata) + send(.addWebPage(item)) + send(.setNewURL()) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + case .delete(let item): + Task { + do { + defer { send(.setLoading(false)) } + send(.setLoading(true)) + + try await deleteWebPageUseCase.execute(item.url.absoluteString) + send(.deleteWebPage(item: item, fromEffect: true)) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + } + } +} + +private extension SearchViewModel { + func setAlert(isPresented: Bool, for type: AlertType?) { + switch type { + case .addWebPage: + state.alertTitle = "웹 페이지 추가" + state.alertMessage = "" + case .error: + state.alertTitle = "오류" + state.alertMessage = "문제가 발생했습니다. 다시 시도해주세요." + case .none: + state.alertTitle = "" + state.alertMessage = "" + } + state.alertType = type + state.showAlert = isPresented + } +} diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index ef94b08..1a2d501 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -244,9 +244,15 @@ } } } + }, + "URL" : { + }, "개인정보 처리방침" : { + }, + "검색" : { + }, "계정 연동" : { @@ -307,6 +313,9 @@ }, "알림" : { + }, + "앱 내 컨텐츠를 검색할 수 있어요." : { + }, "어제" : { @@ -319,6 +328,9 @@ }, "작성된 내용이 없습니다." : { + }, + "저장된 웹페이지가 없습니다.\n우측 '+' 버튼을 눌러 웹페이지를 추가해보세요." : { + }, "정렬 옵션" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 3ea9224..16c99ba 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -16,28 +16,32 @@ struct MainView: View { upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), fetchPinnedTodosUseCase: container.resolve(FetchPinnedTodosUseCase.self) )) - .tabItem { - Image(systemName: "house.fill") - Text("홈") - } + .tabItem { + Image(systemName: "house.fill") + Text("홈") + } // NotificationView(notiVM: container.notiVM) // .tabItem { // Image(systemName: "bell.fill") // Text("알림") // } -// SearchView(searchVM: container.searchVM) -// .tabItem { -// Image(systemName: "magnifyingglass") -// Text("검색") -// } + SearchView(viewModel: SearchViewModel( + fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), + addWebPageUseCase: container.resolve(AddWebPageUseCase.self), + deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self) + )) + .tabItem { + Image(systemName: "magnifyingglass") + Text("검색") + } ProfileView(viewModel: ProfileViewModel( fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self) )) - .tabItem { - Image(systemName: "person.crop.circle.fill") - Text("프로필") - } + .tabItem { + Image(systemName: "person.crop.circle.fill") + Text("프로필") + } } } } diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index aefff6f..8334870 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -8,204 +8,225 @@ import SwiftUI struct SearchView: View { -// @Environment(\.dismiss) private var dismiss -// @ObservedObject private var searchVM: SearchViewModel -// -// init(searchVM: SearchViewModel) { -// self._searchVM = ObservedObject(wrappedValue: searchVM) -// } - + @Environment(\.dismiss) private var dismiss + @Environment(\.sceneWidth) private var sceneWidth + @Environment(\.sceneHeight) private var sceneHeight + @StateObject private var router = NavigationRouter() + @StateObject var viewModel: SearchViewModel + var body: some View { -// NavigationStack { -// // MARK: - 상단 검색바 -// Searchable(isSearching: $searchVM.isSearching) -// .searchable(text: $searchVM.searchText, prompt: "DevLog 검색") -// .navigationDestination(isPresented: Binding( -// get: { searchVM.selectedWebPage != nil }, -// set: { if !$0 { searchVM.selectedWebPage = nil } } -// )) { -// if let url = searchVM.selectedWebPage?.url { -// WebView(url: url) -// .navigationBarTitleDisplayMode(.inline) // 명시하지 않으면 iOS 18 미만에서는 Large 크기만큼의 상단의 영역을 차지 -// .toolbar { -// ToolbarItem(placement: .principal) { -// Text(searchVM.selectedWebPage!.title) -// .bold() -// } -// } -// } -// } -// GeometryReader { geometry in -// List { -// if searchVM.isSearching { -// if searchVM.searchText.isEmpty { -// VStack { -// Spacer() -// Text("앱 내 컨텐츠를 검색할 수 있어요.") -// .foregroundStyle(Color.gray) -// Spacer() -// } -// .frame(height: geometry.size.height) -// .frame(maxWidth: .infinity, alignment: .center) -// .listRowSeparator(.hidden) -// .listRowBackground(Color.clear) -// } else { -// let webPages = searchVM.webPages.filter { -// $0.title.localizedCaseInsensitiveContains(searchVM.searchText) || -// $0.urlString.localizedCaseInsensitiveContains(searchVM.searchText) -// } -// if !webPages.isEmpty { -// ForEach(webPages, id: \.id) { page in -// Button(action: { -// searchVM.selectedWebPage = page -// }) { -// HStack { -// Group { -// if let uiImage = page.image { -// Image(uiImage: uiImage) -// .resizable() -// .scaledToFill() -// } else { -// Image(systemName: "globe") -// .resizable() -// .scaledToFit() -// } -// } -// .frame( -// width: UIScreen.main.bounds.width / 5, -// height: UIScreen.main.bounds.width / 5 -// ) -// .clipShape(RoundedRectangle(cornerRadius: 10)) -// -// VStack(alignment: .leading) { -// Text(page.title) -// .foregroundStyle(Color.primary) -// .bold() -// Text(page.urlString) -// .foregroundStyle(Color.accentColor) -// .underline() -// } -// } -// } -// } -// } -// } -// } else { -// Section { -// if searchVM.webPages.isEmpty { -// Text("저장된 웹페이지가 없습니다.\n우측 '+' 버튼을 눌러 웹페이지를 추가해보세요.") -// .foregroundStyle(Color.gray) -// .frame(maxWidth: .infinity) -// .frame(height: geometry.size.height) -// .multilineTextAlignment(.center) -// } else { -// let webPages = searchVM.webPages -// ForEach(Array(zip(webPages.indices, webPages)), id: \.1.id) { idx, page in -// Button(action: { -// searchVM.selectedWebPage = page -// }) { -// ZStack(alignment: .bottom) { -// Color.white -// if let uiImage = page.image { -// GeometryReader { geo in -// Image(uiImage: uiImage) -// .resizable() -// .scaledToFill() -// .frame(width: geo.size.width, height: geo.size.height) -// .clipped() -// } -// } else { -// VStack { -// Image(systemName: "globe") -// .resizable() -// .scaledToFit() -// .frame(height: UIScreen.main.bounds.height / 5) -// .foregroundStyle(Color.gray) -// .padding() -// Spacer() -// } -// } -// HStack { -// VStack(alignment: .leading) { -// Text(page.title) -// .foregroundStyle(Color.black) -// .multilineTextAlignment(.leading) -// Text(page.urlString) -// .foregroundStyle(Color.accentColor) -// .underline() -// } -// .padding() -// Spacer() -// } -// .background(Color.white) -// } -// .clipShape(RoundedRectangle(cornerRadius: 15)) -// .frame(height: UIScreen.main.bounds.height / 4) -// } -// .swipeActions { -// Button(role: .destructive, action: { -// Task { -// await searchVM.deleteWebPage(webPage: page) -// searchVM.webPages.remove(at: idx) -// } -// }) { -// Image(systemName: "trash") -// } -// } -// } -// } -// } -// .listRowSeparator(.hidden) // 섹션 내 요소의 구분선 숨김 -// .listSectionSeparator(.hidden) // 섹션의 구분선 숨김 -// .listRowBackground(Color.clear) -// } -// } -// .listStyle(.plain) -// .navigationTitle("검색") -// .toolbar { -// ToolbarItem(placement: .navigationBarTrailing) { -// Button(action: { -// searchVM.addNewLink = true -// }) { -// Image(systemName: "plus") -// } -// } -// } -// .alert("", isPresented: $searchVM.showAlert) { -// Button("확인", role: .cancel) { -// searchVM.alertMsg = "" -// } -// } message: { -// Text(searchVM.alertMsg) -// } -// .alert("웹페이지 추가", isPresented: $searchVM.addNewLink) { -// TextField("URL", text: $searchVM.newURL) -// HStack { -// Button(action: { -// searchVM.newURL = "https://" -// dismiss() -// }) { -// Text("취소") -// } -// Button(action: { -// Task { -// let newPage = try await WebPageInfo.fetch(from: searchVM.newURL) -// await searchVM.upsertWebPage(webPage: newPage) -// searchVM.webPages.append(newPage) -// searchVM.newURL = "https://" -// dismiss() -// } -// }) { -// Text("추가") -// } -// } -// } -// .overlay { -// if searchVM.isLoading { -// LoadingView() -// } -// } -// } -// } + NavigationStack(path: $router.path) { + VStack { + searchable + if viewModel.state.isSearching { + if viewModel.state.searchQuery.isEmpty { + searchInstruction + } else { + ScrollView { + LazyVStack { + ForEach(viewModel.state.filteredWebPages, id: \.id) { page in + webInfoCard(page) + } + } + } + } + } else { + if viewModel.state.webPages.isEmpty { + webInstruction + } else { + List(viewModel.state.webPages, id: \.id) { page in + webInfoRaw(page) + .listRowSeparator(.hidden) // 섹션 내 요소의 구분선 숨김 + .listSectionSeparator(.hidden) // 섹션의 구분선 숨김 + .listRowBackground(Color.clear) + .swipeActions { + Button(role: .destructive, action: { + viewModel.send(.deleteWebPage(item: page)) + }) { + Image(systemName: "trash") + } + } + } + } + } + } + .navigationTitle("검색") + .navigationDestination(for: Path.self) { path in + switch path { + case .webView(let url): + WebView(url: url) + .navigationBarTitleDisplayMode(.inline) // 명시하지 않으면 iOS 18 미만에서는 Large 크기만큼의 상단의 영역을 차지 + .toolbar { + ToolbarItem(placement: .principal) { + Text(viewModel.state.selectedWebPage?.title ?? "") + .bold() + } + } + } + } + .task { viewModel.send(.fetchWebPage()) } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.send(.setAlert(isPresented: true, type: .addWebPage)) + } label: { + Image(systemName: "plus") + } + } + } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + )) { + if let type = viewModel.state.alertType { + alertView(type) + } + } message: { + Text(viewModel.state.alertMessage) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() + } + } + } + } + + @ViewBuilder + private func alertView(_ type: SearchViewModel.AlertType) -> some View { + switch type { + case .addWebPage: + TextField("URL", text: Binding( + get: { viewModel.state.newURL }, + set: { viewModel.send(.setNewURL($0)) } + )) + HStack { + Button { + viewModel.send(.setNewURL()) + dismiss() + } label: { + Text("취소") + } + Button { + viewModel.send(.addWebPage()) + dismiss() + } label: { + Text("추가") + } + } + case .error: + Button("확인", role: .cancel) {} + } + } + + private var searchable: some View { + Searchable(isSearching: Binding( + get: { viewModel.state.isSearching }, + set: { viewModel.send(.setSearching($0)) } + )) + .searchable( + text: Binding( + get: { viewModel.state.searchQuery }, + set: { viewModel.send(.setSearchQuery($0)) } + ), + prompt: "DevLog 검색") + } + + private var searchInstruction: some View { + VStack { + Spacer() + Text("앱 내 컨텐츠를 검색할 수 있어요.") + .foregroundStyle(Color.gray) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var webInstruction: some View { + Text("저장된 웹페이지가 없습니다.\n우측 '+' 버튼을 눌러 웹페이지를 추가해보세요.") + .foregroundStyle(Color.gray) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .multilineTextAlignment(.center) + } + + private func webInfoCard(_ item: WebPageItem) -> some View { + ZStack(alignment: .bottom) { + Color.white + GeometryReader { geometry in + AsyncImage(url: item.imageURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + default: + Image(systemName: "globe") + .resizable() + .scaledToFit() + .frame(height: UIScreen.main.bounds.height / 5) + .foregroundStyle(Color.gray) + .padding() + } + } + } + HStack { + VStack(alignment: .leading) { + Text(item.title) + .foregroundStyle(Color.black) + .multilineTextAlignment(.leading) + Text(item.displayURL) + .foregroundStyle(Color.accentColor) + .underline() + } + .padding() + Spacer() + } + .background(Color.white) + } + .clipShape(RoundedRectangle(cornerRadius: 15)) + .frame(height: sceneHeight / 4) + } + + private func webInfoRaw(_ item: WebPageItem) -> some View { + Button { + router.push(Path.webView(item.url)) + } label: { + HStack { + AsyncImage(url: item.imageURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + Image(systemName: "globe") + .resizable() + .scaledToFit() + } + } + .frame( + width: sceneWidth / 5, + height: sceneWidth / 5 + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading) { + Text(item.title) + .foregroundStyle(Color.primary) + .bold() + Text(item.displayURL) + .foregroundStyle(Color.accentColor) + .underline() + } + } + } + } + + private enum Path: Hashable { + case webView(URL) } } From a5683d6eb468ff226f222dd07fdd8eecf357bb18 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 14:16:26 +0900 Subject: [PATCH 11/21] =?UTF-8?q?fix:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=97=90=20=EB=94=B0=EB=A5=B8=20append?= =?UTF-8?q?=EB=A1=9C=20=EB=B7=B0=EC=97=90=20=ED=91=9C=EC=8B=9C=EB=90=A0=20?= =?UTF-8?q?=EB=95=8C=20=EC=88=9C=EC=84=9C=EA=B0=80=20=EB=8B=AC=EB=9D=BC?= =?UTF-8?q?=EC=A7=80=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/WebPageRepositoryImpl.swift | 18 +++++++++--------- .../ViewModel/SearchViewModel.swift | 1 - 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift index fbe4933..f480cb9 100644 --- a/DevLog/Data/Repository/WebPageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -19,22 +19,22 @@ final class WebPageRepositoryImpl: WebPageRepository { func fetch() async throws -> [WebPageMetadata] { let responses = try await webPageService.fetchWebPages() + let indexedResponses = responses.enumerated().map { ($0.offset, $0.element) } - return try await withThrowingTaskGroup(of: WebPageMetadata?.self) { group in - for response in responses { + return try await withThrowingTaskGroup(of: (Int, WebPageMetadata?).self) { group in + for (index, response) in indexedResponses { group.addTask { - try? await self.metadataService.fetchMetadata(from: response) + let metadata = try? await self.metadataService.fetchMetadata(from: response) + return (index, metadata) } } - var results: [WebPageMetadata] = [] - for try await metadata in group { - if let metadata { - results.append(metadata) - } + var results: [WebPageMetadata?] = Array(repeating: nil, count: responses.count) + for try await (index, metadata) in group { + results[index] = metadata } - return results + return results.compactMap { $0 } } } diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index 6a02df2..104456b 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -83,7 +83,6 @@ final class SearchViewModel: Store { state.webPages.removeAll { $0.url == info.url } case .fetchWebPage(let items): guard let items else { return [.fetch] } - // TODO: OrderedSet으로 바로 바꾸면 순서가 달라지는것 해결 state.webPages = OrderedSet(items) case .selectWebPage(let newValue): state.selectedWebPage = newValue From dc30e6cd9e39e76d0149ed34ce1a8b1e7c3aa3a7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 14:39:01 +0900 Subject: [PATCH 12/21] =?UTF-8?q?fix:=20LoadingView=20=EC=A4=91=EC=9D=BC?= =?UTF-8?q?=20=EB=95=8C=20=ED=95=98=EB=8B=A8=EC=97=90=20=EC=BB=A8=ED=85=90?= =?UTF-8?q?=EC=B8=A0=EA=B0=80=20=EB=B9=84=EC=B9=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Search/SearchView.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 8334870..a88e453 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -18,7 +18,9 @@ struct SearchView: View { NavigationStack(path: $router.path) { VStack { searchable - if viewModel.state.isSearching { + if viewModel.state.isLoading { + LoadingView() + } else if viewModel.state.isSearching { if viewModel.state.searchQuery.isEmpty { searchInstruction } else { @@ -86,11 +88,6 @@ struct SearchView: View { } message: { Text(viewModel.state.alertMessage) } - .overlay { - if viewModel.state.isLoading { - LoadingView() - } - } } } From 9a00f9c8df2e70db26e696116a0a400788d6e8fd Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 14:48:29 +0900 Subject: [PATCH 13/21] =?UTF-8?q?refactor:=20url=EC=9D=98=20host=20?= =?UTF-8?q?=EB=B0=8F=20url=EC=97=90=20=ED=8F=AC=ED=95=A8=EB=90=9C=20?= =?UTF-8?q?=ED=8A=B9=EC=88=98=EB=AC=B8=EC=9E=90=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageMetadataService.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift index c4b4828..4053d18 100644 --- a/DevLog/Infra/Service/WebPageMetadataService.swift +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -15,6 +15,8 @@ final class WebPageMetadataService { } let provider = LPMetadataProvider() + provider.timeout = 10.0 + let metadata = try await provider.startFetchingMetadata(for: url) let imageURL = try await extractImageURL(from: metadata.imageProvider, url: url) @@ -38,8 +40,13 @@ final class WebPageMetadataService { return } + guard let fileName = url.host?.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { + continuation.resume(returning: nil) + return + } + let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent(url.absoluteString) + .appendingPathComponent(fileName) .appendingPathExtension("jpeg") do { From 9fae0aef93549a7daafe419ed1b038af00236acc Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 14:57:36 +0900 Subject: [PATCH 14/21] =?UTF-8?q?design:=20=EC=A3=BC=EB=B3=80=20=ED=8C=A8?= =?UTF-8?q?=EB=94=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Search/SearchView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index a88e453..0ede4f0 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -39,8 +39,6 @@ struct SearchView: View { List(viewModel.state.webPages, id: \.id) { page in webInfoRaw(page) .listRowSeparator(.hidden) // 섹션 내 요소의 구분선 숨김 - .listSectionSeparator(.hidden) // 섹션의 구분선 숨김 - .listRowBackground(Color.clear) .swipeActions { Button(role: .destructive, action: { viewModel.send(.deleteWebPage(item: page)) @@ -49,6 +47,7 @@ struct SearchView: View { } } } + .listStyle(.plain) } } } From 26314f9a2bd557f443064c0752840602d2d25442 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 18:15:44 +0900 Subject: [PATCH 15/21] =?UTF-8?q?ui:=20padding=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Search/SearchView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 0ede4f0..57fd81e 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -30,6 +30,7 @@ struct SearchView: View { webInfoCard(page) } } + .padding(.horizontal) } } } else { @@ -65,7 +66,7 @@ struct SearchView: View { } } } - .task { viewModel.send(.fetchWebPage()) } + .onAppear { viewModel.send(.fetchWebPage()) } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { From 380e68a1236b9fb0feaf24cd698ea7549b7e71b5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 18:16:56 +0900 Subject: [PATCH 16/21] =?UTF-8?q?feat:=20webInfoCard=EC=97=90=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Search/SearchView.swift | 68 ++++++++++++++++--------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 57fd81e..f401091 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -149,43 +149,47 @@ struct SearchView: View { } private func webInfoCard(_ item: WebPageItem) -> some View { - ZStack(alignment: .bottom) { - Color.white - GeometryReader { geometry in - AsyncImage(url: item.imageURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: geometry.size.height) - .clipped() - default: - Image(systemName: "globe") - .resizable() - .scaledToFit() - .frame(height: UIScreen.main.bounds.height / 5) - .foregroundStyle(Color.gray) - .padding() + Button { + router.push(Path.webView(item.url)) + } label: { + ZStack(alignment: .bottom) { + Color.white + GeometryReader { geometry in + AsyncImage(url: item.imageURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + default: + Image(systemName: "globe") + .resizable() + .scaledToFit() + .frame(height: sceneHeight / 5) + .foregroundStyle(Color.gray) + .padding() + } } } - } - HStack { - VStack(alignment: .leading) { - Text(item.title) - .foregroundStyle(Color.black) - .multilineTextAlignment(.leading) - Text(item.displayURL) - .foregroundStyle(Color.accentColor) - .underline() + HStack { + VStack(alignment: .leading) { + Text(item.title) + .foregroundStyle(Color.black) + .multilineTextAlignment(.leading) + Text(item.displayURL) + .foregroundStyle(Color.accentColor) + .underline() + } + .padding() + Spacer() } - .padding() - Spacer() + .background(Color.white) } - .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .frame(height: sceneHeight / 4) } - .clipShape(RoundedRectangle(cornerRadius: 15)) - .frame(height: sceneHeight / 4) } private func webInfoRaw(_ item: WebPageItem) -> some View { From 1907d9caa17f1a53156fa4861dc974f75a71e01b Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 18:33:42 +0900 Subject: [PATCH 17/21] =?UTF-8?q?refactor:=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=9B=90=20url=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageMetadataService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DevLog/Infra/Service/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift index 4053d18..d40d031 100644 --- a/DevLog/Infra/Service/WebPageMetadataService.swift +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -40,7 +40,9 @@ final class WebPageMetadataService { return } - guard let fileName = url.host?.addingPercentEncoding(withAllowedCharacters: .alphanumerics) else { + guard let fileName = url.absoluteString.addingPercentEncoding( + withAllowedCharacters: .alphanumerics + ) else { continuation.resume(returning: nil) return } From 9972a8319144072f52d8fe4c5b9a01a27b5934ae Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 18:34:05 +0900 Subject: [PATCH 18/21] =?UTF-8?q?fix:=20=EC=8A=A4=EC=99=80=EC=9D=B4?= =?UTF-8?q?=ED=94=84=20=EC=95=A1=EC=85=98=EC=9C=BC=EB=A1=9C=20=EC=9B=B9?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=9A=94=EC=86=8C=EA=B0=80=20=EC=A0=9C=EA=B1=B0=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=98=84=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Infra/Service/WebPageService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 833a4d9..7ea88de 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -47,6 +47,6 @@ final class WebPageService { } let infosRef = store.document("users/\(uid)/userData/webPageInfos") - try await infosRef.updateData(["WebPageInfos": FieldValue.arrayRemove([urlString])]) + try await infosRef.updateData(["webPageInfos": FieldValue.arrayRemove([urlString])]) } } From dcf20915d310567c2088cadcd9d232f6b19f4a3a Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 23:09:16 +0900 Subject: [PATCH 19/21] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=A1=9C=EB=93=9C=ED=95=98=EC=A7=80=20=EB=AA=BB?= =?UTF-8?q?=ED=96=88=EC=9D=84=20=EB=95=8C=EC=9D=98=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EB=B0=96=EC=97=90=EC=84=9C=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=EC=98=AC=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/Common/Componeent/CacheableImage.swift | 19 ++++++--- DevLog/UI/Profile/ProfileView.swift | 2 +- DevLog/UI/Search/SearchView.swift | 39 ++++++------------- 3 files changed, 26 insertions(+), 34 deletions(-) diff --git a/DevLog/UI/Common/Componeent/CacheableImage.swift b/DevLog/UI/Common/Componeent/CacheableImage.swift index aa2de56..bdc94fd 100644 --- a/DevLog/UI/Common/Componeent/CacheableImage.swift +++ b/DevLog/UI/Common/Componeent/CacheableImage.swift @@ -7,14 +7,24 @@ import SwiftUI -struct CacheableImage: View { +struct CacheableImage: View { @State private var loadedUIImage: UIImage? @State private var isInvalid: Bool = false private let url: URL? private let request: URLRequest + @ViewBuilder private var content: () -> Content - init(_ url: URL?) { + init( + url: URL?, + @ViewBuilder content: @escaping () -> Content = { + Image(systemName: "photo") + .foregroundColor(.gray) + .font(.largeTitle) + .scaledToFill() + } + ) { self.url = url + self.content = content if let url { var request = URLRequest(url: url) request.cachePolicy = .returnCacheDataElseLoad @@ -33,10 +43,7 @@ struct CacheableImage: View { .resizable() .scaledToFill() } else if isInvalid { - Image(systemName: "photo") - .foregroundColor(.gray) - .font(.largeTitle) - .scaledToFill() + content() } else { ProgressView() } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 4040b4c..0db61de 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -19,7 +19,7 @@ struct ProfileView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { HStack { - CacheableImage(viewModel.state.avatarURL) + CacheableImage(url: viewModel.state.avatarURL) .frame(width: 60, height: 60) .cornerRadius(30) .foregroundStyle(Color.gray) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index f401091..2ae6da4 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -155,23 +155,15 @@ struct SearchView: View { ZStack(alignment: .bottom) { Color.white GeometryReader { geometry in - AsyncImage(url: item.imageURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - .frame(width: geometry.size.width, height: geometry.size.height) - .clipped() - default: - Image(systemName: "globe") - .resizable() - .scaledToFit() - .frame(height: sceneHeight / 5) - .foregroundStyle(Color.gray) - .padding() - } + CacheableImage(url: item.imageURL) { + Image(systemName: "globe") + .resizable() + .scaledToFit() + .foregroundStyle(Color.gray) + .padding() } + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() } HStack { VStack(alignment: .leading) { @@ -197,17 +189,10 @@ struct SearchView: View { router.push(Path.webView(item.url)) } label: { HStack { - AsyncImage(url: item.imageURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFill() - default: - Image(systemName: "globe") - .resizable() - .scaledToFit() - } + CacheableImage(url: item.imageURL) { + Image(systemName: "globe") + .resizable() + .scaledToFit() } .frame( width: sceneWidth / 5, From 6c22eca83f98aeb31bfd9138ff8736ba1baa7696 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 23:10:27 +0900 Subject: [PATCH 20/21] =?UTF-8?q?feat:=20WebPageView=EC=9D=98=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=ED=8B=80=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Search/SearchView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 2ae6da4..32dc31c 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -150,6 +150,7 @@ struct SearchView: View { private func webInfoCard(_ item: WebPageItem) -> some View { Button { + viewModel.send(.selectWebPage(item)) router.push(Path.webView(item.url)) } label: { ZStack(alignment: .bottom) { @@ -186,6 +187,7 @@ struct SearchView: View { private func webInfoRaw(_ item: WebPageItem) -> some View { Button { + viewModel.send(.selectWebPage(item)) router.push(Path.webView(item.url)) } label: { HStack { From 24be9b23252672dfc4e82323b5408439790d8f40 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 9 Feb 2026 23:10:46 +0900 Subject: [PATCH 21/21] =?UTF-8?q?feat:=20=EB=A1=9C=EC=BB=AC=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20URL=EB=8F=84=20=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/Common/Componeent/CacheableImage.swift | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/DevLog/UI/Common/Componeent/CacheableImage.swift b/DevLog/UI/Common/Componeent/CacheableImage.swift index bdc94fd..43f583a 100644 --- a/DevLog/UI/Common/Componeent/CacheableImage.swift +++ b/DevLog/UI/Common/Componeent/CacheableImage.swift @@ -57,7 +57,12 @@ struct CacheableImage: View { @MainActor private func loadImageWithCache() async { - guard self.url != nil else { return } + guard let url = self.url else { return } + + if url.isFileURL { + await loadLocalImage(from: url) + return + } if let cachedResponse = URLCache.imageCached.cachedResponse(for: request) { if let uiImage = UIImage(data: cachedResponse.data) { @@ -81,6 +86,23 @@ struct CacheableImage: View { isInvalid = true } } + + @MainActor + private func loadLocalImage(from url: URL) async { + do { + let data = try await Task.detached { + try Data(contentsOf: url) + }.value + + if let uiImage = UIImage(data: data) { + self.loadedUIImage = uiImage + } else { + isInvalid = true + } + } catch { + isInvalid = true + } + } } extension URLCache {