diff --git a/.swiftlint.yml b/.swiftlint.yml index fb904f64..6767d1a4 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -2,3 +2,4 @@ disabled_rules: - nesting - multiple_closures_with_trailing_closure - trailing_whitespace + - type_body_length diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift index 613e2da3..c962d0a0 100644 --- a/DevLog/Data/Repository/WebPageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -23,7 +23,7 @@ final class WebPageRepositoryImpl: WebPageRepository { .compactMap { try? $0.toDomain() } } - func upsert(_ urlString: String) async throws -> WebPage { + func upsert(_ urlString: String) async throws { let metadata = try await metadataService.fetchMetadata(from: urlString) let request = WebPageRequest( title: metadata.title, @@ -32,13 +32,6 @@ final class WebPageRepositoryImpl: WebPageRepository { imageURL: metadata.imageURL ) try await webPageService.upsertWebPage(request) - let response = WebPageResponse( - title: request.title, - url: request.url, - displayURL: request.displayURL, - imageURL: request.imageURL - ) - return try response.toDomain() } func delete(_ urlString: String) async throws { diff --git a/DevLog/Domain/Protocol/WebPageRepository.swift b/DevLog/Domain/Protocol/WebPageRepository.swift index b3fd3174..3da6ad66 100644 --- a/DevLog/Domain/Protocol/WebPageRepository.swift +++ b/DevLog/Domain/Protocol/WebPageRepository.swift @@ -7,6 +7,6 @@ protocol WebPageRepository { func fetch(_ query: String) async throws -> [WebPage] - func upsert(_ urlString: String) async throws -> WebPage + func upsert(_ urlString: String) async throws func delete(_ urlString: String) async throws } diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift index ab6aaab7..8f73c764 100644 --- a/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift +++ b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCase.swift @@ -6,5 +6,5 @@ // protocol AddWebPageUseCase { - func execute(_ urlString: String) async throws -> WebPage + func execute(_ urlString: String) async throws } diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift index 9aa3404e..8313a071 100644 --- a/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift +++ b/DevLog/Domain/UseCase/WebPage/Upsert/AddWebPageUseCaseImpl.swift @@ -12,7 +12,7 @@ final class AddWebPageUseCaseImpl: AddWebPageUseCase { self.repository = repository } - func execute(_ urlString: String) async throws -> WebPage { + func execute(_ urlString: String) async throws { try await repository.upsert(urlString) } } diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index a81139b9..91bc0041 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,6 +11,7 @@ final class HomeViewModel: Store { struct State { var todoKindPreferences = TodoKind.allCases.map { TodoKindPreference(kind: $0, isVisible: true) } var pinnedTodos: [PinnedTodoItem] = [] + var webPages: [WebPageItem] = [] var showTodoKindPicker: Bool = false var showTodoEditor: Bool = false var showSearchView: Bool = false @@ -19,7 +20,9 @@ final class HomeViewModel: Store { var searchText: String = "" var isSearching: Bool = false var reorderTodo: Bool = false - var isLoading: Bool = false + var isPinnedLoading: Bool = false + var isWebPageLoading: Bool = false + var isWebPageInputLoading: Bool = false var showAlert: Bool = false var alertTitle: String = "" var alertType: AlertType? @@ -40,14 +43,20 @@ final class HomeViewModel: Store { case updateSearchText(String) case upsertTodo(Todo) case addWebPage + case deleteWebPage(WebPageItem) case fetchPinnedTodos([PinnedTodoItem]) - case setLoading(Bool) + case fetchWebPages([WebPageItem]) + case setPinnedLoading(Bool) + case setWebPageLoading(Bool) + case setWebPageInputLoading(Bool) } enum SideEffect { case upsertTodo(Todo) case addWebPage(String) + case deleteWebPage(String) case fetchPinnedTodos + case fetchWebPages } enum AlertType { @@ -58,17 +67,23 @@ final class HomeViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let addWebPageUseCase: AddWebPageUseCase + private let deleteWebPageUseCase: DeleteWebPageUseCase private let fetchPinnedTodosUseCase: FetchPinnedTodosUseCase + private let fetchWebPagesUseCase: FetchWebPagesUseCase @Published private(set) var state = State() init( addWebPageUseCase: AddWebPageUseCase, + deleteWebPageUseCase: DeleteWebPageUseCase, upsertTodoUseCase: UpsertTodoUseCase, - fetchPinnedTodosUseCase: FetchPinnedTodosUseCase + fetchPinnedTodosUseCase: FetchPinnedTodosUseCase, + fetchWebPagesUseCase: FetchWebPagesUseCase ) { self.addWebPageUseCase = addWebPageUseCase + self.deleteWebPageUseCase = deleteWebPageUseCase self.upsertTodoUseCase = upsertTodoUseCase self.fetchPinnedTodosUseCase = fetchPinnedTodosUseCase + self.fetchWebPagesUseCase = fetchWebPagesUseCase } func reduce(with action: Action) -> [SideEffect] { @@ -81,10 +96,10 @@ final class HomeViewModel: Store { .updateWebPageURLInput, .setAlert: effects = reduceByUser(action, state: &state) - case .onAppear, .updateSearching, .updateSearchText, .upsertTodo, .addWebPage: + case .onAppear, .updateSearching, .updateSearchText, .upsertTodo, .addWebPage, .deleteWebPage: effects = reduceByView(action, state: &state) - case .fetchPinnedTodos, .setLoading: + case .fetchPinnedTodos, .fetchWebPages, .setPinnedLoading, .setWebPageLoading, .setWebPageInputLoading: effects = reduceByRun(action, state: &state) } @@ -97,8 +112,6 @@ final class HomeViewModel: Store { case .upsertTodo(let todo): Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) try await upsertTodoUseCase.execute(todo) } catch { send(.setAlert(isPresented: true, type: .error)) @@ -107,24 +120,51 @@ final class HomeViewModel: Store { case .addWebPage(let urlString): Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) - _ = try await addWebPageUseCase.execute(urlString) + defer { send(.setWebPageInputLoading(false)) } + send(.setWebPageInputLoading(true)) + try await addWebPageUseCase.execute(urlString) + let pages = try await fetchWebPagesUseCase.execute("") + send(.fetchWebPages(pages.map { WebPageItem(from: $0) })) } catch { + send(.setWebPageInputLoading(false)) + send(.setAlert(isPresented: true, type: .error)) + } + } + case .deleteWebPage(let urlString): + Task { + do { + defer { send(.setWebPageLoading(false)) } + send(.setWebPageLoading(true)) + try await deleteWebPageUseCase.execute(urlString) + let pages = try await fetchWebPagesUseCase.execute("") + send(.fetchWebPages(pages.map { WebPageItem(from: $0) })) + } catch { + send(.setWebPageLoading(false)) send(.setAlert(isPresented: true, type: .error)) } } case .fetchPinnedTodos: Task { do { - defer { send(.setLoading(false)) } - send(.setLoading(true)) + defer { send(.setPinnedLoading(false)) } + send(.setPinnedLoading(true)) let todos = try await fetchPinnedTodosUseCase.execute() send(.fetchPinnedTodos(todos.map { PinnedTodoItem(from: $0) })) } catch { send(.setAlert(isPresented: true, type: .error)) } } + case .fetchWebPages: + Task { + do { + defer { send(.setWebPageLoading(false)) } + send(.setWebPageLoading(true)) + let pages = try await fetchWebPagesUseCase.execute("") + send(.fetchWebPages(pages.map { WebPageItem(from: $0) })) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } } } } @@ -161,7 +201,7 @@ private extension HomeViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .onAppear: - return [.fetchPinnedTodos] + return [.fetchPinnedTodos, .fetchWebPages] case .updateSearching(let isSearching): state.isSearching = isSearching case .updateSearchText(let text): @@ -175,6 +215,8 @@ private extension HomeViewModel { } setAlert(&state, isPresented: false, type: nil) return [.addWebPage(normalizedURL)] + case .deleteWebPage(let page): + return [.deleteWebPage(page.url.absoluteString)] default: break } @@ -185,8 +227,14 @@ private extension HomeViewModel { switch action { case .fetchPinnedTodos(let todos): state.pinnedTodos = todos - case .setLoading(let isLoading): - state.isLoading = isLoading + case .fetchWebPages(let pages): + state.webPages = pages + case .setPinnedLoading(let isLoading): + state.isPinnedLoading = isLoading + case .setWebPageLoading(let isLoading): + state.isWebPageLoading = isLoading + case .setWebPageInputLoading(let isLoading): + state.isWebPageInputLoading = isLoading default: break } diff --git a/DevLog/Presentation/ViewModel/SearchViewModel.swift b/DevLog/Presentation/ViewModel/SearchViewModel.swift index b8ec927d..41d0fc51 100644 --- a/DevLog/Presentation/ViewModel/SearchViewModel.swift +++ b/DevLog/Presentation/ViewModel/SearchViewModel.swift @@ -13,7 +13,6 @@ final class SearchViewModel: Store { var isLoading: Bool = false var isSearching: Bool = false var searchQuery: String = "" - var selectedWebPage: WebPageItem? var webPages: [WebPageItem] = [] var todos: [TodoListItem] = [] var recentQueries: OrderedSet = [] @@ -27,7 +26,6 @@ final class SearchViewModel: Store { enum Action { case fetchWebPage([WebPageItem]) case fetchTodos([TodoListItem]) - case selectWebPage(WebPageItem) case addRecentQuery(String) case removeRecentQuery(String) case clearRecentQueries @@ -78,8 +76,6 @@ final class SearchViewModel: Store { state.webPages = items case .fetchTodos(let items): state.todos = items - case .selectWebPage(let item): - state.selectedWebPage = item case .addRecentQuery(let query): let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { break } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 540c5813..fc120f3a 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -313,6 +313,9 @@ }, "사용자 설정" : { + }, + "삭제" : { + }, "상태 설정" : { @@ -361,6 +364,9 @@ }, "작성된 내용이 없습니다." : { + }, + "저장한 Web Page가 표시됩니다." : { + }, "전체 삭제" : { @@ -386,7 +392,7 @@ "최근 검색" : { }, - "최근에 중요 표시를 한 Todo가 여기 표시됩니다." : { + "최근에 중요 표시를 한 Todo가 표시됩니다." : { }, "추가" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index c4c259bf..66b8be49 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -14,8 +14,10 @@ struct MainView: View { TabView { HomeView(viewModel: HomeViewModel( addWebPageUseCase: container.resolve(AddWebPageUseCase.self), + deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchPinnedTodosUseCase: container.resolve(FetchPinnedTodosUseCase.self) + fetchPinnedTodosUseCase: container.resolve(FetchPinnedTodosUseCase.self), + fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self) )) .tabItem { Image(systemName: "house.fill") diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index cb1afa5c..5415474d 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -18,6 +18,7 @@ struct HomeView: View { List { todoSection pinnedSection + webPageSection } .listStyle(.insetGrouped) .navigationTitle("홈") @@ -38,6 +39,14 @@ struct HomeView: View { upsertUseCase: container.resolve(UpsertTodoUseCase.self), todoID: todoID )) + case .web(let page): + WebView(url: page.url) + .toolbar { + ToolbarItem(placement: .principal) { + Text(page.title) + .bold() + } + } } } .toolbar { toolbar } @@ -95,6 +104,11 @@ struct HomeView: View { .onAppear { viewModel.send(.onAppear) } + .overlay { + if viewModel.state.isWebPageInputLoading { + LoadingView() + } + } } } @@ -160,12 +174,12 @@ struct HomeView: View { private var pinnedSection: some View { Section(content: { if viewModel.state.pinnedTodos.isEmpty { - if viewModel.state.isLoading { + if viewModel.state.isPinnedLoading { LoadingView(isClear: true) } else { HStack { Spacer() - Text("최근에 중요 표시를 한 Todo가 여기 표시됩니다.") + Text("최근에 중요 표시를 한 Todo가 표시됩니다.") .font(.callout) Spacer() } @@ -201,8 +215,7 @@ struct HomeView: View { HStack { Text("중요 표시") .foregroundStyle(Color.primary) - .font(.title2) - .bold() + .font(.title2.bold()) Spacer() } @@ -210,6 +223,36 @@ struct HomeView: View { }) } + private var webPageSection: some View { + Section { + if viewModel.state.webPages.isEmpty { + if viewModel.state.isWebPageLoading { + LoadingView(isClear: true) + } else { + HStack { + Spacer() + Text("저장한 Web Page가 표시됩니다.") + .font(.callout) + Spacer() + } + } + } else { + ForEach(viewModel.state.webPages, id: \.id) { page in + webResultRow(page) + } + } + } header: { + HStack { + Text("Web Page") + .foregroundStyle(Color.primary) + .font(.title2.bold()) + Spacer() + + } + .listRowInsets(EdgeInsets()) + } + } + @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { @@ -231,6 +274,40 @@ struct HomeView: View { } } + private func webResultRow(_ item: WebPageItem) -> some View { + NavigationLink(value: Path.web(item)) { + HStack { + CacheableImage(url: item.imageURL) { + Image(systemName: "globe") + .resizable() + .scaledToFit() + } + .frame(width: sceneWidth / 10, height: sceneWidth / 10) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading) { + Text(item.title) + .foregroundStyle(Color.primary) + .bold() + .multilineTextAlignment(.leading) + .lineLimit(2) + Text(item.displayURL) + .foregroundStyle(Color.accentColor) + .underline() + } + Spacer() + } + .padding(.vertical, 4) + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + Button(role: .destructive) { + viewModel.send(.deleteWebPage(item)) + } label: { + Label("삭제", systemImage: "trash") + } + } + } + private var contentPicker: some View { NavigationStack { List { @@ -307,5 +384,6 @@ struct HomeView: View { private enum Path: Hashable { case kind(TodoKind) case detail(String) + case web(WebPageItem) } } diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 541ef134..3082d9e5 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -25,11 +25,11 @@ struct SearchView: View { upsertUseCase: container.resolve(UpsertTodoUseCase.self), todoID: todoID )) - case .web(let url): - WebView(url: url) + case .web(let page): + WebView(url: page.url) .toolbar { ToolbarItem(placement: .principal) { - Text(viewModel.state.selectedWebPage?.title ?? "") + Text(page.title) .bold() } } @@ -213,10 +213,7 @@ struct SearchView: View { } private func webResultRow(_ item: WebPageItem) -> some View { - Button { - viewModel.send(.selectWebPage(item)) - router.push(Path.web(item.url)) - } label: { + NavigationLink(value: Path.web(item)) { VStack(spacing: 4) { HStack { CacheableImage(url: item.imageURL) { @@ -290,6 +287,6 @@ struct SearchView: View { private enum Path: Hashable { case todo(String) - case web(URL) + case web(WebPageItem) } }