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/Structure/RecentTodoItem.swift b/DevLog/Presentation/Structure/RecentTodoItem.swift new file mode 100644 index 00000000..12f92f4e --- /dev/null +++ b/DevLog/Presentation/Structure/RecentTodoItem.swift @@ -0,0 +1,26 @@ +// +// RecentTodoItem.swift +// DevLog +// +// Created by Codex on 3/6/26. +// + +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/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index f23544e8..3f9e677e 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -11,7 +11,7 @@ import Foundation final class HomeViewModel: Store { struct State: Equatable { var todoKindPreferences = TodoKind.allCases.map { TodoKindPreference(kind: $0, isVisible: true) } - var pinnedTodos: [PinnedTodoItem] = [] + var recentTodos: [RecentTodoItem] = [] var webPages: [WebPageItem] = [] var showContentPicker: Bool = false var showTodoEditor: Bool = false @@ -21,7 +21,7 @@ final class HomeViewModel: Store { var searchText: String = "" var isSearching: Bool = false var reorderTodo: Bool = false - var isPinnedLoading: Bool = false + var isRecentTodosLoading: Bool = false var isWebPageLoading: Bool = false var isWebPageInputLoading: Bool = false var showAlert: Bool = false @@ -51,9 +51,9 @@ final class HomeViewModel: Store { case undoDeleteWebPage case confirmDeleteWebPage case setToast(isPresented: Bool, type: ToastType? = nil) - case fetchPinnedTodos([PinnedTodoItem]) + case fetchRecentTodos([RecentTodoItem]) case fetchWebPages([WebPageItem]) - case setPinnedLoading(Bool) + case setRecentTodosLoading(Bool) case setWebPageLoading(Bool) case setWebPageInputLoading(Bool) } @@ -62,7 +62,7 @@ final class HomeViewModel: Store { case upsertTodo(Todo) case addWebPage(String) case deleteWebPage(String) - case fetchPinnedTodos + case fetchRecentTodos case fetchWebPages case showModalAfterDelay(ModalType) } @@ -119,7 +119,7 @@ final class HomeViewModel: Store { .addWebPage, .confirmDeleteWebPage: effects = reduceByView(action, state: &state) - case .fetchPinnedTodos, .fetchWebPages, .setPinnedLoading, + case .fetchRecentTodos, .fetchWebPages, .setRecentTodosLoading, .setWebPageLoading, .setWebPageInputLoading: effects = reduceByRun(action, state: &state) } @@ -134,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)) } @@ -164,18 +185,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 +258,7 @@ private extension HomeViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .onAppear: - return [.fetchPinnedTodos, .fetchWebPages] + return [.fetchRecentTodos, .fetchWebPages] case .updateSearching(let isSearching): state.isSearching = isSearching case .updateSearchText(let text): @@ -275,8 +284,8 @@ private extension HomeViewModel { func reduceByRun(_ action: Action, state: inout State) -> [SideEffect] { switch action { - case .fetchPinnedTodos(let todos): - state.pinnedTodos = todos + case .fetchRecentTodos(let todos): + state.recentTodos = todos case .fetchWebPages(let pages): let filteredPages: [WebPageItem] if let (pendingPage, _) = pendingTask { @@ -285,8 +294,8 @@ private extension HomeViewModel { filteredPages = pages } state.webPages = filteredPages - case .setPinnedLoading(let isLoading): - state.isPinnedLoading = isLoading + case .setRecentTodosLoading(let isLoading): + state.isRecentTodosLoading = isLoading case .setWebPageLoading(let isLoading): state.isWebPageLoading = isLoading case .setWebPageInputLoading(let isLoading): @@ -350,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/Component/Tag+.swift b/DevLog/UI/Common/Component/Tag+.swift index 7d91bc77..0e71cfc2 100644 --- a/DevLog/UI/Common/Component/Tag+.swift +++ b/DevLog/UI/Common/Component/Tag+.swift @@ -63,30 +63,98 @@ 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] = [] } 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 +186,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 +207,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 +256,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 +283,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 { 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/HomeView.swift b/DevLog/UI/Home/HomeView.swift index d5af8965..6866009a 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -17,7 +17,7 @@ struct HomeView: View { NavigationStack(path: $router.path) { List { todoSection - pinnedSection + recentTodoSection webPageSection } .listStyle(.insetGrouped) @@ -188,55 +188,36 @@ struct HomeView: View { }) } - private var pinnedSection: some View { - Section(content: { - if viewModel.state.pinnedTodos.isEmpty { - if viewModel.state.isPinnedLoading { + private var recentTodoSection: some View { + Section { + if viewModel.state.recentTodos.isEmpty { + if viewModel.state.isRecentTodosLoading { LoadingView() } else { HStack { Spacer() - Text("최근에 중요 표시를 한 Todo가 표시됩니다.") + Text("최근 수정한 Todo가 없습니다.") .font(.callout) Spacer() } } } else { - ForEach(viewModel.state.pinnedTodos, id: \.id) { todo in + 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) { - Text(todo.title) - .foregroundStyle(Color.primary) - Text(todo.dueDate? - .formatted(date: .abbreviated, time: .omitted) ?? "마감일 없음" - ) - .font(.caption2) - .foregroundStyle(Color.gray) - } - } - .padding(.vertical, -6) + RecentTodoRow(todo: todo, sceneWidth: sceneWidth) + .padding(.vertical, -4) } } } - }, header: { + } header: { HStack { - Text("중요 표시") + Text("최근 수정") .foregroundStyle(Color.primary) .font(.title2.bold()) Spacer() - } .listRowInsets(EdgeInsets()) - }) + } } private var webPageSection: some View { @@ -386,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)일 전" + } + } +} 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) } } }