From 214724fbb95350627a388ee96e3219b0ad37df4e Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 18:37:36 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20TodayView=EC=99=80=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=EB=90=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Structure/PinnedTodoItem.swift | 22 ------- .../ViewModel/HomeViewModel.swift | 29 +-------- DevLog/UI/Common/MainView.swift | 1 - DevLog/UI/Home/HomeView.swift | 59 ------------------- 4 files changed, 2 insertions(+), 109 deletions(-) delete mode 100644 DevLog/Presentation/Structure/PinnedTodoItem.swift diff --git a/DevLog/Presentation/Structure/PinnedTodoItem.swift b/DevLog/Presentation/Structure/PinnedTodoItem.swift deleted file mode 100644 index d1d034f3..00000000 --- a/DevLog/Presentation/Structure/PinnedTodoItem.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// PinnedTodoItem.swift -// DevLog -// -// Created by 최윤진 on 2/17/26. -// - -import Foundation - -struct PinnedTodoItem: Identifiable, Hashable { - let id: String - let title: String - let dueDate: Date? - let kind: TodoKind - - init(from todo: Todo) { - self.id = todo.id - self.title = todo.title - self.dueDate = todo.dueDate - self.kind = todo.kind - } -} diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index f23544e8..ee5ce12b 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,7 +11,6 @@ import Foundation final class HomeViewModel: Store { struct State: Equatable { var todoKindPreferences = TodoKind.allCases.map { TodoKindPreference(kind: $0, isVisible: true) } - var pinnedTodos: [PinnedTodoItem] = [] var webPages: [WebPageItem] = [] var showContentPicker: Bool = false var showTodoEditor: Bool = false @@ -21,7 +20,6 @@ final class HomeViewModel: Store { var searchText: String = "" var isSearching: Bool = false var reorderTodo: Bool = false - var isPinnedLoading: Bool = false var isWebPageLoading: Bool = false var isWebPageInputLoading: Bool = false var showAlert: Bool = false @@ -51,9 +49,7 @@ final class HomeViewModel: Store { case undoDeleteWebPage case confirmDeleteWebPage case setToast(isPresented: Bool, type: ToastType? = nil) - case fetchPinnedTodos([PinnedTodoItem]) case fetchWebPages([WebPageItem]) - case setPinnedLoading(Bool) case setWebPageLoading(Bool) case setWebPageInputLoading(Bool) } @@ -62,7 +58,6 @@ final class HomeViewModel: Store { case upsertTodo(Todo) case addWebPage(String) case deleteWebPage(String) - case fetchPinnedTodos case fetchWebPages case showModalAfterDelay(ModalType) } @@ -86,7 +81,6 @@ final class HomeViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let addWebPageUseCase: AddWebPageUseCase private let deleteWebPageUseCase: DeleteWebPageUseCase - private let fetchTodosUseCase: FetchTodosUseCase private let fetchWebPagesUseCase: FetchWebPagesUseCase private var pendingTask: (WebPageItem, Int)? @@ -94,13 +88,11 @@ final class HomeViewModel: Store { addWebPageUseCase: AddWebPageUseCase, deleteWebPageUseCase: DeleteWebPageUseCase, upsertTodoUseCase: UpsertTodoUseCase, - fetchTodosUseCase: FetchTodosUseCase, fetchWebPagesUseCase: FetchWebPagesUseCase ) { self.addWebPageUseCase = addWebPageUseCase self.deleteWebPageUseCase = deleteWebPageUseCase self.upsertTodoUseCase = upsertTodoUseCase - self.fetchTodosUseCase = fetchTodosUseCase self.fetchWebPagesUseCase = fetchWebPagesUseCase } @@ -119,8 +111,7 @@ final class HomeViewModel: Store { .addWebPage, .confirmDeleteWebPage: effects = reduceByView(action, state: &state) - case .fetchPinnedTodos, .fetchWebPages, .setPinnedLoading, - .setWebPageLoading, .setWebPageInputLoading: + case .fetchWebPages, .setWebPageLoading, .setWebPageInputLoading: effects = reduceByRun(action, state: &state) } @@ -164,18 +155,6 @@ final class HomeViewModel: Store { send(.setAlert(isPresented: true, type: .error)) } } - case .fetchPinnedTodos: - Task { - do { - defer { send(.setPinnedLoading(false)) } - send(.setPinnedLoading(true)) - let page = try await fetchTodosUseCase.execute(TodoQuery(isPinned: true), cursor: nil) - let todos = page.items - send(.fetchPinnedTodos(todos.map { PinnedTodoItem(from: $0) })) - } catch { - send(.setAlert(isPresented: true, type: .error)) - } - } case .fetchWebPages: Task { do { @@ -249,7 +228,7 @@ private extension HomeViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .onAppear: - return [.fetchPinnedTodos, .fetchWebPages] + return [.fetchWebPages] case .updateSearching(let isSearching): state.isSearching = isSearching case .updateSearchText(let text): @@ -275,8 +254,6 @@ private extension HomeViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { - case .fetchPinnedTodos(let todos): - state.pinnedTodos = todos case .fetchWebPages(let pages): let filteredPages: [WebPageItem] if let (pendingPage, _) = pendingTask { @@ -285,8 +262,6 @@ private extension HomeViewModel { filteredPages = pages } state.webPages = filteredPages - case .setPinnedLoading(let isLoading): - state.isPinnedLoading = isLoading case .setWebPageLoading(let isLoading): state.isWebPageLoading = isLoading case .setWebPageInputLoading(let isLoading): diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 0d24daf4..87d86833 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -16,7 +16,6 @@ struct MainView: View { addWebPageUseCase: container.resolve(AddWebPageUseCase.self), deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self) )) .tabItem { diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index d5af8965..2d309bb9 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -17,7 +17,6 @@ struct HomeView: View { NavigationStack(path: $router.path) { List { todoSection - pinnedSection webPageSection } .listStyle(.insetGrouped) @@ -33,12 +32,6 @@ struct HomeView: View { kind: todoKind )) .environment(router) - case .detail(let todoID): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchUseCase: container.resolve(FetchTodoByIDUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoID: todoID - )) case .web(let page): WebView(url: page.url) .navigationBarTitleDisplayMode(.inline) @@ -188,57 +181,6 @@ struct HomeView: View { }) } - private var pinnedSection: some View { - Section(content: { - if viewModel.state.pinnedTodos.isEmpty { - if viewModel.state.isPinnedLoading { - LoadingView() - } else { - HStack { - Spacer() - Text("최근에 중요 표시를 한 Todo가 표시됩니다.") - .font(.callout) - Spacer() - } - } - } else { - ForEach(viewModel.state.pinnedTodos, id: \.id) { todo in - NavigationLink(value: Path.detail(todo.id)) { - HStack { - RoundedRectangle(cornerRadius: 8) - .fill(todo.kind.color) - .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) - .overlay { - Image(systemName: todo.kind.symbolName) - .foregroundStyle(Color.white) - .font(.title3) - } - VStack(alignment: .leading) { - Text(todo.title) - .foregroundStyle(Color.primary) - Text(todo.dueDate? - .formatted(date: .abbreviated, time: .omitted) ?? "마감일 없음" - ) - .font(.caption2) - .foregroundStyle(Color.gray) - } - } - .padding(.vertical, -6) - } - } - } - }, header: { - HStack { - Text("중요 표시") - .foregroundStyle(Color.primary) - .font(.title2.bold()) - Spacer() - - } - .listRowInsets(EdgeInsets()) - }) - } - private var webPageSection: some View { Section { if viewModel.state.webPages.isEmpty { @@ -382,7 +324,6 @@ struct HomeView: View { private enum Path: Hashable { case kind(TodoKind) - case detail(String) case web(WebPageItem) } } From 370b58149c2831d3fe020079883dbae468efb73d Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 19:26:03 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=B5=9C=EA=B7=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EB=90=9C=20Todo=20(=EC=B5=9C=EB=8C=80=205=EA=B0=9C)?= =?UTF-8?q?=20=EB=A5=BC=20=EB=B3=B4=EC=97=AC=EC=A3=BC=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 --- .../Structure/RecentTodoItem.swift | 22 ++++++++ .../ViewModel/HomeViewModel.swift | 49 +++++++++++++++- DevLog/Resource/Localizable.xcstrings | 5 +- DevLog/UI/Common/MainView.swift | 1 + DevLog/UI/Home/HomeView.swift | 56 +++++++++++++++++++ 5 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 DevLog/Presentation/Structure/RecentTodoItem.swift diff --git a/DevLog/Presentation/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/RecentTodoItem.swift new file mode 100644 index 00000000..fb8694f6 --- /dev/null +++ b/DevLog/Presentation/Structure/RecentTodoItem.swift @@ -0,0 +1,22 @@ +// +// RecentTodoItem.swift +// DevLog +// +// Created by Codex on 3/6/26. +// + +import Foundation + +struct RecentTodoItem: Identifiable, Hashable { + let id: String + let title: String + let updatedAt: Date + let kind: TodoKind + + init(from todo: Todo) { + self.id = todo.id + self.title = todo.title + self.updatedAt = todo.updatedAt + self.kind = todo.kind + } +} diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index ee5ce12b..3f9e677e 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,6 +11,7 @@ import Foundation final class HomeViewModel: Store { struct State: Equatable { var todoKindPreferences = TodoKind.allCases.map { TodoKindPreference(kind: $0, isVisible: true) } + var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] var showContentPicker: Bool = false var showTodoEditor: Bool = false @@ -20,6 +21,7 @@ final class HomeViewModel: Store { var searchText: String = "" var isSearching: Bool = false var reorderTodo: Bool = false + var isRecentTodosLoading: Bool = false var isWebPageLoading: Bool = false var isWebPageInputLoading: Bool = false var showAlert: Bool = false @@ -49,7 +51,9 @@ final class HomeViewModel: Store { case undoDeleteWebPage case confirmDeleteWebPage case setToast(isPresented: Bool, type: ToastType? = nil) + case fetchRecentTodos([RecentTodoItem]) case fetchWebPages([WebPageItem]) + case setRecentTodosLoading(Bool) case setWebPageLoading(Bool) case setWebPageInputLoading(Bool) } @@ -58,6 +62,7 @@ final class HomeViewModel: Store { case upsertTodo(Todo) case addWebPage(String) case deleteWebPage(String) + case fetchRecentTodos case fetchWebPages case showModalAfterDelay(ModalType) } @@ -81,6 +86,7 @@ final class HomeViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let addWebPageUseCase: AddWebPageUseCase private let deleteWebPageUseCase: DeleteWebPageUseCase + private let fetchTodosUseCase: FetchTodosUseCase private let fetchWebPagesUseCase: FetchWebPagesUseCase private var pendingTask: (WebPageItem, Int)? @@ -88,11 +94,13 @@ final class HomeViewModel: Store { addWebPageUseCase: AddWebPageUseCase, deleteWebPageUseCase: DeleteWebPageUseCase, upsertTodoUseCase: UpsertTodoUseCase, + fetchTodosUseCase: FetchTodosUseCase, fetchWebPagesUseCase: FetchWebPagesUseCase ) { self.addWebPageUseCase = addWebPageUseCase self.deleteWebPageUseCase = deleteWebPageUseCase self.upsertTodoUseCase = upsertTodoUseCase + self.fetchTodosUseCase = fetchTodosUseCase self.fetchWebPagesUseCase = fetchWebPagesUseCase } @@ -111,7 +119,8 @@ final class HomeViewModel: Store { .addWebPage, .confirmDeleteWebPage: effects = reduceByView(action, state: &state) - case .fetchWebPages, .setWebPageLoading, .setWebPageInputLoading: + case .fetchRecentTodos, .fetchWebPages, .setRecentTodosLoading, + .setWebPageLoading, .setWebPageInputLoading: effects = reduceByRun(action, state: &state) } @@ -125,6 +134,27 @@ final class HomeViewModel: Store { Task { do { try await upsertTodoUseCase.execute(todo) + let page = try await fetchRecentTodos() + let items = page.items + .filter { $0.createdAt != $0.updatedAt } + .prefix(5) + .map { RecentTodoItem(from: $0) } + send(.fetchRecentTodos(items)) + } catch { + send(.setAlert(isPresented: true, type: .error)) + } + } + case .fetchRecentTodos: + Task { + do { + defer { send(.setRecentTodosLoading(false)) } + send(.setRecentTodosLoading(true)) + let page = try await fetchRecentTodos() + let items = page.items + .filter { $0.createdAt != $0.updatedAt } + .prefix(5) + .map { RecentTodoItem(from: $0) } + send(.fetchRecentTodos(items)) } catch { send(.setAlert(isPresented: true, type: .error)) } @@ -228,7 +258,7 @@ private extension HomeViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .onAppear: - return [.fetchWebPages] + return [.fetchRecentTodos, .fetchWebPages] case .updateSearching(let isSearching): state.isSearching = isSearching case .updateSearchText(let text): @@ -254,6 +284,8 @@ private extension HomeViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { + case .fetchRecentTodos(let todos): + state.recentTodos = todos case .fetchWebPages(let pages): let filteredPages: [WebPageItem] if let (pendingPage, _) = pendingTask { @@ -262,6 +294,8 @@ private extension HomeViewModel { filteredPages = pages } state.webPages = filteredPages + case .setRecentTodosLoading(let isLoading): + state.isRecentTodosLoading = isLoading case .setWebPageLoading(let isLoading): state.isWebPageLoading = isLoading case .setWebPageInputLoading(let isLoading): @@ -325,4 +359,15 @@ private extension HomeViewModel { } return "https://" + trimmed } + + func fetchRecentTodos() async throws -> TodoPage { + try await fetchTodosUseCase.execute( + TodoQuery( + sortTarget: .updatedAt, + sortOrder: .latest, + pageSize: 100 + ), + cursor: nil + ) + } } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 33582fa8..83e18e5a 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -400,7 +400,10 @@ "최근 검색" : { }, - "최근에 중요 표시를 한 Todo가 표시됩니다." : { + "최근 수정" : { + + }, + "최근 수정한 Todo가 없습니다." : { }, "추가" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 87d86833..0d24daf4 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -16,6 +16,7 @@ struct MainView: View { addWebPageUseCase: container.resolve(AddWebPageUseCase.self), deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self) )) .tabItem { diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 2d309bb9..7bf4b2c7 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -17,6 +17,7 @@ struct HomeView: View { NavigationStack(path: $router.path) { List { todoSection + recentTodoSection webPageSection } .listStyle(.insetGrouped) @@ -32,6 +33,12 @@ struct HomeView: View { kind: todoKind )) .environment(router) + case .detail(let todoID): + TodoDetailView(viewModel: TodoDetailViewModel( + fetchUseCase: container.resolve(FetchTodoByIDUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoID: todoID + )) case .web(let page): WebView(url: page.url) .navigationBarTitleDisplayMode(.inline) @@ -181,6 +188,54 @@ struct HomeView: View { }) } + private var recentTodoSection: some View { + Section { + if viewModel.state.recentTodos.isEmpty { + if viewModel.state.isRecentTodosLoading { + LoadingView() + } else { + HStack { + Spacer() + Text("최근 수정한 Todo가 없습니다.") + .font(.callout) + Spacer() + } + } + } else { + ForEach(viewModel.state.recentTodos, id: \.id) { todo in + NavigationLink(value: Path.detail(todo.id)) { + HStack { + RoundedRectangle(cornerRadius: 8) + .fill(todo.kind.color) + .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) + .overlay { + Image(systemName: todo.kind.symbolName) + .foregroundStyle(Color.white) + .font(.title3) + } + VStack(alignment: .leading, spacing: 2) { + Text(todo.title) + .foregroundStyle(Color.primary) + Text(todo.updatedAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2) + .foregroundStyle(Color.gray) + } + } + .padding(.vertical, -6) + } + } + } + } header: { + HStack { + Text("최근 수정") + .foregroundStyle(Color.primary) + .font(.title2.bold()) + Spacer() + } + .listRowInsets(EdgeInsets()) + } + } + private var webPageSection: some View { Section { if viewModel.state.webPages.isEmpty { @@ -324,6 +379,7 @@ struct HomeView: View { private enum Path: Hashable { case kind(TodoKind) + case detail(String) case web(WebPageItem) } } From 1cceafc3d6db45f86412cf5321183e96b3c76529 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 19:43:01 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20lineLimit=EC=9D=B4=20=EC=9E=88?= =?UTF-8?q?=EC=9D=84=20=EA=B2=BD=EC=9A=B0,=20'...'=20=EC=9D=B4=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/Component/Tag+.swift | 72 +++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/DevLog/UI/Common/Component/Tag+.swift b/DevLog/UI/Common/Component/Tag+.swift index 7d91bc77..7c8f0748 100644 --- a/DevLog/UI/Common/Component/Tag+.swift +++ b/DevLog/UI/Common/Component/Tag+.swift @@ -70,23 +70,28 @@ struct TagLayout: Layout { } var lineLimit: Int? + var showsOverflowIndicator: Bool var verticalSpacing: CGFloat var horizontalSpacing: CGFloat init( + showsOverflowIndicator: Bool = true, verticalSpacing: CGFloat = 8, horizontalSpacing: CGFloat = 8 ) { + self.showsOverflowIndicator = showsOverflowIndicator self.verticalSpacing = verticalSpacing self.horizontalSpacing = horizontalSpacing } init( lineLimit: Int, + showsOverflowIndicator: Bool = true, verticalSpacing: CGFloat = 8, horizontalSpacing: CGFloat = 8 ) { self.lineLimit = lineLimit + self.showsOverflowIndicator = showsOverflowIndicator self.verticalSpacing = verticalSpacing self.horizontalSpacing = horizontalSpacing } @@ -118,7 +123,7 @@ struct TagLayout: Layout { cache: inout Cache ) -> CGSize { updateCache(&cache, subviews: subviews, proposal: proposal) - let rows = limitedRows(cache.rows) + let rows = limitedRows(cache.rows, subviews: subviews, maxWidth: cache.maxWidth) let height = rows.reduce(0) { $0 + $1.maxHeight } + CGFloat(max(0, rows.count - 1)) * verticalSpacing let rowsWidth = rows.map { $0.width }.max() ?? 0 let width = (proposal.width ?? 0) > 0 ? (proposal.width ?? 0) : rowsWidth @@ -139,7 +144,7 @@ struct TagLayout: Layout { cache.maxWidth = width cache.rows = computeRows(maxWidth: width, subviews: subviews) } - let rows = limitedRows(cache.rows) + let rows = limitedRows(cache.rows, subviews: subviews, maxWidth: width) let allowedIndices = Set(rows.flatMap { $0.indices }) var minY = bounds.minY @@ -188,8 +193,12 @@ struct TagLayout: Layout { var rows: [Row] = [] var currentRow = Row() var currentWidth: CGFloat = 0 + let overflowIndex = subviews.count - 1 + let usesOverflowIndicator = usesOverflowIndicator + let contentIndices = subviews.indices.filter { !usesOverflowIndicator || $0 != overflowIndex } - for (index, subview) in subviews.enumerated() { + for index in contentIndices { + let subview = subviews[index] let size = subview.sizeThatFits(.unspecified) if currentWidth + size.width > availableWidth && !currentRow.indices.isEmpty { @@ -211,11 +220,64 @@ struct TagLayout: Layout { return rows } - private func limitedRows(_ rows: [Row]) -> [Row] { + private func limitedRows( + _ rows: [Row], + subviews: Subviews, + maxWidth: CGFloat + ) -> [Row] { guard let lineLimit, 0 < lineLimit else { return rows } - return Array(rows.prefix(lineLimit)) + let limited = Array(rows.prefix(lineLimit)) + + guard usesOverflowIndicator, + lineLimit < rows.count, + !subviews.isEmpty else { + return limited + } + + let overflowIndex = subviews.count - 1 + let overflowSize = subviews[overflowIndex].sizeThatFits(.unspecified) + guard !limited.isEmpty else { + return [Row(indices: [overflowIndex], maxHeight: overflowSize.height, width: overflowSize.width)] + } + + var adjustedRows = limited + var lastRow = adjustedRows.removeLast() + var candidateIndices = lastRow.indices + + while !candidateIndices.isEmpty { + let rowIndices = candidateIndices + [overflowIndex] + let rowWidth = width(for: rowIndices, subviews: subviews) + if rowWidth <= maxWidth { + lastRow.indices = rowIndices + lastRow.maxHeight = max(lastRow.maxHeight, overflowSize.height) + lastRow.width = rowWidth + adjustedRows.append(lastRow) + return adjustedRows + } + candidateIndices.removeLast() + } + + adjustedRows.append( + Row(indices: [overflowIndex], maxHeight: overflowSize.height, width: overflowSize.width) + ) + return adjustedRows + } + + private func width( + for indices: [Int], + subviews: Subviews + ) -> CGFloat { + guard !indices.isEmpty else { return 0 } + let widths = indices.reduce(CGFloat.zero) { partialResult, index in + partialResult + subviews[index].sizeThatFits(.unspecified).width + } + return widths + CGFloat(max(0, indices.count - 1)) * horizontalSpacing + } + + private var usesOverflowIndicator: Bool { + showsOverflowIndicator && (lineLimit ?? 0) > 0 } struct Row { From 8aca5f39f9d3db178df4c9ad794733da04a19516 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 20:10:01 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20lineLimit=EC=9D=B4=20=EA=B1=B8?= =?UTF-8?q?=EB=A6=B4=20=EC=8B=9C=20=ED=83=9C=EA=B7=B8=20=EB=A7=88=EC=A7=80?= =?UTF-8?q?=EB=A7=89=EC=97=90=20'...'=EA=B0=80=20=EB=B3=B4=EC=9D=B4?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/Component/Tag+.swift | 65 +++++++++++++++++++- DevLog/UI/Common/Component/TodoItemRow.swift | 6 +- DevLog/UI/Common/TodoInfoSheetView.swift | 6 +- DevLog/UI/Home/TodoEditorView.swift | 8 +-- DevLog/UI/Today/TodayView.swift | 6 +- 5 files changed, 68 insertions(+), 23 deletions(-) diff --git a/DevLog/UI/Common/Component/Tag+.swift b/DevLog/UI/Common/Component/Tag+.swift index 7c8f0748..0e71cfc2 100644 --- a/DevLog/UI/Common/Component/Tag+.swift +++ b/DevLog/UI/Common/Component/Tag+.swift @@ -63,7 +63,70 @@ struct Tag: View { } } -struct TagLayout: Layout { +struct TagList: View { + private let tags: [String] + private let lineLimit: Int? + private let showsOverflowIndicator: Bool + private let isEditing: Bool + private let action: ((String) -> Void)? + private let verticalSpacing: CGFloat + private let horizontalSpacing: CGFloat + + init( + _ tags: C, + lineLimit: Int? = nil, + showsOverflowIndicator: Bool = true, + isEditing: Bool = false, + verticalSpacing: CGFloat = 8, + horizontalSpacing: CGFloat = 8, + action: ((String) -> Void)? = nil + ) where C.Element == String { + self.tags = Array(tags) + self.lineLimit = lineLimit + self.showsOverflowIndicator = showsOverflowIndicator + self.isEditing = isEditing + self.verticalSpacing = verticalSpacing + self.horizontalSpacing = horizontalSpacing + self.action = action + } + + var body: some View { + Group { + if let lineLimit { + TagLayout( + lineLimit: lineLimit, + showsOverflowIndicator: showsOverflowIndicator, + verticalSpacing: verticalSpacing, + horizontalSpacing: horizontalSpacing + ) { + contentTags + if showsOverflowIndicator { + Tag("...", isEditing: false) + } + } + } else { + TagLayout( + showsOverflowIndicator: false, + verticalSpacing: verticalSpacing, + horizontalSpacing: horizontalSpacing + ) { + contentTags + } + } + } + } + + @ViewBuilder + private var contentTags: some View { + ForEach(tags, id: \.self) { tagText in + Tag(tagText, isEditing: isEditing) { + action?(tagText) + } + } + } +} + +private struct TagLayout: Layout { struct Cache { var maxWidth: CGFloat = 0 var rows: [Row] = [] diff --git a/DevLog/UI/Common/Component/TodoItemRow.swift b/DevLog/UI/Common/Component/TodoItemRow.swift index ad6de7d7..aca4f4ba 100644 --- a/DevLog/UI/Common/Component/TodoItemRow.swift +++ b/DevLog/UI/Common/Component/TodoItemRow.swift @@ -34,11 +34,7 @@ struct TodoItemRow: View { .lineLimit(1) } if !item.tags.isEmpty { - TagLayout(lineLimit: 1) { - ForEach(item.tags, id: \.self) { tagText in - Tag(tagText, isEditing: false) - } - } + TagList(item.tags, lineLimit: 1) } } Spacer() diff --git a/DevLog/UI/Common/TodoInfoSheetView.swift b/DevLog/UI/Common/TodoInfoSheetView.swift index af8ca649..72f15eaa 100644 --- a/DevLog/UI/Common/TodoInfoSheetView.swift +++ b/DevLog/UI/Common/TodoInfoSheetView.swift @@ -88,11 +88,7 @@ struct TodoInfoSheetView: View { .foregroundStyle(.secondary) Divider() if !tags.isEmpty { - TagLayout { - ForEach(tags, id: \.self) { tag in - Tag(tag, isEditing: false) - } - } + TagList(tags) } } } diff --git a/DevLog/UI/Home/TodoEditorView.swift b/DevLog/UI/Home/TodoEditorView.swift index bb4a42dc..e47e1997 100644 --- a/DevLog/UI/Home/TodoEditorView.swift +++ b/DevLog/UI/Home/TodoEditorView.swift @@ -229,13 +229,7 @@ private struct TagEditor: View { ) { VStack(spacing: tags.isEmpty ? 0 : spacing) { ScrollView { - TagLayout { - ForEach(tags, id: \.self) { tagText in - Tag(tagText, isEditing: true) { - deleteAction(tagText) - } - } - } + TagList(tags, isEditing: true, action: deleteAction) .background { GeometryReader { geometry in Color.clear diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 8029be81..f5b91e73 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -318,11 +318,7 @@ private struct TodayTodoRow: View { } if !item.tags.isEmpty { - TagLayout(lineLimit: 1) { - ForEach(item.tags, id: \.self) { tagText in - Tag(tagText, isEditing: false) - } - } + TagList(item.tags, lineLimit: 1) } } } From 9a7f4f69dce9f90668a4d1bce2370d65a71afc1c Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 6 Mar 2026 20:10:47 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=ED=83=9C=EA=B7=B8=EC=99=80=20?= =?UTF-8?q?=EC=A4=91=EC=9A=94=20=ED=91=9C=EC=8B=9C=EA=B0=80=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EC=9D=B4=201=EC=B4=88=EB=A7=88=EB=8B=A4=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Structure/RecentTodoItem.swift | 4 + DevLog/UI/Home/HomeView.swift | 85 +++++++++++++++---- 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/DevLog/Presentation/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/RecentTodoItem.swift index fb8694f6..12f92f4e 100644 --- a/DevLog/Presentation/Structure/RecentTodoItem.swift +++ b/DevLog/Presentation/Structure/RecentTodoItem.swift @@ -10,13 +10,17 @@ import Foundation struct RecentTodoItem: Identifiable, Hashable { let id: String let title: String + let isPinned: Bool let updatedAt: Date + let tags: [String] let kind: TodoKind init(from todo: Todo) { self.id = todo.id self.title = todo.title + self.isPinned = todo.isPinned self.updatedAt = todo.updatedAt + self.tags = todo.tags self.kind = todo.kind } } diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 7bf4b2c7..6866009a 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -204,24 +204,8 @@ struct HomeView: View { } else { ForEach(viewModel.state.recentTodos, id: \.id) { todo in NavigationLink(value: Path.detail(todo.id)) { - HStack { - RoundedRectangle(cornerRadius: 8) - .fill(todo.kind.color) - .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) - .overlay { - Image(systemName: todo.kind.symbolName) - .foregroundStyle(Color.white) - .font(.title3) - } - VStack(alignment: .leading, spacing: 2) { - Text(todo.title) - .foregroundStyle(Color.primary) - Text(todo.updatedAt.formatted(date: .abbreviated, time: .shortened)) - .font(.caption2) - .foregroundStyle(Color.gray) - } - } - .padding(.vertical, -6) + RecentTodoRow(todo: todo, sceneWidth: sceneWidth) + .padding(.vertical, -4) } } } @@ -383,3 +367,68 @@ struct HomeView: View { case web(WebPageItem) } } + +private struct RecentTodoRow: View { + let todo: RecentTodoItem + let sceneWidth: CGFloat + + var body: some View { + HStack(alignment: .top, spacing: 12) { + RoundedRectangle(cornerRadius: 8) + .fill(todo.kind.color) + .frame(width: sceneWidth * 0.08, height: sceneWidth * 0.08) + .overlay { + Image(systemName: todo.kind.symbolName) + .foregroundStyle(Color.white) + .font(.title3) + } + + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + if todo.isPinned { + Image(systemName: "star.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(.orange) + } + Text(todo.title) + .foregroundStyle(Color.primary) + .font(.headline) + .lineLimit(1) + } + + HStack(spacing: 6) { + Text(todo.kind.localizedName) + .font(.caption.weight(.semibold)) + .foregroundStyle(todo.kind.color) + + TimelineView(.periodic(from: .now, by: 1.0)) { context in + Text(timeAgoText(from: todo.updatedAt, now: context.date)) + .font(.caption2) + .foregroundStyle(Color.gray) + } + } + + if !todo.tags.isEmpty { + TagList(todo.tags, lineLimit: 1) + } + } + } + } + + private func timeAgoText(from date: Date, now: Date) -> String { + let seconds = Int(now.timeIntervalSince(date)) + + if seconds < 60 { + return "\(max(0, seconds))초 전" + } else if seconds < 3600 { + let minutes = seconds / 60 + return "\(minutes)분 전" + } else if seconds < 86400 { + let hours = seconds / 3600 + return "\(hours)시간 전" + } else { + let days = seconds / 86400 + return "\(days)일 전" + } + } +}