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/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/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/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/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift new file mode 100644 index 0000000..f480cb9 --- /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() + let indexedResponses = responses.enumerated().map { ($0.offset, $0.element) } + + return try await withThrowingTaskGroup(of: (Int, WebPageMetadata?).self) { group in + for (index, response) in indexedResponses { + group.addTask { + let metadata = try? await self.metadataService.fetchMetadata(from: response) + return (index, metadata) + } + } + + var results: [WebPageMetadata?] = Array(repeating: nil, count: responses.count) + for try await (index, metadata) in group { + results[index] = metadata + } + + return results.compactMap { $0 } + } + } + + 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/Entity/WebPage.swift b/DevLog/Domain/Entity/WebPage.swift new file mode 100644 index 0000000..fb1b530 --- /dev/null +++ b/DevLog/Domain/Entity/WebPage.swift @@ -0,0 +1,15 @@ +// +// WebPage.swift +// DevLog +// +// Created by 최윤진 on 2/9/26. +// + +import Foundation + +struct WebPage { + let title: String? + let url: URL + let displayURL: URL + let imageURL: URL? +} 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 +} 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) + } +} + 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/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/WebPageMetadataService.swift b/DevLog/Infra/Service/WebPageMetadataService.swift new file mode 100644 index 0000000..d40d031 --- /dev/null +++ b/DevLog/Infra/Service/WebPageMetadataService.swift @@ -0,0 +1,63 @@ +// +// 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() + provider.timeout = 10.0 + + 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 + } + + guard let fileName = url.absoluteString.addingPercentEncoding( + withAllowedCharacters: .alphanumerics + ) else { + continuation.resume(returning: nil) + return + } + + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + .appendingPathExtension("jpeg") + + do { + try data.write(to: tempURL) + continuation.resume(returning: tempURL) + } catch { + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index 1d88a3d..7ea88de 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -5,48 +5,48 @@ // 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 -> [WebPageResponse] { + 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() { 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) } - - 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(_ 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([urlString])], 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(_ 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([urlString])]) } } 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 + } +} diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift new file mode 100644 index 0000000..104456b --- /dev/null +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -0,0 +1,164 @@ +// +// 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] } + 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/Componeent/CacheableImage.swift b/DevLog/UI/Common/Componeent/CacheableImage.swift index aa2de56..43f583a 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() } @@ -50,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) { @@ -74,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 { 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/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 aefff6f..32dc31c 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -8,204 +8,213 @@ 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.isLoading { + LoadingView() + } else if viewModel.state.isSearching { + if viewModel.state.searchQuery.isEmpty { + searchInstruction + } else { + ScrollView { + LazyVStack { + ForEach(viewModel.state.filteredWebPages, id: \.id) { page in + webInfoCard(page) + } + } + .padding(.horizontal) + } + } + } else { + if viewModel.state.webPages.isEmpty { + webInstruction + } else { + List(viewModel.state.webPages, id: \.id) { page in + webInfoRaw(page) + .listRowSeparator(.hidden) // 섹션 내 요소의 구분선 숨김 + .swipeActions { + Button(role: .destructive, action: { + viewModel.send(.deleteWebPage(item: page)) + }) { + Image(systemName: "trash") + } + } + } + .listStyle(.plain) + } + } + } + .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() + } + } + } + } + .onAppear { 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) + } + } + } + + @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 { + Button { + viewModel.send(.selectWebPage(item)) + router.push(Path.webView(item.url)) + } label: { + ZStack(alignment: .bottom) { + Color.white + GeometryReader { geometry in + 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) { + 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 { + viewModel.send(.selectWebPage(item)) + router.push(Path.webView(item.url)) + } label: { + HStack { + CacheableImage(url: item.imageURL) { + 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) } }