From 244bcc5fde105d2b39007bc139b46c51140c3d86 Mon Sep 17 00:00:00 2001 From: opficdev Date: Sun, 27 Sep 2026 22:31:38 +0900 Subject: [PATCH 01/11] =?UTF-8?q?ui:=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EA=B5=AC=EC=84=B1=EA=B3=BC=20=EC=95=A1?= =?UTF-8?q?=EC=85=98=20=ED=91=9C=ED=98=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/PushNotificationListView.swift | 24 +++- .../PushNotificationListTestAssertions.swift | 20 ++- .../PushNotificationListTestSupport.swift | 9 +- .../Resources/Localizable.xcstrings | 17 +++ .../Sources/Common/Component/Toast.swift | 124 ++++++++---------- .../Sources/Todo/List/TodoListView.swift | 19 ++- .../Sources/Settings/AccountFeature.swift | 24 ++-- .../Sources/Settings/AccountView.swift | 14 ++ 8 files changed, 152 insertions(+), 99 deletions(-) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index c39bbae5..3c030bb5 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -322,19 +322,16 @@ public struct PushNotificationListView: View { private func presentDeleteNotificationToast(_ notificationId: String) { ToastPresenter.present( - message: String(localized: "common_undo", bundle: PresentationResources.bundle), - systemImage: "arrow.uturn.left", duration: 5, - font: .caption, - multilineTextAlignment: .center, - lineLimit: 3, action: { store.send(.view(.undoDelete)) }, onDismiss: { store.send(.view(.finishDeleteToast(notificationId))) } - ) + ) { + ToastLabel() + } } private func timeAgoText(from date: Date, now: Date) -> String { @@ -366,3 +363,18 @@ public struct PushNotificationListView: View { } } } + +struct ToastLabel: View { + var body: some View { + Label { + Text(String(localized: "common_undo", bundle: PresentationResources.bundle)) + .font(.caption) + .lineLimit(3) + .foregroundStyle(.blue) + } icon: { + Image(systemName: "arrow.uturn.left.circle.fill") + .font(.title2.weight(.semibold)) + .foregroundStyle(.blue) + } + } +} diff --git a/Application/Presentation/NotificationTab/Tests/PushNotificationListTestAssertions.swift b/Application/Presentation/NotificationTab/Tests/PushNotificationListTestAssertions.swift index f5481e17..9ce79977 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotificationListTestAssertions.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotificationListTestAssertions.swift @@ -188,6 +188,7 @@ func verifyDeleteUndoAndFinishToast( undoDeleteUseCaseSpy: UndoDeletePushNotificationUseCaseSpy ) async throws { ToastPresenter.reset() + defer { ToastPresenter.reset() } await adapter.fetchNotifications() @@ -201,15 +202,21 @@ func verifyDeleteUndoAndFinishToast( await adapter.deleteNotification(item) let deletedNotifications = adapter.notifications - let toastMessage = ToastPresenter.item?.message + let toast = try #require(ToastPresenter.item) + let undo = try #require(toast.action) #expect(deletedNotifications.first?.isHidden == true) - #expect(toastMessage == String(localized: "common_undo", bundle: PresentationResources.bundle)) + #expect(toast.duration == 5) + #expect(toast.onDismiss != nil) await waitUntilMainActor { deleteUseCaseSpy.calledNotificationIds == ["notification-1"] } - await adapter.undoDelete() + undo() + + await waitUntilMainActor { + adapter.notifications.first?.isHidden == false + } let restoredNotifications = adapter.notifications #expect(restoredNotifications.first?.isHidden == false) @@ -219,7 +226,12 @@ func verifyDeleteUndoAndFinishToast( } await adapter.deleteNotification(item) - await adapter.finishDeleteToast("notification-1") + let finish = try #require(ToastPresenter.item?.onDismiss) + finish() + + await waitUntilMainActor { + adapter.notifications.isEmpty + } let finalNotifications = adapter.notifications #expect(finalNotifications.isEmpty) diff --git a/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift b/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift index 25f2263a..614aeb07 100644 --- a/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift +++ b/Application/Presentation/NotificationTab/Tests/PushNotificationListTestSupport.swift @@ -161,12 +161,7 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { private func presentDeleteNotificationToast(_ notificationId: String) { ToastPresenter.present( - message: String(localized: "common_undo", bundle: PresentationResources.bundle), - systemImage: "arrow.uturn.left", duration: 5, - font: .caption, - multilineTextAlignment: .center, - lineLimit: 3, action: { Task { @MainActor in await undoDelete() @@ -177,7 +172,9 @@ struct PushNotificationListStoreTestAdapter: PushNotificationListStateDriving { await finishDeleteToast(notificationId) } } - ) + ) { + ToastLabel() + } } private func drainReceivedActions() async { diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 9c3e093d..389460aa 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -451,6 +451,23 @@ } } }, + "todo_list_delete_toast_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo deleted." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 삭제했어요." + } + } + } + }, "profile_recent_empty" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift index 76698a59..f270fa1b 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/Toast.swift @@ -20,26 +20,20 @@ public final class ToastPresenter { presenter.item } - public static func present( - message: String, - systemImage: String? = nil, + public static func present( duration: TimeInterval = 2, - font: Font? = nil, - multilineTextAlignment: TextAlignment = .leading, - lineLimit: Int? = nil, action: (() -> Void)? = nil, - onDismiss: (() -> Void)? = nil + onDismiss: (() -> Void)? = nil, + @ViewBuilder label: @escaping () -> Label ) { presenter.present( ToastItem( - message: message, - systemImage: systemImage, duration: duration, - font: font, - multilineTextAlignment: multilineTextAlignment, - lineLimit: lineLimit, action: action, - onDismiss: onDismiss + onDismiss: onDismiss, + label: { + AnyView(label()) + } ) ) } @@ -68,14 +62,10 @@ public final class ToastPresenter { public struct ToastItem: Identifiable { public let id = UUID() - public let message: String - public let systemImage: String? public let duration: TimeInterval - public let font: Font? - public let multilineTextAlignment: TextAlignment - public let lineLimit: Int? public let action: (() -> Void)? public let onDismiss: (() -> Void)? + fileprivate let label: () -> AnyView } public extension View { @@ -115,7 +105,7 @@ private struct ToastHostModifier: ViewModifier { action: item.action, onDismiss: item.onDismiss ) { - ToastItemLabel(item: item) + item.label() } .id(item.id) .padding(.horizontal, 12) @@ -179,23 +169,6 @@ private enum ToastPresentationPlacement { } } -private struct ToastItemLabel: View { - let item: ToastItem - - var body: some View { - Group { - if let systemImage = item.systemImage { - Label(item.message, systemImage: systemImage) - } else { - Text(item.message) - } - } - .font(item.font) - .multilineTextAlignment(item.multilineTextAlignment) - .lineLimit(item.lineLimit) - } -} - private struct ToastOverlayView: View { @Binding var isPresented: Bool let duration: TimeInterval @@ -213,10 +186,29 @@ private struct ToastOverlayView: View { var body: some View { if isPresented { - ToastCardView( - label, - color: action == nil ? .primary : .blue - ) + Group { + if #available(iOS 26.0, *) { + toastButton + .glassEffect(.regular.tint(Color.surface.opacity(0.65)), in: .capsule) + } else { + toastButton + .background(Color.surface.opacity(0.45), in: .capsule) + .background(.regularMaterial, in: .capsule) + } + } + .overlay { + Capsule() + .strokeBorder( + LinearGradient( + colors: [Color.primary.opacity(0.14), Color.primary.opacity(0.03)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + .shadow(color: Color.black.opacity(0.16), radius: 16, y: 8) .offset(y: yOffset) .opacity(opacityValue) .onChange(of: isPresented) { _, newValue in @@ -235,15 +227,21 @@ private struct ToastOverlayView: View { .onDisappear { cleanupPresentation() } - .onTapGesture { - isTapped = true - dismissAnimated() - action?() - } .transition(.identity) } } + private var toastButton: some View { + Button(action: performAction) { + label() + .labelStyle(TrailingIconLabelStyle()) + .padding(.vertical, 6) + .padding(.horizontal, 10) + } + .buttonStyle(.plain) + .contentShape(.capsule) + } + private func presentAnimated() { guard opacityValue == 0 else { return } @@ -308,33 +306,19 @@ private struct ToastOverlayView: View { dismissCompletionWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem) } -} - -private struct ToastCardView: View { - @ViewBuilder let label: Label - let color: Color - init( - @ViewBuilder _ label: @escaping () -> Label, - color: Color = .primary - ) { - self.label = label() - self.color = color + private func performAction() { + isTapped = true + dismissAnimated() + action?() } +} - var body: some View { - self.label - .foregroundStyle(color) - .padding(.vertical, 12) - .padding(.horizontal, 14) - .background { - if #available(iOS 26.0, *) { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .glassEffect() - } else { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.ultraThinMaterial) - } - } +private struct TrailingIconLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.title + configuration.icon + } } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index 35ae4a05..f945df13 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -245,8 +245,6 @@ public struct TodoListView: View { private func presentDeleteTodoToast(_ todoId: String) { ToastPresenter.present( - message: String(localized: "common_undo", bundle: PresentationResources.bundle), - systemImage: "arrow.uturn.left", duration: 5, action: { store.send(.view(.undoDelete)) @@ -254,7 +252,9 @@ public struct TodoListView: View { onDismiss: { store.send(.view(.finishDeleteToast(todoId))) } - ) + ) { + ToastLabel() + } } @ViewBuilder @@ -422,3 +422,16 @@ public struct TodoListView: View { onSelectTodo(todoId) } } + +struct ToastLabel: View { + var body: some View { + Label { + Text(String(localized: "todo_list_delete_toast_message", bundle: PresentationResources.bundle)) + .foregroundStyle(.primary) + } icon: { + Image(systemName: "arrow.uturn.backward.circle.fill") + .font(.title2.weight(.semibold)) + .foregroundStyle(Color.accent) + } + } +} diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift index 1249d465..23c6970d 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountFeature.swift @@ -176,12 +176,14 @@ private extension AccountFeature { return } - await ToastPresenter.present( - message: String( - localized: "account_toast_link_success", - bundle: PresentationResources.bundle + await ToastPresenter.present { + ToastLabel( + message: String( + localized: "account_toast_link_success", + bundle: PresentationResources.bundle + ) ) - ) + } let providers = try await fetchProvidersUseCase.execute() await send(.setProviders( currentProvider: providers.currentProvider, @@ -200,12 +202,14 @@ private extension AccountFeature { await send(.loading(.begin(target: .default, mode: .delayed))) do { try await unlinkProviderUseCase.execute(provider) - await ToastPresenter.present( - message: String( - localized: "account_toast_unlink_success", - bundle: PresentationResources.bundle + await ToastPresenter.present { + ToastLabel( + message: String( + localized: "account_toast_unlink_success", + bundle: PresentationResources.bundle + ) ) - ) + } let providers = try await fetchProvidersUseCase.execute() await send(.setProviders( currentProvider: providers.currentProvider, diff --git a/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift b/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift index feb2c78b..3858cdd0 100644 --- a/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift +++ b/Application/Presentation/ProfileTab/Sources/Settings/AccountView.swift @@ -109,6 +109,20 @@ struct AccountView: View { } } +struct ToastLabel: View { + let message: String + + var body: some View { + Label { + Text(message) + .foregroundStyle(.primary) + } icon: { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accent) + } + } +} + private struct ProviderRow: View { let provider: AuthProvider let isConnected: Bool From 43320c221097174129e84cbc4b6418f79e16e84e Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 09:23:33 +0900 Subject: [PATCH 02/11] =?UTF-8?q?ui:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/PushNotificationListView.swift | 33 +- .../Resources/Localizable.xcstrings | 4 +- .../Common/Component/TodoItemRow.swift | 53 ++- .../Sources/Extension/View+ItemActions.swift | 39 +- .../Sources/Todo/List/TodoListView.swift | 397 +++++++++--------- 5 files changed, 277 insertions(+), 249 deletions(-) diff --git a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift index 3c030bb5..01dcfe83 100644 --- a/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift +++ b/Application/Presentation/NotificationTab/Sources/PushNotificationListView.swift @@ -141,35 +141,20 @@ public struct PushNotificationListView: View { store.send(.view(.selectNotification(item.id))) } .itemActions { - Button { + ItemActionButton( + color: Color.accent, + image: Image(systemName: item.isRead + ? "circle.badge.xmark" : "checkmark.circle") + ) { store.send(.view(.toggleRead(item))) - } label: { - HStack(spacing: 6) { - Image(systemName: item.isRead - ? "circle.badge.xmark" : "checkmark.circle") - Text(String( - localized: item.isRead - ? "push_mark_unread" : "push_mark_read", - bundle: PresentationResources.bundle - )) - .lineLimit(1) - .minimumScaleFactor(0.8) - } } - Button(role: .destructive) { + ItemActionButton( + color: Color.red, + image: Image(systemName: "trash") + ) { store.send(.view(.deleteNotification(item))) presentDeleteNotificationToast(item.id) - } label: { - HStack(spacing: 6) { - Image(systemName: "trash") - Text(String( - localized: "common_delete", - bundle: PresentationResources.bundle - )) - .lineLimit(1) - .minimumScaleFactor(0.8) - } } } .clipShape(.rect(cornerRadius: 16)) diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index 389460aa..cf2cf3ff 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -4458,7 +4458,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Newest First" + "value" : "Newest" } }, "ko" : { @@ -4475,7 +4475,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Oldest First" + "value" : "Oldest" } }, "ko" : { diff --git a/Application/Presentation/PresentationShared/Sources/Common/Component/TodoItemRow.swift b/Application/Presentation/PresentationShared/Sources/Common/Component/TodoItemRow.swift index 0e222106..08681020 100644 --- a/Application/Presentation/PresentationShared/Sources/Common/Component/TodoItemRow.swift +++ b/Application/Presentation/PresentationShared/Sources/Common/Component/TodoItemRow.swift @@ -9,7 +9,7 @@ import SwiftUI import Domain public struct TodoItemRow: View { - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) + @ScaledMetric(relativeTo: .headline) private var labelWidth = CGFloat(28) private let item: TodoListItem public init(_ item: TodoListItem) { @@ -17,40 +17,53 @@ public struct TodoItemRow: View { } public var body: some View { - HStack { - Image(systemName: "checkmark.circle") + HStack(spacing: 12) { + Image(systemName: item.isCompleted ? "checkmark.circle.fill" : "circle") .resizable() .frame(width: labelWidth, height: labelWidth) - .foregroundStyle(item.isCompleted ? .green : .secondary) - VStack(alignment: .leading, spacing: 0) { - HStack { + .foregroundStyle(item.isCompleted ? Color.accent : Color.textTertiary) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { Text(item.title) .font(.headline) - .foregroundStyle(Color(.label)) + .foregroundStyle(item.isCompleted ? Color.textSecondary : .primary) .lineLimit(1) + .layoutPriority(1) + Text("#\(item.number)") .font(.subheadline.weight(.semibold)) - .foregroundStyle(.gray) + .foregroundStyle(Color.textTertiary) .fixedSize(horizontal: true, vertical: false) } - HStack(spacing: 4) { - if item.isPinned { - Image(systemName: "star.fill") - .font(.headline) - .foregroundStyle(.orange) - } + + HStack(spacing: 8) { RelativeTimeText(date: item.updatedAt) - } - .frame(height: UIFont.preferredFont(forTextStyle: .headline).lineHeight) - if !item.tags.isEmpty { - TagList(item.tags, lineLimit: 1) + + if !item.tags.isEmpty { + TagList( + item.tags, + lineLimit: 1, + verticalSpacing: 4, + horizontalSpacing: 4 + ) + } } } - Spacer() + + Spacer(minLength: 4) + + if item.isPinned { + Image(systemName: "star.fill") + .font(.title3) + .foregroundStyle(.orange) + } + Image(systemName: "chevron.right") .font(.caption2.bold()) - .foregroundStyle(.gray) + .foregroundStyle(Color.textTertiary) } + .padding(.horizontal, 16) .padding(.vertical, 12) } } diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift index b8bdc055..62a3da2b 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift @@ -14,6 +14,37 @@ public extension View { } } +public struct ItemActionButton: View { + @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac + + private let color: Color + private let image: Image + private let action: () -> Void + + public init( + color: Color, + image: Image, + action: @escaping () -> Void + ) { + self.color = color + self.image = image + self.action = action + } + + public var body: some View { + if isiOSAppOnMac { + Button(action: action) { + image + } + } else { + Button(action: action) { + image + } + .buttonStyle(ItemActionButtonStyle(color: color)) + } + } +} + private struct ItemActionsModifier: ViewModifier { @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @State private var store = Store(initialState: ItemActionsFeature.State()) { @@ -46,7 +77,6 @@ private struct ItemActionsModifier: ViewModifier { } } } - .buttonStyle(ItemActionButtonStyle()) .padding(.leading, 12) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in store.send(.view(.setTrayWidth(width))) @@ -96,15 +126,14 @@ private struct ItemActionButtonStyle: ButtonStyle { @ScaledMetric(relativeTo: .body) private var width = 88 @ScaledMetric(relativeTo: .body) private var height = 44 + let color: Color + func makeBody(configuration: Configuration) -> some View { configuration.label .font(.callout) .foregroundStyle(Color.white) .frame(width: width, height: height) - .background( - configuration.role == .destructive ? Color.red : Color.accent, - in: Capsule() - ) + .background(color, in: Capsule()) } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift index f945df13..2dbec4ca 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift @@ -12,12 +12,10 @@ import Core import Domain public struct TodoListView: View { + @Environment(\.dismiss) private var dismiss @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @ScaledMetric(relativeTo: .body) private var headerHeight = 41 - @State private var headerOffset: CGFloat = .zero - @State private var isScrollTrackingEnabled = false @State var store: StoreOf private let windowEvent: TodoEditorWindowEvent? private let onSelectTodo: (String) -> Void @@ -34,31 +32,10 @@ public struct TodoListView: View { public var body: some View { Group { - if #available(iOS 18, *) { - if store.state.isSearching { - todoSearchContent - } else { - todoListContent - } + if store.state.isSearching { + todoSearchContent } else { - Group { - if store.state.isSearching { - searchResultsContent - } else { - todoListContent - } - } - .searchable( - text: $store.searchText, - isPresented: $store.isSearching, - placement: .navigationBarDrawer(displayMode: .always), - prompt: Text( - String.localizedStringWithFormat( - String(localized: "todo_list_search_prompt_format", bundle: PresentationResources.bundle), - TodoCategoryItem(from: store.category).localizedName - ) - ) - ) + todoListContent } } .prominentAlert(store, state: \.alert, action: \.alert) @@ -67,36 +44,14 @@ public struct TodoListView: View { value.matchesCreate(category: store.category, source: .list) else { return } store.send(.view(.windowTodoCreated)) } - .navigationTitle(TodoCategoryItem(from: store.category).localizedName) .fullScreenCover( item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) .activePresentation(when: isTabContentActive) ) { coverStore in fullScreenCoverContent(coverStore) } - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - openTodoEditor() - } label: { - Image(systemName: "plus") - } - } - if #available(iOS 26.0, *) { - ToolbarSpacer(.fixed, placement: .topBarTrailing) - } - if #available(iOS 18, *) { - ToolbarItem(placement: .topBarTrailing) { - Button { - store.send(.binding(.set(\.isSearching, true))) - } label: { - Image(systemName: "magnifyingglass") - } - } - } - } .background(NavigationBarConfigurator()) - .background(Color(.systemGroupedBackground)) + .background(Color.appBackground.ignoresSafeArea()) .task { store.send(.view(.onAppear)) } } @@ -109,92 +64,79 @@ public struct TodoListView: View { let visibleTodos = store.state.todos.filter { !$0.isHidden } ZStack { - List { - Group { - if visibleTodos.isEmpty, !store.state.isLoading { - HStack { - Spacer() + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + Section { + if visibleTodos.isEmpty, !store.state.isLoading { Text(String(localized: "todo_list_empty", bundle: PresentationResources.bundle)) - .foregroundStyle(Color.gray) - Spacer() - } - .listRowSeparator(.hidden) - } else { - ForEach(Array(zip(visibleTodos.indices, visibleTodos)), id: \.1.id) { idx, todo in - Button { - selectTodo(todo.id) - } label: { - TodoItemRow(todo) - } - .todoDetailPreview(todoId: todo.id) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .alignmentGuide(.listRowSeparatorLeading) { _ in return 0 } - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if idx == 0 { - Divider() - .padding(.horizontal, -16) - } - } - } - .onAppear { - let lastID = visibleTodos.last?.id - if todo.id == lastID, store.state.hasMore { - store.send(.view(.loadNextPage)) - } - } - .swipeActions(edge: .leading) { - Button(action: { - store.send(.view(.tapTogglePinned(todo))) - }) { - Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") - } - .tint(Color.orange) - Button { - store.send(.view(.tapToggleCompleted(todo))) - } label: { - Image(systemName: todo.isCompleted ? "arrow.uturn.backward" : "checkmark") - } - .tint(Color.green) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive, action: { - store.send(.view(.swipeTodo(todo))) - presentDeleteTodoToast(todo.id) - }) { - Image(systemName: "trash") + .font(.callout) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background(Color.surface, in: .rect(cornerRadius: 28)) + .padding(.horizontal, 16) + } else { + LazyVStack(spacing: 0) { + ForEach(visibleTodos) { todo in + TodoItemRow(todo) + .background(Color.surface) + .contentShape(.rect) + .todoDetailPreview(todoId: todo.id) + .onTapGesture { + selectTodo(todo.id) + } + .itemActions { + ItemActionButton( + color: Color.orange, + image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") + ) { + store.send(.view(.tapTogglePinned(todo))) + } + + ItemActionButton( + color: Color.accent, + image: Image(systemName: todo.isCompleted + ? "arrow.uturn.backward" : "checkmark") + ) { + store.send(.view(.tapToggleCompleted(todo))) + } + + ItemActionButton( + color: Color.red, + image: Image(systemName: "trash") + ) { + store.send(.view(.swipeTodo(todo))) + presentDeleteTodoToast(todo.id) + } + } + .overlay(alignment: .bottom) { + if todo.id != visibleTodos.last?.id { + Divider() + } + } + .onAppear { + if todo.id == visibleTodos.last?.id, store.state.hasMore { + store.send(.view(.loadNextPage)) + } + } } } + .background(Color.surface, in: .rect(cornerRadius: 28)) + .compositingGroup() + .clipShape(.rect(cornerRadius: 28)) + .padding(.horizontal, 16) } + } header: { + stickyHeader } } - .listRowBackground(Color.clear) - .listSectionSeparator(.hidden, edges: .top) } - .listStyle(.plain) - .onScrollOffsetChange { offset in - guard isScrollTrackingEnabled else { return } - headerOffset = max(0, -offset) - } - .safeAreaInset(edge: .top) { - VStack(spacing: 4) { - headerView - if #unavailable(iOS 26) { - Divider() - .padding(.horizontal, -16) - } - } - .background { - if #available(iOS 26.0, *) { - Color.clear - } else { - Color(.systemGroupedBackground) - } - } - .offset(y: headerOffset) + .safeAreaInset(edge: .top, spacing: 0) { + topBar } .refreshable { await store.send(.view(.refresh)).finish() } .scrollDisabled(visibleTodos.isEmpty || store.state.isLoading) + .toolbarVisibility(.hidden, for: .navigationBar) if store.state.isLoading { LoadingView() @@ -202,9 +144,12 @@ public struct TodoListView: View { } } - @available(iOS 18, *) private var todoSearchContent: some View { searchResultsContent + .background(Color.appBackground) + .navigationTitle(TodoCategoryItem(from: store.category).localizedName) + .navigationBarTitleDisplayMode(.inline) + .toolbarVisibility(.visible, for: .navigationBar) .searchable( text: $store.searchText, isPresented: $store.isSearching, @@ -272,46 +217,124 @@ public struct TodoListView: View { TodoCategoryItem(from: store.category).localizedName ) ) - .foregroundStyle(Color.gray) - .frame(maxWidth: .infinity) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if store.state.isLoading { LoadingView() } else if searchResults.isEmpty { - Spacer() Text(String(localized: "todo_list_search_empty", bundle: PresentationResources.bundle)) - .foregroundStyle(Color.gray) - .frame(maxWidth: .infinity) - Spacer() + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { - LazyVStack(spacing: 0) { - ForEach(displayedTodos) { todo in - Button { - selectTodo(todo.id) - } label: { - VStack(spacing: 0) { + LazyVStack(spacing: 12) { + LazyVStack(spacing: 0) { + ForEach(displayedTodos) { todo in + Button { + selectTodo(todo.id) + } label: { TodoItemRow(todo) - Divider() + .contentShape(.rect) + } + .buttonStyle(.plain) + .todoDetailPreview(todoId: todo.id) + .overlay(alignment: .bottom) { + if todo.id != displayedTodos.last?.id { + Divider() + } } } - .todoDetailPreview(todoId: todo.id) } - .padding(.horizontal, 16) + .background(Color.surface, in: .rect(cornerRadius: 28)) + .compositingGroup() + .clipShape(.rect(cornerRadius: 28)) if !store.state.showAllSearchResults, limit < searchResults.count { Button(String(localized: "todo_list_show_more", bundle: PresentationResources.bundle)) { store.send(.binding(.set(\.showAllSearchResults, true))) } .font(.subheadline) - .foregroundStyle(Color.gray) + .foregroundStyle(Color.textSecondary) .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 4) } } + .padding(16) } } } + private var topBar: some View { + ZStack { + Text(TodoCategoryItem(from: store.category).localizedName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) + + HStack(spacing: 12) { + NavigationBackButton { + dismiss() + } + + Spacer() + + if #available(iOS 26.0, *) { + Button { + store.send(.binding(.set(\.isSearching, true))) + } label: { + Image(systemName: "magnifyingglass") + } + .topBarButtonStyle() + } else { + Button { + store.send(.binding(.set(\.isSearching, true))) + } label: { + Image(systemName: "magnifyingglass") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + } + + if #available(iOS 26.0, *) { + Button { + openTodoEditor() + } label: { + Image(systemName: "plus") + } + .topBarButtonStyle() + } else { + Button { + openTodoEditor() + } label: { + Image(systemName: "plus") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.appBackground, ignoresSafeAreaEdges: .top) + } + + private var stickyHeader: some View { + headerView + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 12) + .background(Color.appBackground) + } + private var headerView: some View { ScrollView(.horizontal) { HStack(spacing: 8) { @@ -333,12 +356,35 @@ public struct TodoListView: View { } } label: { HStack(spacing: 6) { - Image(systemName: "line.3.horizontal.decrease") - .foregroundStyle(Color.onControlBackground) - filterBadge + Text("\(store.appliedFilterCount)") + Image(systemName: "xmark") } - .adaptiveButtonStyle(color: .controlBackground) + .foregroundStyle(Color.onPrimaryContainer) + .adaptiveButtonStyle(color: .primaryContainer) + } + } + + Menu { + Toggle(isOn: $store.query.isPinned) { + Text(String(localized: "todo_pinned", bundle: PresentationResources.bundle)) + } + + Picker(selection: $store.query.completionFilter) { + ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in + Text(option.title).tag(option) + } + } label: { + Text(String(localized: "todo_list_completion_status", bundle: PresentationResources.bundle)) + } + } label: { + let condition = store.state.query.isPinned || store.state.query.completionFilter != .all + HStack(spacing: 6) { + Image(systemName: "line.3.horizontal.decrease") + Text(String(localized: "todo_list_filter_options", bundle: PresentationResources.bundle)) + Image(systemName: "chevron.down") } + .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) + .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) } Menu { @@ -358,64 +404,19 @@ public struct TodoListView: View { } } label: { let condition = store.state.query.sortTarget == .createdAt && store.state.query.sortOrder == .latest - HStack { - Text( - String.localizedStringWithFormat( - String(localized: "todo_list_sort_format", bundle: PresentationResources.bundle), - store.state.query.sortTarget.title, - store.state.query.sortOrder.title - ) - ) + HStack(spacing: 6) { + Image(systemName: "arrow.up.arrow.down") + Text(String(localized: "todo_list_sort_by", bundle: PresentationResources.bundle)) Image(systemName: "chevron.down") } .foregroundStyle(condition ? Color.onControlBackground : .onPrimaryContainer) .adaptiveButtonStyle(color: condition ? .controlBackground : .primaryContainer) } - - Menu { - Toggle(isOn: $store.query.isPinned) { - Text(String(localized: "todo_pinned", bundle: PresentationResources.bundle)) - } - - Picker(selection: $store.query.completionFilter) { - ForEach([TodoQuery.CompletionFilter.all, .incomplete, .completed], id: \.self) { option in - Text(option.title).tag(option) - } - } label: { - Text(String(localized: "todo_list_completion_status", bundle: PresentationResources.bundle)) - } - } label: { - let condition = store.state.query.isPinned || store.state.query.completionFilter != .all - HStack { - Text(String(localized: "todo_list_filter_options", bundle: PresentationResources.bundle)) - Image(systemName: "chevron.down") - } - .foregroundStyle(condition ? Color.onPrimaryContainer : .onControlBackground) - .adaptiveButtonStyle(color: condition ? .primaryContainer : .controlBackground) - } } } - .scrollIndicators(.never) - .scrollDisabled(!isScrollTrackingEnabled) - .contentMargins(.leading, 16, for: .scrollContent) - .frame(height: headerHeight) - .onAppear { - headerOffset = 0 - isScrollTrackingEnabled = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - isScrollTrackingEnabled = true - } - } - } - - private var filterBadge: some View { - Text("\(store.appliedFilterCount)") - .font(.caption2.weight(.bold)) - .foregroundStyle(Color.onPrimaryContainer) - .lineLimit(1) - .minimumScaleFactor(0.6) - .frame(width: 20, height: 20) - .background(Circle().fill(Color.primaryContainer)) + .scrollIndicators(.hidden) + .fixedSize(horizontal: false, vertical: true) + .contentMargins(.horizontal, 16, for: .scrollContent) } private func selectTodo(_ todoId: String) { From 492feeca128b78f09bf33bd1da2ebc34859130e7 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 10:30:44 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20Todo=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EA=B3=BC=20=EA=B2=80=EC=83=89=20=ED=9D=90=EB=A6=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppGraph+PresentationDependencies.swift | 4 +- .../HomeTab/Sources/Home/HomeView.swift | 1 + .../Sources/HomeDependencyPreparation.swift | 26 ++ .../HomeTab/Sources/Search/SearchView.swift | 2 +- .../Todo/List/TodoListFeature+Effects.swift | 28 +- .../Sources/Todo/List/TodoListFeature.swift | 118 ++------ .../Sources/Todo/List/TodoListView.swift | 253 ++++++------------ .../Todo/TodoListFeatureTestDoubles.swift | 34 +-- .../Tests/Todo/TodoListFeatureTests.swift | 50 +--- .../Resources/Localizable.xcstrings | 68 ----- .../Common/NavigationBarConfigurator.swift | 105 -------- .../TodoDependencyPreparation.swift | 25 -- .../Sources/Todo/Editor/TodoEditorView.swift | 2 +- 13 files changed, 152 insertions(+), 564 deletions(-) rename Application/Presentation/{PresentationShared => HomeTab}/Sources/Todo/List/TodoListFeature+Effects.swift (81%) rename Application/Presentation/{PresentationShared => HomeTab}/Sources/Todo/List/TodoListFeature.swift (73%) rename Application/Presentation/{PresentationShared => HomeTab}/Sources/Todo/List/TodoListView.swift (57%) rename Application/Presentation/{PresentationShared => HomeTab}/Tests/Todo/TodoListFeatureTestDoubles.swift (90%) rename Application/Presentation/{PresentationShared => HomeTab}/Tests/Todo/TodoListFeatureTests.swift (80%) delete mode 100644 Application/Presentation/PresentationShared/Sources/Common/NavigationBarConfigurator.swift diff --git a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift index 29bc0262..7f787158 100644 --- a/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift +++ b/Application/App/Sources/App/Dependency/AppGraph+PresentationDependencies.swift @@ -107,7 +107,7 @@ private extension AppGraph { fetchReferenceItemsUseCase: todoGraphSet.todoUseCaseGraph.fetchReferenceItemsUseCase, upsertTodoUseCase: todoGraphSet.todoUseCaseGraph.upsertTodoUseCase ) - TodoDependencyPreparation.prepareListQuery( + HomePresentationDependencyPreparation.prepareTodoListQuery( &dependencies, fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase, fetchTodoByIdUseCase: todoGraphSet.todoUseCaseGraph.fetchTodoByIdUseCase, @@ -116,7 +116,7 @@ private extension AppGraph { .todoCategoryUseCaseGraph .fetchTodoCategoryPreferencesUseCase ) - TodoDependencyPreparation.prepareListMutation( + HomePresentationDependencyPreparation.prepareTodoListMutation( &dependencies, upsertTodoUseCase: todoGraphSet.todoUseCaseGraph.upsertTodoUseCase, deleteTodoUseCase: todoGraphSet.todoUseCaseGraph.deleteTodoUseCase, diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index f38fbbc9..ce166cfe 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -316,6 +316,7 @@ public struct HomeView: View { store: Store(initialState: TodoListFeature.State(category: item.todoCategory)) { TodoListFeature() }, + searchStore: searchStore, windowEvent: windowEvent, onSelectTodo: { router.push(.todo(TodoIdItem(id: $0))) } ) diff --git a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift index 56c13e1b..569f84d7 100644 --- a/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift +++ b/Application/Presentation/HomeTab/Sources/HomeDependencyPreparation.swift @@ -36,6 +36,32 @@ public enum HomeDependencyPreparation { dependencies.homeNetworkConnectivityUseCase = networkConnectivityUseCase } + public static func prepareTodoListQuery( + _ dependencies: inout DependencyValues, + fetchTodosUseCase: FetchTodosUseCase, + fetchTodoByIdUseCase: FetchTodoByIdUseCase, + fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, + fetchTodoCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase + ) { + dependencies.todoListFetchTodosUseCase = fetchTodosUseCase + dependencies.fetchTodoByIdUseCase = fetchTodoByIdUseCase + dependencies.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase + dependencies.fetchTodoCategoryPreferencesUseCase = fetchTodoCategoryPreferencesUseCase + } + + public static func prepareTodoListMutation( + _ dependencies: inout DependencyValues, + upsertTodoUseCase: UpsertTodoUseCase, + deleteTodoUseCase: DeleteTodoUseCase, + undoDeleteTodoUseCase: UndoDeleteTodoUseCase, + trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase + ) { + dependencies.upsertTodoUseCase = upsertTodoUseCase + dependencies.todoListDeleteTodoUseCase = deleteTodoUseCase + dependencies.todoListUndoDeleteTodoUseCase = undoDeleteTodoUseCase + dependencies.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + } + public static func prepareSearch( _ dependencies: inout DependencyValues, fetchRecentSearchQueriesUseCase: FetchRecentSearchQueriesUseCase, diff --git a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift index eed20697..710874ba 100644 --- a/Application/Presentation/HomeTab/Sources/Search/SearchView.swift +++ b/Application/Presentation/HomeTab/Sources/Search/SearchView.swift @@ -150,7 +150,7 @@ private struct SearchField: View { localized: "search_prompt", bundle: PresentationResources.bundle) ) - .foregroundColor(Color.secondary), + .foregroundStyle(Color.secondary), ) .focused($isFocused) .onSubmit { diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature+Effects.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature+Effects.swift similarity index 81% rename from Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature+Effects.swift rename to Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature+Effects.swift index f7f2be59..c5954250 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature+Effects.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature+Effects.swift @@ -1,6 +1,6 @@ // // TodoListFeature+Effects.swift -// PresentationShared +// HomeTab // // Created by opfic on 6/12/26. // @@ -9,29 +9,9 @@ import ComposableArchitecture import Core import Domain import Foundation +import PresentationShared extension TodoListFeature { - func searchEffect( - _ keyword: String, - category: TodoCategory - ) -> Effect { - .run { [fetchTodosUseCase] send in - do { - let query = TodoQuery(categoryId: category.storageValue, keyword: keyword) - let page = try await fetchTodosUseCase.execute(query, cursor: nil) - try Task.checkCancellation() - await send(.store(.fetchSearchResults(page.items.compactMap(TodoListItem.init(from:))))) - await send(.loading(.end(target: .default, mode: .immediate))) - } catch is CancellationError { - return - } catch { - await send(.store(.setAlert(true))) - await send(.loading(.end(target: .default, mode: .immediate))) - } - } - .cancellable(id: CancelID.request, cancelInFlight: true) - } - func toggleCompletedEffect(_ item: TodoListItem) -> Effect { .concatenate( .send(.loading(.begin(target: .default, mode: .delayed))), @@ -147,9 +127,5 @@ extension TodoListFeature { if let todoIndex = state.todos.firstIndex(where: { $0.id == todoId }) { state.todos[todoIndex].isHidden = isHidden } - - if let searchResultIndex = state.searchResults.firstIndex(where: { $0.id == todoId }) { - state.searchResults[searchResultIndex].isHidden = isHidden - } } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift similarity index 73% rename from Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift rename to Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift index 6c5874fe..4dbcef72 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift @@ -1,6 +1,6 @@ // // TodoListFeature.swift -// PresentationShared +// HomeTab // // Created by opfic on 6/12/26. // @@ -9,36 +9,32 @@ import ComposableArchitecture import Core import Domain import Foundation +import PresentationShared @Reducer -public struct TodoListFeature { +struct TodoListFeature { @ObservableState - public struct State: Equatable { - @Presents public var alert: AlertState? - @Presents public var fullScreenCover: FullScreenCoverState? - public var category: TodoCategory - public var todos: [TodoListItem] = [] - public var searchText = "" - public var searchResults: [TodoListItem] = [] - public var isSearching = false - public var showAllSearchResults = false - public var query: TodoQuery - public var hasMore = false - public var loading = LoadingFeature.State() + struct State: Equatable { + @Presents var alert: AlertState? + @Presents var fullScreenCover: FullScreenCoverState? + var category: TodoCategory + var todos: [TodoListItem] = [] + var query: TodoQuery + var hasMore = false + var loading = LoadingFeature.State() var undoTodoId: String? var nextCursor: TodoCursor? - let searchResultsLimit = 5 - public init(category: TodoCategory) { + init(category: TodoCategory) { self.category = category self.query = TodoQuery(categoryId: category.storageValue) } - public var isLoading: Bool { + var isLoading: Bool { loading.isLoading } - public var appliedFilterCount: Int { + var appliedFilterCount: Int { var count = 0 if query.sortTarget != .createdAt { count += 1 } if query.sortOrder != .latest { count += 1 } @@ -49,15 +45,15 @@ public struct TodoListFeature { } @ObservableState - public struct FullScreenCoverState: Equatable { - public var destination: Destination + struct FullScreenCoverState: Equatable { + var destination: Destination var todoEditor: TodoEditorFeature.State? - public enum Destination: Equatable { + enum Destination: Equatable { case editor } - public static let editor = Self(destination: .editor) + static let editor = Self(destination: .editor) static func editor(_ category: TodoCategory) -> Self { Self( @@ -67,7 +63,7 @@ public struct TodoListFeature { } } - public enum Action: BindableAction { + enum Action: BindableAction { case alert(PresentationAction) case fullScreenCover(PresentationAction) case binding(BindingAction) @@ -76,11 +72,11 @@ public struct TodoListFeature { case loading(LoadingFeature.Action) @CasePathable - public enum FullScreenCover: Equatable { + enum FullScreenCover: Equatable { case todoEditor(TodoEditorFeature.Action) } - public enum ViewAction: Equatable { + enum ViewAction: Equatable { case refresh case swipeTodo(TodoListItem) case resetFilters @@ -93,11 +89,9 @@ public struct TodoListFeature { case loadNextPage } - public enum StoreAction: Equatable { + enum StoreAction: Equatable { case setFullScreenCover(FullScreenCoverState?) case setAlert(Bool) - case applySearchQuery(String) - case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) case setTodoHidden(String, Bool) @@ -108,12 +102,9 @@ public struct TodoListFeature { } enum CancelID: Hashable { - case debounce case fetch - case request } - @Dependency(\.continuousClock) var clock @Dependency(\.todoListFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase @@ -121,11 +112,9 @@ public struct TodoListFeature { @Dependency(\.todoListUndoDeleteTodoUseCase) var undoDeleteTodoUseCase @Dependency(\.trackAnalyticsEventUseCase) var trackAnalyticsEventUseCase - private let searchDebounceDelay = Duration.seconds(0.4) + init() { } - public init() { } - - public var body: some ReducerOf { + var body: some ReducerOf { Scope(state: \.loading, action: \.loading) { LoadingFeature() } @@ -144,14 +133,6 @@ public struct TodoListFeature { state.fullScreenCover = nil case .fullScreenCover: break - case .binding(\.searchText): - return setSearchTextEffect(state: &state) - case .binding(\.isSearching): - guard !state.isSearching else { break } - state.searchText = "" - state.searchResults = [] - state.showAllSearchResults = false - return cancelSearchEffect() case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), .binding(\.query.completionFilter): state.nextCursor = nil @@ -187,7 +168,7 @@ private struct FullScreenCoverFeature: Reducer { } } -public extension DependencyValues { +extension DependencyValues { var todoListFetchTodosUseCase: FetchTodosUseCase { get { self[FetchTodosUseCaseKey.self] } set { self[FetchTodosUseCaseKey.self] = newValue } @@ -270,50 +251,6 @@ private extension TodoListFeature { .cancellable(id: CancelID.fetch, cancelInFlight: true) } - func setSearchTextEffect(state: inout State) -> Effect { - state.showAllSearchResults = false - let trimmed = state.searchText.trimmingCharacters(in: .whitespacesAndNewlines) - - if trimmed.isEmpty { - state.searchResults = [] - return cancelSearchEffect() - } else { - return .concatenate( - cancelSearchEffect(), - debounceSearchEffect(trimmed) - ) - } - } - - func applySearchQueryEffect(_ query: String, state: inout State) -> Effect { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { - state.searchResults = [] - return cancelSearchEffect() - } else { - return searchEffect(trimmed, category: state.category) - } - } - - func cancelSearchEffect() -> Effect { - .merge( - .cancel(id: CancelID.debounce), - .cancel(id: CancelID.request), - .send(.loading(.end(target: .default, mode: .immediate))) - ) - } - - func debounceSearchEffect(_ keyword: String) -> Effect { - .concatenate( - .send(.loading(.begin(target: .default, mode: .immediate))), - .run { [clock, searchDebounceDelay] send in - try await clock.sleep(for: searchDebounceDelay) - await send(.store(.applySearchQuery(keyword))) - } - .cancellable(id: CancelID.debounce, cancelInFlight: true) - ) - } - func reduce( _ action: Action.ViewAction, state: inout State @@ -336,7 +273,6 @@ private extension TodoListFeature { return fetchEffect(query: state.query, cursor: nil) case .finishDeleteToast(let todoId): state.todos.removeAll { $0.id == todoId && $0.isHidden } - state.searchResults.removeAll { $0.id == todoId && $0.isHidden } if state.undoTodoId == todoId { state.undoTodoId = nil } @@ -366,10 +302,6 @@ private extension TodoListFeature { state.fullScreenCover = cover?.destination == .editor ? .editor(state.category) : nil case .setAlert(let value): Self.setAlert(&state, isPresented: value) - case .applySearchQuery(let query): - return applySearchQueryEffect(query, state: &state) - case .fetchSearchResults(let items): - state.searchResults = items case .didToggleCompleted(let todo), .didTogglePinned(let todo): if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo diff --git a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift similarity index 57% rename from Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift rename to Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift index 2dbec4ca..ff813f8f 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift @@ -1,6 +1,6 @@ // // TodoListView.swift -// PresentationShared +// HomeTab // // Created by opfic on 5/30/25. // @@ -9,50 +9,52 @@ import SwiftUI import Combine import ComposableArchitecture import Core -import Domain +import PresentationShared -public struct TodoListView: View { +struct TodoListView: View { @Environment(\.dismiss) private var dismiss @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac - @State var store: StoreOf + @Namespace private var searchTransition + @State private var isSearchPresented = false + @State private var store: StoreOf + private let searchStore: StoreOf private let windowEvent: TodoEditorWindowEvent? private let onSelectTodo: (String) -> Void - public init( + init( store: StoreOf, + searchStore: StoreOf, windowEvent: TodoEditorWindowEvent? = nil, onSelectTodo: @escaping (String) -> Void = { _ in } ) { self.store = store + self.searchStore = searchStore self.windowEvent = windowEvent self.onSelectTodo = onSelectTodo } - public var body: some View { - Group { - if store.state.isSearching { - todoSearchContent - } else { - todoListContent + var body: some View { + todoListContent + .prominentAlert(store, state: \.alert, action: \.alert) + .onReceive(windowSubmits) { submit in + guard case .create(let value) = submit, + value.matchesCreate(category: store.category, source: .list) else { return } + store.send(.view(.windowTodoCreated)) } - } - .prominentAlert(store, state: \.alert, action: \.alert) - .onReceive(windowSubmits) { submit in - guard case .create(let value) = submit, - value.matchesCreate(category: store.category, source: .list) else { return } - store.send(.view(.windowTodoCreated)) - } - .fullScreenCover( - item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) - .activePresentation(when: isTabContentActive) - ) { coverStore in - fullScreenCoverContent(coverStore) - } - .background(NavigationBarConfigurator()) - .background(Color.appBackground.ignoresSafeArea()) - .task { store.send(.view(.onAppear)) } + .fullScreenCover( + item: $store.scope(state: \.fullScreenCover, action: \.fullScreenCover) + .activePresentation(when: isTabContentActive) + ) { coverStore in + fullScreenCoverContent(coverStore) + } + .fullScreenCover(isPresented: $isSearchPresented) { + SearchView(store: searchStore) + .navigationTransition(.zoom(sourceID: "todo-search", in: searchTransition)) + } + .background(Color.appBackground.ignoresSafeArea()) + .task { store.send(.view(.onAppear)) } } private var windowSubmits: AnyPublisher { @@ -76,64 +78,68 @@ public struct TodoListView: View { .background(Color.surface, in: .rect(cornerRadius: 28)) .padding(.horizontal, 16) } else { - LazyVStack(spacing: 0) { - ForEach(visibleTodos) { todo in - TodoItemRow(todo) - .background(Color.surface) - .contentShape(.rect) - .todoDetailPreview(todoId: todo.id) - .onTapGesture { - selectTodo(todo.id) + ForEach(visibleTodos) { todo in + let isFirst = todo.id == visibleTodos.first?.id + let isLast = todo.id == visibleTodos.last?.id + + TodoItemRow(todo) + .background(Color.surface) + .contentShape(.rect) + .todoDetailPreview(todoId: todo.id) + .onTapGesture { + selectTodo(todo.id) + } + .itemActions { + ItemActionButton( + color: Color.orange, + image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") + ) { + store.send(.view(.tapTogglePinned(todo))) } - .itemActions { - ItemActionButton( - color: Color.orange, - image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") - ) { - store.send(.view(.tapTogglePinned(todo))) - } - ItemActionButton( - color: Color.accent, - image: Image(systemName: todo.isCompleted - ? "arrow.uturn.backward" : "checkmark") - ) { - store.send(.view(.tapToggleCompleted(todo))) - } + ItemActionButton( + color: Color.accent, + image: Image(systemName: todo.isCompleted + ? "arrow.uturn.backward" : "checkmark") + ) { + store.send(.view(.tapToggleCompleted(todo))) + } - ItemActionButton( - color: Color.red, - image: Image(systemName: "trash") - ) { - store.send(.view(.swipeTodo(todo))) - presentDeleteTodoToast(todo.id) - } + ItemActionButton( + color: Color.red, + image: Image(systemName: "trash") + ) { + store.send(.view(.swipeTodo(todo))) + presentDeleteTodoToast(todo.id) } - .overlay(alignment: .bottom) { - if todo.id != visibleTodos.last?.id { - Divider() - } + } + .overlay(alignment: .bottom) { + if !isLast { + Divider() } - .onAppear { - if todo.id == visibleTodos.last?.id, store.state.hasMore { - store.send(.view(.loadNextPage)) - } + } + .onAppear { + if isLast, store.state.hasMore { + store.send(.view(.loadNextPage)) } - } + } + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: isFirst ? 28 : 0, + bottomLeadingRadius: isLast ? 28 : 0, + bottomTrailingRadius: isLast ? 28 : 0, + topTrailingRadius: isFirst ? 28 : 0 + ) + ) + .padding(.horizontal, 16) } - .background(Color.surface, in: .rect(cornerRadius: 28)) - .compositingGroup() - .clipShape(.rect(cornerRadius: 28)) - .padding(.horizontal, 16) } } header: { - stickyHeader + filterHeader } } } - .safeAreaInset(edge: .top, spacing: 0) { - topBar - } + .safeAreaInset(edge: .top, spacing: 0) { topBar } .refreshable { await store.send(.view(.refresh)).finish() } .scrollDisabled(visibleTodos.isEmpty || store.state.isLoading) .toolbarVisibility(.hidden, for: .navigationBar) @@ -144,25 +150,6 @@ public struct TodoListView: View { } } - private var todoSearchContent: some View { - searchResultsContent - .background(Color.appBackground) - .navigationTitle(TodoCategoryItem(from: store.category).localizedName) - .navigationBarTitleDisplayMode(.inline) - .toolbarVisibility(.visible, for: .navigationBar) - .searchable( - text: $store.searchText, - isPresented: $store.isSearching, - placement: .navigationBarDrawer(displayMode: .always), - prompt: Text( - String.localizedStringWithFormat( - String(localized: "todo_list_search_prompt_format", bundle: PresentationResources.bundle), - TodoCategoryItem(from: store.category).localizedName - ) - ) - ) - } - @ViewBuilder private func fullScreenCoverContent( _ coverStore: Store< @@ -202,68 +189,6 @@ public struct TodoListView: View { } } - @ViewBuilder - private var searchResultsContent: some View { - let searchResults = store.state.searchResults.filter { !$0.isHidden } - let limit = store.searchResultsLimit - let displayedTodos = store.state.showAllSearchResults - ? searchResults - : Array(searchResults.prefix(limit)) - - if store.state.searchText.isEmpty { - Text( - String.localizedStringWithFormat( - String(localized: "todo_list_search_instruction_format", bundle: PresentationResources.bundle), - TodoCategoryItem(from: store.category).localizedName - ) - ) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if store.state.isLoading { - LoadingView() - } else if searchResults.isEmpty { - Text(String(localized: "todo_list_search_empty", bundle: PresentationResources.bundle)) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - LazyVStack(spacing: 12) { - LazyVStack(spacing: 0) { - ForEach(displayedTodos) { todo in - Button { - selectTodo(todo.id) - } label: { - TodoItemRow(todo) - .contentShape(.rect) - } - .buttonStyle(.plain) - .todoDetailPreview(todoId: todo.id) - .overlay(alignment: .bottom) { - if todo.id != displayedTodos.last?.id { - Divider() - } - } - } - } - .background(Color.surface, in: .rect(cornerRadius: 28)) - .compositingGroup() - .clipShape(.rect(cornerRadius: 28)) - - if !store.state.showAllSearchResults, limit < searchResults.count { - Button(String(localized: "todo_list_show_more", bundle: PresentationResources.bundle)) { - store.send(.binding(.set(\.showAllSearchResults, true))) - } - .font(.subheadline) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 4) - } - } - .padding(16) - } - } - } - private var topBar: some View { ZStack { Text(TodoCategoryItem(from: store.category).localizedName) @@ -280,14 +205,15 @@ public struct TodoListView: View { if #available(iOS 26.0, *) { Button { - store.send(.binding(.set(\.isSearching, true))) + isSearchPresented = true } label: { Image(systemName: "magnifyingglass") } .topBarButtonStyle() + .matchedTransitionSource(id: "todo-search", in: searchTransition) } else { Button { - store.send(.binding(.set(\.isSearching, true))) + isSearchPresented = true } label: { Image(systemName: "magnifyingglass") .font(.title3.weight(.semibold)) @@ -298,6 +224,7 @@ public struct TodoListView: View { color: .surface, glassEffect: .enabled ) + .matchedTransitionSource(id: "todo-search", in: searchTransition) } if #available(iOS 26.0, *) { @@ -324,18 +251,11 @@ public struct TodoListView: View { } } .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.bottom, 12) .background(Color.appBackground, ignoresSafeAreaEdges: .top) } - private var stickyHeader: some View { - headerView - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 12) - .background(Color.appBackground) - } - - private var headerView: some View { + private var filterHeader: some View { ScrollView(.horizontal) { HStack(spacing: 8) { if 0 < store.appliedFilterCount { @@ -417,6 +337,9 @@ public struct TodoListView: View { .scrollIndicators(.hidden) .fixedSize(horizontal: false, vertical: true) .contentMargins(.horizontal, 16, for: .scrollContent) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 12) + .background(Color.appBackground) } private func selectTodo(_ todoId: String) { diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoListFeatureTestDoubles.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift similarity index 90% rename from Application/Presentation/PresentationShared/Tests/Todo/TodoListFeatureTestDoubles.swift rename to Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift index d8c69f4d..1cf823cc 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoListFeatureTestDoubles.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift @@ -1,6 +1,6 @@ // // TodoListFeatureTestDoubles.swift -// PresentationSharedTests +// HomeTabTests // // Created by opfic on 6/12/26. // @@ -8,17 +8,14 @@ import Foundation import Core import Domain -@testable import PresentationShared +import PresentationShared +@testable import HomeTab @MainActor final class TodoListStoreTestAdapter { private let store: TestStoreOf var todos: [TodoListItem] { store.state.todos } - var searchText: String { store.state.searchText } - var searchResults: [TodoListItem] { store.state.searchResults } - var isSearching: Bool { store.state.isSearching } - var showAllSearchResults: Bool { store.state.showAllSearchResults } var query: TodoQuery { store.state.query } var isLoading: Bool { store.state.isLoading } var hasMore: Bool { store.state.hasMore } @@ -49,7 +46,6 @@ final class TodoListStoreTestAdapter { $0.todoListDeleteTodoUseCase = deleteUseCase $0.todoListUndoDeleteTodoUseCase = undoDeleteUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - $0.continuousClock = ContinuousClock() configureDependencies?(&$0) } store.exhaustivity = .off(showSkippedAssertions: false) @@ -90,30 +86,6 @@ final class TodoListStoreTestAdapter { await drainReceivedActions() } - func setSearchText(_ text: String) async { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - await store.send(.binding(.set(\.searchText, text))) - await drainReceivedActions() - - if !trimmed.isEmpty { - try? await Task.sleep(for: .milliseconds(450)) - await drainReceivedActions() - } - } - - func setSearchResults(_ results: [TodoListItem]) async { - await store.send(.store(.fetchSearchResults(results))) - } - - func setIsSearching(_ value: Bool) async { - await store.send(.binding(.set(\.isSearching, value))) - await drainReceivedActions() - } - - func setShowAllSearchResults(_ value: Bool) async { - await store.send(.binding(.set(\.showAllSearchResults, value))) - } - func appendTodos(_ todos: [TodoListItem]) async { await store.send(.store(.appendTodos(todos, nextCursor: nil))) } diff --git a/Application/Presentation/PresentationShared/Tests/Todo/TodoListFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift similarity index 80% rename from Application/Presentation/PresentationShared/Tests/Todo/TodoListFeatureTests.swift rename to Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift index bc6c1f73..faa1f621 100644 --- a/Application/Presentation/PresentationShared/Tests/Todo/TodoListFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift @@ -1,6 +1,6 @@ // // TodoListFeatureTests.swift -// PresentationSharedTests +// HomeTabTests // // Created by opfic on 6/12/26. // @@ -9,7 +9,8 @@ import Testing import Foundation import Core import Domain -@testable import PresentationShared +import PresentationShared +@testable import HomeTab @MainActor struct TodoListFeatureTests { @@ -110,47 +111,6 @@ struct TodoListFeatureTests { #expect(adapter.appliedFilterCount == 0) } - @Test("setSearchText는 표시 범위를 초기화하고 디바운스 후 검색 결과를 반영한다") - func setSearchText는_표시_범위를_초기화하고_디바운스_후_검색_결과를_반영한다() async { - let todo = makeTodoListTodo(id: "todo-search", title: "Swift") - let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ - TodoPage(items: [todo], nextCursor: nil) - ]) - let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) - - await adapter.setShowAllSearchResults(true) - await adapter.setSearchText(" swift ") - - #expect(adapter.searchText == " swift ") - #expect(!adapter.showAllSearchResults) - - await waitUntil(timeout: .seconds(2)) { - adapter.searchResults == [TodoListItem(from: todo)] - } - - #expect(fetchSpy.queries.map(\.keyword) == ["swift"]) - #expect(fetchSpy.cursors.map { $0?.documentID } == [nil]) - #expect(!adapter.isLoading) - } - - @Test("setIsSearching false는 검색 상태와 검색 결과 표시 상태를 초기화한다") - func setIsSearching_false는_검색_상태와_검색_결과_표시_상태를_초기화한다() async { - let todo = TodoListItem(from: makeTodoListTodo(id: "todo-search"))! - let adapter = TodoListStoreTestAdapter() - - await adapter.setSearchResults([todo]) - await adapter.setShowAllSearchResults(true) - await adapter.setSearchText("swift") - await adapter.setIsSearching(true) - await adapter.setIsSearching(false) - - #expect(!adapter.isSearching) - #expect(adapter.searchText.isEmpty) - #expect(adapter.searchResults.isEmpty) - #expect(!adapter.showAllSearchResults) - #expect(!adapter.isLoading) - } - @Test("fullScreenCover 상태를 설정하고 dismiss 할 수 있다") func fullScreenCover_상태를_설정하고_dismiss_할_수_있다() async { let adapter = TodoListStoreTestAdapter() @@ -195,11 +155,9 @@ struct TodoListFeatureTests { ) await adapter.appendTodos([item]) - await adapter.setSearchResults([item]) await adapter.swipeTodo(item) #expect(adapter.todos.first?.isHidden == true) - #expect(adapter.searchResults.first?.isHidden == true) await waitUntil { deleteSpy.todoIds == ["todo-delete"] @@ -208,7 +166,6 @@ struct TodoListFeatureTests { await adapter.undoDelete() #expect(adapter.todos.first?.isHidden == false) - #expect(adapter.searchResults.first?.isHidden == false) await waitUntil { undoSpy.todoIds == ["todo-delete"] @@ -218,7 +175,6 @@ struct TodoListFeatureTests { await adapter.finishDeleteToast("todo-delete") #expect(adapter.todos.isEmpty) - #expect(adapter.searchResults.isEmpty) } @Test("tapToggleCompleted와 tapTogglePinned는 조회한 Todo를 갱신해 목록에 반영한다") diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index cf2cf3ff..f9eef478 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -4129,57 +4129,6 @@ } } }, - "todo_list_search_instruction_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Search titles or content in %1$@." - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@의 제목이나 내용을 검색해 보세요." - } - } - } - }, - "todo_list_search_prompt_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Search %1$@ (e.g. #123)" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@ 검색 (예: #123)" - } - } - } - }, - "todo_list_show_more" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show More" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "더보기" - } - } - } - }, "todo_list_sort_by" : { "extractionState" : "manual", "localizations" : { @@ -4197,23 +4146,6 @@ } } }, - "todo_list_sort_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sort: %1$@ / %2$@" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "정렬: %1$@ / %2$@" - } - } - } - }, "todo_list_sort_order" : { "extractionState" : "manual", "localizations" : { diff --git a/Application/Presentation/PresentationShared/Sources/Common/NavigationBarConfigurator.swift b/Application/Presentation/PresentationShared/Sources/Common/NavigationBarConfigurator.swift deleted file mode 100644 index 11a5ad40..00000000 --- a/Application/Presentation/PresentationShared/Sources/Common/NavigationBarConfigurator.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// NavigationBarConfigurator.swift -// PresentationShared -// -// Created by 최윤진 on 3/5/26. -// - -import SwiftUI - -/// NavigationBar의 배경색을 지정하고 shadowColor를 제거하는 구조체 -/// -/// 기본적으로 ``UIColor/systemGroupedBackground``를 배경색으로 사용하며, -/// 자체 `NavigationStack`을 가진 뷰에서는 `alwaysVisible`을 `true`로 설정하여 -/// 스크롤 위치와 관계없이 배경색이 항상 표시되도록 할 수 있다. -public struct NavigationBarConfigurator: UIViewControllerRepresentable { - private let backgroundColor: UIColor - private let alwaysVisible: Bool - - /// 지정된 배경색으로 Configurator를 생성한다. - /// - /// - Parameter backgroundColor: NavigationBar에 적용할 배경색. - public init(_ backgroundColor: UIColor = .systemGroupedBackground) { - self.backgroundColor = backgroundColor - self.alwaysVisible = false - } - - /// 지정된 배경색과 상시 표시 옵션으로 Configurator를 생성한다. - /// - /// - Parameters: - /// - backgroundColor: NavigationBar에 적용할 배경색. - /// - alwaysVisible: `true`이면 스크롤 위치와 관계없이 배경색이 항상 표시된다. - /// 자체 `NavigationStack`을 가진 뷰에서 사용한다. - @available(iOS, deprecated: 18, message: "iOS 18 이상에서는 alwaysVisible 파라미터가 없는 생성자를 사용한다.") - public init(_ backgroundColor: UIColor = .systemGroupedBackground, alwaysVisible: Bool) { - self.backgroundColor = backgroundColor - if #available(iOS 18.0, *) { - self.alwaysVisible = false - } else { - self.alwaysVisible = alwaysVisible - } - } - - public func makeCoordinator() -> Coordinator { - Coordinator() - } - - public func makeUIViewController(context: Context) -> UIViewController { - UIViewController() - } - - public func updateUIViewController(_ uiViewController: UIViewController, context: Context) { - if #available(iOS 26, *) { return } - DispatchQueue.main.async { - guard let navigationBar = uiViewController.navigationController?.navigationBar else { return } - let coordinator = context.coordinator - if coordinator.originalShadowColor == nil { - coordinator.originalShadowColor = navigationBar.standardAppearance.shadowColor - coordinator.originalBackgroundColor = navigationBar.standardAppearance.backgroundColor - } - if self.alwaysVisible, navigationBar.scrollEdgeAppearance == nil { - let appearance = UINavigationBarAppearance() - appearance.configureWithDefaultBackground() - navigationBar.scrollEdgeAppearance = appearance - } - Self.applyAppearance( - to: navigationBar, - shadowColor: .clear, - backgroundColor: self.backgroundColor - ) - } - } - - public static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) { - if #available(iOS 26, *) { return } - guard let navigationBar = uiViewController.navigationController?.navigationBar else { return } - applyAppearance( - to: navigationBar, - shadowColor: coordinator.originalShadowColor, - backgroundColor: coordinator.originalBackgroundColor - ) - } - - private static func applyAppearance( - to navigationBar: UINavigationBar, - shadowColor: UIColor?, - backgroundColor: UIColor? - ) { - let appearances = [ - navigationBar.standardAppearance, - navigationBar.scrollEdgeAppearance, - navigationBar.compactAppearance, - navigationBar.compactScrollEdgeAppearance - ] - - for appearance in appearances { - appearance?.shadowColor = shadowColor - appearance?.backgroundColor = backgroundColor - } - } - - public class Coordinator { - var originalShadowColor: UIColor? - var originalBackgroundColor: UIColor? - } -} diff --git a/Application/Presentation/PresentationShared/Sources/Dependency/TodoDependencyPreparation.swift b/Application/Presentation/PresentationShared/Sources/Dependency/TodoDependencyPreparation.swift index d315b634..6bff2988 100644 --- a/Application/Presentation/PresentationShared/Sources/Dependency/TodoDependencyPreparation.swift +++ b/Application/Presentation/PresentationShared/Sources/Dependency/TodoDependencyPreparation.swift @@ -28,29 +28,4 @@ public enum TodoDependencyPreparation { dependencies.upsertTodoUseCase = upsertTodoUseCase } - public static func prepareListQuery( - _ dependencies: inout DependencyValues, - fetchTodosUseCase: FetchTodosUseCase, - fetchTodoByIdUseCase: FetchTodoByIdUseCase, - fetchReferenceItemsUseCase: FetchReferenceItemsUseCase, - fetchTodoCategoryPreferencesUseCase: FetchTodoCategoryPreferencesUseCase - ) { - dependencies.todoListFetchTodosUseCase = fetchTodosUseCase - dependencies.fetchTodoByIdUseCase = fetchTodoByIdUseCase - dependencies.fetchReferenceItemsUseCase = fetchReferenceItemsUseCase - dependencies.fetchTodoCategoryPreferencesUseCase = fetchTodoCategoryPreferencesUseCase - } - - public static func prepareListMutation( - _ dependencies: inout DependencyValues, - upsertTodoUseCase: UpsertTodoUseCase, - deleteTodoUseCase: DeleteTodoUseCase, - undoDeleteTodoUseCase: UndoDeleteTodoUseCase, - trackAnalyticsEventUseCase: TrackAnalyticsEventUseCase - ) { - dependencies.upsertTodoUseCase = upsertTodoUseCase - dependencies.todoListDeleteTodoUseCase = deleteTodoUseCase - dependencies.todoListUndoDeleteTodoUseCase = undoDeleteTodoUseCase - dependencies.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase - } } diff --git a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift index 384e6cff..115dd22d 100644 --- a/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift +++ b/Application/Presentation/PresentationShared/Sources/Todo/Editor/TodoEditorView.swift @@ -280,7 +280,7 @@ private struct TitleField: View { bundle: PresentationResources.bundle ) ) - .foregroundColor(Color.secondary), + .foregroundStyle(Color.secondary), ) .font(.body) .focused($field, equals: .title) From 8d6561f858aafb2434ca661455ac082509fb96bd Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 12:15:49 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B5=AC=EC=84=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Todo/List/TodoListView.swift | 105 +++++++++--------- 1 file changed, 54 insertions(+), 51 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift index ff813f8f..24b4ab18 100644 --- a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift @@ -75,64 +75,67 @@ struct TodoListView: View { .foregroundStyle(Color.textSecondary) .frame(maxWidth: .infinity) .padding(.vertical, 32) - .background(Color.surface, in: .rect(cornerRadius: 28)) + .background(Color.surface, in: .rect(cornerRadius: 16)) .padding(.horizontal, 16) } else { - ForEach(visibleTodos) { todo in - let isFirst = todo.id == visibleTodos.first?.id - let isLast = todo.id == visibleTodos.last?.id + ZStack { + RoundedRectangle(cornerRadius: 16) + .fill(Color.surface) - TodoItemRow(todo) - .background(Color.surface) - .contentShape(.rect) - .todoDetailPreview(todoId: todo.id) - .onTapGesture { - selectTodo(todo.id) - } - .itemActions { - ItemActionButton( - color: Color.orange, - image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") - ) { - store.send(.view(.tapTogglePinned(todo))) - } + VStack(spacing: 0) { + ForEach( + Array(zip(visibleTodos.indices, visibleTodos)), + id: \.1.id + ) { index, todo in + let isLast = index == visibleTodos.count - 1 - ItemActionButton( - color: Color.accent, - image: Image(systemName: todo.isCompleted - ? "arrow.uturn.backward" : "checkmark") - ) { - store.send(.view(.tapToggleCompleted(todo))) - } + TodoItemRow(todo) + .background(Color.surface) + .contentShape(.rect) + .todoDetailPreview(todoId: todo.id) + .onTapGesture { + selectTodo(todo.id) + } + .itemActions { + ItemActionButton( + color: Color.orange, + image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") + ) { + store.send(.view(.tapTogglePinned(todo))) + } - ItemActionButton( - color: Color.red, - image: Image(systemName: "trash") - ) { - store.send(.view(.swipeTodo(todo))) - presentDeleteTodoToast(todo.id) - } - } - .overlay(alignment: .bottom) { - if !isLast { - Divider() - } - } - .onAppear { - if isLast, store.state.hasMore { - store.send(.view(.loadNextPage)) - } + ItemActionButton( + color: Color.accent, + image: Image(systemName: todo.isCompleted + ? "arrow.uturn.backward" : "checkmark") + ) { + store.send(.view(.tapToggleCompleted(todo))) + } + + ItemActionButton( + color: Color.red, + image: Image(systemName: "trash") + ) { + store.send(.view(.swipeTodo(todo))) + presentDeleteTodoToast(todo.id) + } + } + .overlay(alignment: .bottom) { + if !isLast { + Divider() + } + } + .onAppear { + if isLast, store.state.hasMore { + store.send(.view(.loadNextPage)) + } + } } - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: isFirst ? 28 : 0, - bottomLeadingRadius: isLast ? 28 : 0, - bottomTrailingRadius: isLast ? 28 : 0, - topTrailingRadius: isFirst ? 28 : 0 - ) - ) - .padding(.horizontal, 16) + } } + .compositingGroup() + .clipShape(.rect(cornerRadius: 16)) + .padding(.horizontal, 16) } } header: { filterHeader From 13cdc5d9327372c95671e29ba86afad6783adf62 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 12:44:40 +0900 Subject: [PATCH 05/11] =?UTF-8?q?chore:=20SwiftLint=20=EC=A4=84=20?= =?UTF-8?q?=EA=B8=B8=EC=9D=B4=20=EA=B8=B0=EC=A4=80=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .swiftlint.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index 014709f4..5d49a56c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -13,6 +13,8 @@ excluded: - Widget/DevLogWidgetCore/Project.swift - Widget/DevLogWidgetExtension/Project.swift +line_length: 200 + disabled_rules: - nesting - multiple_closures_with_trailing_closure From 7749412f18541de09644d9d6fa1b446d41d0226d Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 14:31:20 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EA=B2=80=EC=83=89=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeTab/Sources/Home/HomeView.swift | 1 - .../Sources/Todo/List/TodoListFeature.swift | 56 +++++- .../Sources/Todo/List/TodoListView.swift | 175 ++++++++++++------ .../Todo/TodoListFeatureTestDoubles.swift | 21 +++ .../Tests/Todo/TodoListFeatureTests.swift | 64 ++++++- 5 files changed, 252 insertions(+), 65 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index ce166cfe..f38fbbc9 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -316,7 +316,6 @@ public struct HomeView: View { store: Store(initialState: TodoListFeature.State(category: item.todoCategory)) { TodoListFeature() }, - searchStore: searchStore, windowEvent: windowEvent, onSelectTodo: { router.push(.todo(TodoIdItem(id: $0))) } ) diff --git a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift index 4dbcef72..9676b62e 100644 --- a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift @@ -21,7 +21,9 @@ struct TodoListFeature { var todos: [TodoListItem] = [] var query: TodoQuery var hasMore = false + var isSearching = false var loading = LoadingFeature.State() + var searchQuery = "" var undoTodoId: String? var nextCursor: TodoCursor? @@ -87,6 +89,8 @@ struct TodoListFeature { case onAppear case windowTodoCreated case loadNextPage + case searchQueryDebounced + case setSearching(Bool) } enum StoreAction: Equatable { @@ -103,8 +107,10 @@ struct TodoListFeature { enum CancelID: Hashable { case fetch + case searchDebounce } + @Dependency(\.continuousClock) var clock @Dependency(\.todoListFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase @Dependency(\.upsertTodoUseCase) var upsertTodoUseCase @@ -136,7 +142,24 @@ struct TodoListFeature { case .binding(\.query.sortTarget), .binding(\.query.sortOrder), .binding(\.query.isPinned), .binding(\.query.completionFilter): state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) + return .merge( + .cancel(id: CancelID.searchDebounce), + fetchEffect(query: state.query, cursor: nil) + ) + case .binding(\.searchQuery): + let keyword = state.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + state.query.keyword = keyword.isEmpty ? nil : keyword + state.nextCursor = nil + if keyword.isEmpty { + return .merge( + .cancel(id: CancelID.searchDebounce), + fetchEffect(query: state.query, cursor: nil) + ) + } + return .concatenate( + .cancel(id: CancelID.searchDebounce), + debounceSearchEffect() + ) case .binding: break case .view(let action): @@ -223,6 +246,7 @@ private extension TodoListFeature { showsIndicator: Bool = true ) -> Effect { .concatenate( + .send(.loading(.end(target: .default, mode: .delayed))), showsIndicator ? .send(.loading(.begin(target: .default, mode: .delayed))) : .none, .run { [fetchTodosUseCase] send in do { @@ -251,6 +275,14 @@ private extension TodoListFeature { .cancellable(id: CancelID.fetch, cancelInFlight: true) } + func debounceSearchEffect() -> Effect { + .run { [clock] send in + try await clock.sleep(for: .milliseconds(400)) + await send(.view(.searchQueryDebounced)) + } + .cancellable(id: CancelID.searchDebounce, cancelInFlight: true) + } + func reduce( _ action: Action.ViewAction, state: inout State @@ -268,9 +300,15 @@ private extension TodoListFeature { case .swipeTodo(let todo): return swipeTodoEffect(todo, state: &state) case .resetFilters: - state.query = TodoQuery(categoryId: state.category.storageValue) + state.query = TodoQuery( + categoryId: state.category.storageValue, + keyword: state.query.keyword + ) state.nextCursor = nil - return fetchEffect(query: state.query, cursor: nil) + return .merge( + .cancel(id: CancelID.searchDebounce), + fetchEffect(query: state.query, cursor: nil) + ) case .finishDeleteToast(let todoId): state.todos.removeAll { $0.id == todoId && $0.isHidden } if state.undoTodoId == todoId { @@ -288,6 +326,18 @@ private extension TodoListFeature { case .loadNextPage: guard state.hasMore, !state.isLoading else { return .none } return fetchEffect(query: state.query, cursor: state.nextCursor, resetsPagination: false) + case .searchQueryDebounced: + return fetchEffect(query: state.query, cursor: nil) + case .setSearching(let isSearching): + state.isSearching = isSearching + guard !isSearching, !state.searchQuery.isEmpty else { return .none } + state.searchQuery = "" + state.query.keyword = nil + state.nextCursor = nil + return .merge( + .cancel(id: CancelID.searchDebounce), + fetchEffect(query: state.query, cursor: nil) + ) } return .none diff --git a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift index 24b4ab18..4670b9a1 100644 --- a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift @@ -16,21 +16,18 @@ struct TodoListView: View { @Environment(\.isTabContentActive) private var isTabContentActive @Environment(\.openWindow) private var openWindow @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac + @FocusState private var isSearchFocused: Bool @Namespace private var searchTransition - @State private var isSearchPresented = false - @State private var store: StoreOf - private let searchStore: StoreOf + @Bindable private var store: StoreOf private let windowEvent: TodoEditorWindowEvent? private let onSelectTodo: (String) -> Void init( store: StoreOf, - searchStore: StoreOf, windowEvent: TodoEditorWindowEvent? = nil, onSelectTodo: @escaping (String) -> Void = { _ in } ) { self.store = store - self.searchStore = searchStore self.windowEvent = windowEvent self.onSelectTodo = onSelectTodo } @@ -49,10 +46,6 @@ struct TodoListView: View { ) { coverStore in fullScreenCoverContent(coverStore) } - .fullScreenCover(isPresented: $isSearchPresented) { - SearchView(store: searchStore) - .navigationTransition(.zoom(sourceID: "todo-search", in: searchTransition)) - } .background(Color.appBackground.ignoresSafeArea()) .task { store.send(.view(.onAppear)) } } @@ -61,6 +54,56 @@ struct TodoListView: View { windowEvent?.submits ?? Empty().eraseToAnyPublisher() } + private var searchBar: some View { + HStack(spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Color.textSecondary) + TextField( + "", + text: $store.searchQuery, + prompt: Text(String( + localized: "search_prompt", + bundle: PresentationResources.bundle + )) + .foregroundStyle(Color.textSecondary) + ) + .focused($isSearchFocused) + .task { + await Task.yield() + isSearchFocused = true + } + if !store.searchQuery.isEmpty { + Button { + store.send(.binding(.set(\.searchQuery, ""))) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Color.textSecondary) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity) + .adaptiveButtonStyle( + shape: Capsule(), + color: .surface, + glassEffect: .enabled + ) + .matchedGeometryEffect(id: "todo-search", in: searchTransition) + + Button { + isSearchFocused = false + withAnimation(.smooth(duration: 0.3)) { + store.send(.view(.setSearching(false))) + } + } label: { + Text(String(localized: "common_cancel", bundle: PresentationResources.bundle)) + .foregroundStyle(Color.accent) + } + .buttonStyle(.plain) + } + } + @ViewBuilder private var todoListContent: some View { let visibleTodos = store.state.todos.filter { !$0.isHidden } @@ -193,65 +236,77 @@ struct TodoListView: View { } private var topBar: some View { - ZStack { - Text(TodoCategoryItem(from: store.category).localizedName) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.8) + VStack(spacing: 8) { + ZStack { + Text(TodoCategoryItem(from: store.category).localizedName) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.8) - HStack(spacing: 12) { - NavigationBackButton { - dismiss() - } + HStack(spacing: 12) { + NavigationBackButton { + dismiss() + } - Spacer() + Spacer() - if #available(iOS 26.0, *) { - Button { - isSearchPresented = true - } label: { - Image(systemName: "magnifyingglass") - } - .topBarButtonStyle() - .matchedTransitionSource(id: "todo-search", in: searchTransition) - } else { - Button { - isSearchPresented = true - } label: { - Image(systemName: "magnifyingglass") - .font(.title3.weight(.semibold)) - .frame(width: 28, height: 28) + if !store.isSearching { + if #available(iOS 26.0, *) { + Button { + withAnimation(.smooth(duration: 0.3)) { + store.send(.view(.setSearching(true))) + } + } label: { + Image(systemName: "magnifyingglass") + } + .topBarButtonStyle() + .matchedGeometryEffect(id: "todo-search", in: searchTransition) + } else { + Button { + withAnimation(.smooth(duration: 0.3)) { + store.send(.view(.setSearching(true))) + } + } label: { + Image(systemName: "magnifyingglass") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) + .matchedGeometryEffect(id: "todo-search", in: searchTransition) + } } - .adaptiveButtonStyle( - shape: .circle, - color: .surface, - glassEffect: .enabled - ) - .matchedTransitionSource(id: "todo-search", in: searchTransition) - } - if #available(iOS 26.0, *) { - Button { - openTodoEditor() - } label: { - Image(systemName: "plus") - } - .topBarButtonStyle() - } else { - Button { - openTodoEditor() - } label: { - Image(systemName: "plus") - .font(.title3.weight(.semibold)) - .frame(width: 28, height: 28) + if #available(iOS 26.0, *) { + Button { + openTodoEditor() + } label: { + Image(systemName: "plus") + } + .topBarButtonStyle() + } else { + Button { + openTodoEditor() + } label: { + Image(systemName: "plus") + .font(.title3.weight(.semibold)) + .frame(width: 28, height: 28) + } + .adaptiveButtonStyle( + shape: .circle, + color: .surface, + glassEffect: .enabled + ) } - .adaptiveButtonStyle( - shape: .circle, - color: .surface, - glassEffect: .enabled - ) } } + + if store.isSearching { + searchBar + } } .padding(.horizontal, 16) .padding(.bottom, 12) diff --git a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift index 1cf823cc..0fcba440 100644 --- a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift @@ -17,6 +17,8 @@ final class TodoListStoreTestAdapter { var todos: [TodoListItem] { store.state.todos } var query: TodoQuery { store.state.query } + var isSearching: Bool { store.state.isSearching } + var searchQuery: String { store.state.searchQuery } var isLoading: Bool { store.state.isLoading } var hasMore: Bool { store.state.hasMore } var alert: AlertState? { store.state.alert } @@ -81,6 +83,25 @@ final class TodoListStoreTestAdapter { await drainReceivedActions() } + func setSearchQuery(_ query: String) async { + let keyword = query.trimmingCharacters(in: .whitespacesAndNewlines) + await store.send(.binding(.set(\.searchQuery, query))) { + $0.searchQuery = query + $0.query.keyword = keyword.isEmpty ? nil : keyword + $0.nextCursor = nil + } + } + + func receiveSearchQueryDebounced() async { + await store.receive(.view(.searchQueryDebounced)) + await drainReceivedActions() + } + + func setSearching(_ value: Bool) async { + await store.send(.view(.setSearching(value))) + await drainReceivedActions() + } + func resetFilters() async { await store.send(.view(.resetFilters)) await drainReceivedActions() diff --git a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift index faa1f621..92ef4e50 100644 --- a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift @@ -71,7 +71,13 @@ struct TodoListFeatureTests { TodoPage(items: [firstTodo], nextCursor: nil), TodoPage(items: [secondTodo], nextCursor: nil) ]) - let adapter = TodoListStoreTestAdapter(fetchUseCase: fetchSpy) + let clock = TestClock() + let adapter = TodoListStoreTestAdapter( + fetchUseCase: fetchSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) await adapter.onAppear() await fetchSpy.waitForCallCount(1) @@ -88,6 +94,10 @@ struct TodoListFeatureTests { #expect(queries.map(\.sortTarget) == [.createdAt, .updatedAt]) #expect(cancelledCalls == [0]) #expect(!adapter.showAlert) + + await clock.advance(by: .milliseconds(300)) + + #expect(!adapter.isLoading) } @Test("필터와 정렬 액션은 query와 적용 필터 수를 갱신한다") @@ -111,6 +121,58 @@ struct TodoListFeatureTests { #expect(adapter.appliedFilterCount == 0) } + @Test("검색어는 디바운스 후 현재 카테고리 목록을 다시 조회한다") + func 검색어는_디바운스_후_현재_카테고리_목록을_다시_조회한다() async { + let todo = makeTodoListTodo(id: "todo-search", title: "Swift") + let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ + TodoPage(items: [todo], nextCursor: nil) + ]) + let clock = TestClock() + let adapter = TodoListStoreTestAdapter( + fetchUseCase: fetchSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.setSearchQuery(" swift ") + await clock.advance(by: .milliseconds(400)) + await adapter.receiveSearchQueryDebounced() + + await waitUntil { + fetchSpy.queries.count == 1 + } + + #expect(adapter.searchQuery == " swift ") + #expect(fetchSpy.queries.map(\.categoryId) == ["feature"]) + #expect(fetchSpy.queries.map(\.keyword) == ["swift"]) + #expect(adapter.todos == [TodoListItem(from: todo)!]) + } + + @Test("검색 종료는 검색어를 지우고 현재 카테고리 목록을 다시 조회한다") + func 검색_종료는_검색어를_지우고_현재_카테고리_목록을_다시_조회한다() async { + let fetchSpy = TodoListFetchTodosUseCaseSpy() + let clock = TestClock() + let adapter = TodoListStoreTestAdapter( + fetchUseCase: fetchSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.setSearching(true) + await adapter.setSearchQuery("swift") + await adapter.setSearching(false) + + await waitUntil { + fetchSpy.queries.count == 1 + } + + #expect(!adapter.isSearching) + #expect(adapter.searchQuery.isEmpty) + #expect(fetchSpy.queries.map(\.keyword) == [nil]) + } + @Test("fullScreenCover 상태를 설정하고 dismiss 할 수 있다") func fullScreenCover_상태를_설정하고_dismiss_할_수_있다() async { let adapter = TodoListStoreTestAdapter() From d1fb3b318766112b2b691f704d577d9055a5e850 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 14:49:57 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A7=80=EC=97=B0=20=EB=A0=8C=EB=8D=94=EB=A7=81=EA=B3=BC=20?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=EB=B0=B0=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Todo/List/TodoListView.swift | 128 ++++++++---------- 1 file changed, 59 insertions(+), 69 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift index 4670b9a1..c01851a8 100644 --- a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift @@ -110,79 +110,68 @@ struct TodoListView: View { ZStack { ScrollView { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - if visibleTodos.isEmpty, !store.state.isLoading { - Text(String(localized: "todo_list_empty", bundle: PresentationResources.bundle)) - .font(.callout) - .foregroundStyle(Color.textSecondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 32) - .background(Color.surface, in: .rect(cornerRadius: 16)) - .padding(.horizontal, 16) - } else { - ZStack { - RoundedRectangle(cornerRadius: 16) - .fill(Color.surface) - - VStack(spacing: 0) { - ForEach( - Array(zip(visibleTodos.indices, visibleTodos)), - id: \.1.id - ) { index, todo in - let isLast = index == visibleTodos.count - 1 + if visibleTodos.isEmpty, !store.state.isLoading { + Text(String(localized: "todo_list_empty", bundle: PresentationResources.bundle)) + .font(.callout) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background(Color.surface, in: .rect(cornerRadius: 16)) + .padding(.horizontal, 16) + } else { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach( + Array(zip(visibleTodos.indices, visibleTodos)), + id: \.1.id + ) { index, todo in + let isLast = index == visibleTodos.count - 1 - TodoItemRow(todo) - .background(Color.surface) - .contentShape(.rect) - .todoDetailPreview(todoId: todo.id) - .onTapGesture { - selectTodo(todo.id) - } - .itemActions { - ItemActionButton( - color: Color.orange, - image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") - ) { - store.send(.view(.tapTogglePinned(todo))) - } + TodoItemRow(todo) + .background(Color.surface) + .contentShape(.rect) + .todoDetailPreview(todoId: todo.id) + .onTapGesture { + selectTodo(todo.id) + } + .itemActions { + ItemActionButton( + color: Color.orange, + image: Image(systemName: "star\(todo.isPinned ? ".slash" : ".fill")") + ) { + store.send(.view(.tapTogglePinned(todo))) + } - ItemActionButton( - color: Color.accent, - image: Image(systemName: todo.isCompleted - ? "arrow.uturn.backward" : "checkmark") - ) { - store.send(.view(.tapToggleCompleted(todo))) - } + ItemActionButton( + color: Color.accent, + image: Image(systemName: todo.isCompleted + ? "arrow.uturn.backward" : "checkmark") + ) { + store.send(.view(.tapToggleCompleted(todo))) + } - ItemActionButton( - color: Color.red, - image: Image(systemName: "trash") - ) { - store.send(.view(.swipeTodo(todo))) - presentDeleteTodoToast(todo.id) - } - } - .overlay(alignment: .bottom) { - if !isLast { - Divider() - } - } - .onAppear { - if isLast, store.state.hasMore { - store.send(.view(.loadNextPage)) - } - } + ItemActionButton( + color: Color.red, + image: Image(systemName: "trash") + ) { + store.send(.view(.swipeTodo(todo))) + presentDeleteTodoToast(todo.id) + } + } + .overlay(alignment: .bottom) { + if !isLast { + Divider() + } + } + .onAppear { + if isLast, store.state.hasMore { + store.send(.view(.loadNextPage)) } } - } - .compositingGroup() - .clipShape(.rect(cornerRadius: 16)) - .padding(.horizontal, 16) } - } header: { - filterHeader } + .background(Color.surface) + .clipShape(.rect(cornerRadius: 16)) + .padding(.horizontal, 16) } } .safeAreaInset(edge: .top, spacing: 0) { topBar } @@ -303,12 +292,15 @@ struct TodoListView: View { } } } + .padding(.horizontal, 16) if store.isSearching { searchBar + .padding(.horizontal, 16) } + + filterHeader } - .padding(.horizontal, 16) .padding(.bottom, 12) .background(Color.appBackground, ignoresSafeAreaEdges: .top) } @@ -396,8 +388,6 @@ struct TodoListView: View { .fixedSize(horizontal: false, vertical: true) .contentMargins(.horizontal, 16, for: .scrollContent) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.bottom, 12) - .background(Color.appBackground) } private func selectTodo(_ todoId: String) { From a8309e8ab7578cc163ea86aac28d553f35cc72b5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 15:14:51 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9A=94=EC=B2=AD=EA=B3=BC=20=EB=A1=9C?= =?UTF-8?q?=EB=94=A9=20=EC=83=81=ED=83=9C=20=EC=B2=98=EB=A6=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Todo/List/TodoListFeature.swift | 14 +- .../Todo/TodoListFeatureTestDoubles.swift | 21 ++- .../Tests/Todo/TodoListFeatureTests.swift | 131 ++++++++++++++++++ 3 files changed, 161 insertions(+), 5 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift index 9676b62e..86a97652 100644 --- a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListFeature.swift @@ -110,6 +110,8 @@ struct TodoListFeature { case searchDebounce } + private static let fetchLoadingTarget = LoadingFeature.Target("todoList.fetch") + @Dependency(\.continuousClock) var clock @Dependency(\.todoListFetchTodosUseCase) var fetchTodosUseCase @Dependency(\.fetchTodoByIdUseCase) var fetchTodoByIdUseCase @@ -150,6 +152,7 @@ struct TodoListFeature { let keyword = state.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) state.query.keyword = keyword.isEmpty ? nil : keyword state.nextCursor = nil + state.hasMore = false if keyword.isEmpty { return .merge( .cancel(id: CancelID.searchDebounce), @@ -157,6 +160,8 @@ struct TodoListFeature { ) } return .concatenate( + .cancel(id: CancelID.fetch), + .send(.loading(.end(target: Self.fetchLoadingTarget, mode: .delayed))), .cancel(id: CancelID.searchDebounce), debounceSearchEffect() ) @@ -246,8 +251,8 @@ private extension TodoListFeature { showsIndicator: Bool = true ) -> Effect { .concatenate( - .send(.loading(.end(target: .default, mode: .delayed))), - showsIndicator ? .send(.loading(.begin(target: .default, mode: .delayed))) : .none, + .send(.loading(.end(target: Self.fetchLoadingTarget, mode: .delayed))), + showsIndicator ? .send(.loading(.begin(target: Self.fetchLoadingTarget, mode: .delayed))) : .none, .run { [fetchTodosUseCase] send in do { let page = try await fetchTodosUseCase.execute(query, cursor: cursor) @@ -260,14 +265,14 @@ private extension TodoListFeature { ))) await send(.store(.setHasMore(page.items.count == query.pageSize && page.nextCursor != nil))) if showsIndicator { - await send(.loading(.end(target: .default, mode: .delayed))) + await send(.loading(.end(target: Self.fetchLoadingTarget, mode: .delayed))) } } catch is CancellationError { return } catch { await send(.store(.setAlert(true))) if showsIndicator { - await send(.loading(.end(target: .default, mode: .delayed))) + await send(.loading(.end(target: Self.fetchLoadingTarget, mode: .delayed))) } } } @@ -334,6 +339,7 @@ private extension TodoListFeature { state.searchQuery = "" state.query.keyword = nil state.nextCursor = nil + state.hasMore = false return .merge( .cancel(id: CancelID.searchDebounce), fetchEffect(query: state.query, cursor: nil) diff --git a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift index 0fcba440..38dfd9c3 100644 --- a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift @@ -20,6 +20,7 @@ final class TodoListStoreTestAdapter { var isSearching: Bool { store.state.isSearching } var searchQuery: String { store.state.searchQuery } var isLoading: Bool { store.state.isLoading } + var loading: LoadingFeature.State { store.state.loading } var hasMore: Bool { store.state.hasMore } var alert: AlertState? { store.state.alert } var fullScreenCover: TodoListFeature.FullScreenCoverState? { store.state.fullScreenCover } @@ -89,11 +90,22 @@ final class TodoListStoreTestAdapter { $0.searchQuery = query $0.query.keyword = keyword.isEmpty ? nil : keyword $0.nextCursor = nil + $0.hasMore = false } } func receiveSearchQueryDebounced() async { - await store.receive(.view(.searchQueryDebounced)) + await store.receive(\.view, .searchQueryDebounced) + await drainReceivedActions() + } + + func receiveDelayedLoading(target: LoadingFeature.Target = .default) async { + await store.receive(\.loading, .delayedLoadingDidBecomeVisible(target: target)) + await drainReceivedActions() + } + + func receiveLoadingEnded(target: LoadingFeature.Target) async { + await store.receive(\.loading, .end(target: target, mode: .delayed)) await drainReceivedActions() } @@ -279,6 +291,7 @@ final class TodoListFetchTodoByIdUseCaseSpy: FetchTodoByIdUseCase { final class TodoListUpsertTodoUseCaseSpy: UpsertTodoUseCase { var error: Error? + var completion: AsyncStream? private(set) var todos = [Todo]() private(set) var todoDrafts = [TodoDraft]() @@ -288,6 +301,12 @@ final class TodoListUpsertTodoUseCaseSpy: UpsertTodoUseCase { if let error { throw error } + + if let completion { + for await _ in completion { + break + } + } } func execute(_ todoDraft: TodoDraft) async throws { diff --git a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift index 92ef4e50..a50f6abd 100644 --- a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTests.swift @@ -149,6 +149,137 @@ struct TodoListFeatureTests { #expect(adapter.todos == [TodoListItem(from: todo)!]) } + @Test("검색 대기 중 다음 페이지를 요청해도 기존 목록에 검색 결과를 추가하지 않는다") + func 검색_대기_중_다음_페이지를_요청해도_기존_목록에_검색_결과를_추가하지_않는다() async { + let todos = (0..<20).map { makeTodoListTodo(id: "todo-\($0)", number: $0) } + let todo = makeTodoListTodo(id: "todo-search", title: "Swift") + let cursor = makeTodoListCursor(documentID: "cursor-1") + let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ + TodoPage(items: todos, nextCursor: cursor), + TodoPage(items: [todo], nextCursor: nil) + ]) + let clock = TestClock() + let adapter = TodoListStoreTestAdapter( + fetchUseCase: fetchSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.onAppear() + await waitUntil { + adapter.hasMore && adapter.todos.count == 20 + } + + await adapter.setSearchQuery("swift") + #expect(!adapter.hasMore) + + await adapter.loadNextPage() + #expect(fetchSpy.queries.count == 1) + #expect(adapter.todos == todos.compactMap(TodoListItem.init(from:))) + + await clock.advance(by: .milliseconds(400)) + await adapter.receiveSearchQueryDebounced() + await waitUntil { + adapter.todos == [TodoListItem(from: todo)!] + } + + #expect(fetchSpy.queries.map(\.keyword) == [nil, "swift"]) + #expect(fetchSpy.cursors.map { $0?.documentID } == [nil, nil]) + #expect(!adapter.hasMore) + } + + @Test("검색어 변경은 디바운스를 기다리지 않고 진행 중인 조회를 취소한다") + func 검색어_변경은_디바운스를_기다리지_않고_진행_중인_조회를_취소한다() async throws { + let firstTodo = makeTodoListTodo(id: "todo-first", number: 1) + let todo = makeTodoListTodo(id: "todo-search", title: "Swift") + let fetchSpy = DelayedFirstFetchTodosUseCaseSpy(pages: [ + TodoPage(items: [firstTodo], nextCursor: nil), + TodoPage(items: [todo], nextCursor: nil) + ]) + let clock = TestClock() + let adapter = TodoListStoreTestAdapter( + fetchUseCase: fetchSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.onAppear() + await fetchSpy.waitForCallCount(1) + let target = try #require(adapter.loading.scheduledDelayedTargets.first) + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading(target: target) + #expect(adapter.isLoading) + + await adapter.setSearchQuery("swift") + + let cancelledCalls = await fetchSpy.waitForCancelledCalls([0]) + #expect(cancelledCalls == [0]) + await adapter.receiveLoadingEnded(target: target) + #expect(!adapter.isLoading) + #expect(adapter.todos.isEmpty) + + await clock.advance(by: .milliseconds(400)) + await adapter.receiveSearchQueryDebounced() + await waitUntil { + adapter.todos == [TodoListItem(from: todo)!] + } + + let queries = await fetchSpy.calledQueries() + #expect(queries.map(\.keyword) == [nil, "swift"]) + #expect(!adapter.showAlert) + } + + @Test("검색 조회가 끝나도 Todo 변경 작업의 로딩은 유지된다", arguments: [false, true]) + func 검색_조회가_끝나도_Todo_변경_작업의_로딩은_유지된다(togglesPinned: Bool) async { + let todo = makeTodoListTodo(id: "todo-loading", title: "Swift") + let item = TodoListItem(from: todo)! + let fetchSpy = TodoListFetchTodosUseCaseSpy(pages: [ + TodoPage(items: [todo], nextCursor: nil) + ]) + let fetchByIdSpy = TodoListFetchTodoByIdUseCaseSpy(todos: [todo]) + let upsertSpy = TodoListUpsertTodoUseCaseSpy() + let completion = AsyncStream.makeStream() + upsertSpy.completion = completion.stream + defer { completion.continuation.finish() } + let clock = TestClock() + let adapter = TodoListStoreTestAdapter( + fetchUseCase: fetchSpy, + fetchTodoByIdUseCase: fetchByIdSpy, + upsertUseCase: upsertSpy, + configureDependencies: { + $0.continuousClock = clock + } + ) + + await adapter.appendTodos([item]) + if togglesPinned { + await adapter.tapTogglePinned(item) + } else { + await adapter.tapToggleCompleted(item) + } + await waitUntil { + upsertSpy.todos.count == 1 + } + + await clock.advance(by: .milliseconds(300)) + await adapter.receiveDelayedLoading() + #expect(adapter.isLoading) + + await adapter.setSearchQuery("swift") + await clock.advance(by: .milliseconds(400)) + await adapter.receiveSearchQueryDebounced() + await waitUntil { + fetchSpy.queries.count == 1 + } + #expect(adapter.isLoading) + + completion.continuation.finish() + await adapter.receiveLoadingEnded(target: .default) + #expect(!adapter.isLoading) + } + @Test("검색 종료는 검색어를 지우고 현재 카테고리 목록을 다시 조회한다") func 검색_종료는_검색어를_지우고_현재_카테고리_목록을_다시_조회한다() async { let fetchSpy = TodoListFetchTodosUseCaseSpy() From bdacf09174978b1b2fba4e8fb37bc0a3a09eb2f2 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 15:22:08 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=92=A4=EB=A1=9C=EA=B0=80=EA=B8=B0=20=EC=8A=A4=EC=99=80?= =?UTF-8?q?=EC=9D=B4=ED=94=84=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/HomeTab/Sources/Home/HomeView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift index f38fbbc9..8d255245 100644 --- a/Application/Presentation/HomeTab/Sources/Home/HomeView.swift +++ b/Application/Presentation/HomeTab/Sources/Home/HomeView.swift @@ -68,7 +68,16 @@ public struct HomeView: View { .safeAreaInset(edge: .top, spacing: 0) { topBar } .background(Color.appBackground) .toolbarVisibility(.hidden, for: .navigationBar) - .navigationDestination(for: HomeRoute.self, destination: destinationView) + .navigationDestination( + for: HomeRoute.self, + interactivePop: { + switch $0 { + case .category: true + case .todo: false + } + }, + destination: destinationView + ) } .onAppear { store.send(.view(.startObserving)) } .onChange(of: isSelected, initial: true) { _, isSelected in From 7770387cc11256bfa12393fe6c38dcf50a4559ba Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 16:07:32 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9C=A0=EC=A7=80=EC=99=80=20=EC=8A=A4?= =?UTF-8?q?=EC=99=80=EC=9D=B4=ED=94=84=20=EB=8F=99=EC=9E=91=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Todo/List/TodoListView.swift | 2 +- .../Resources/Localizable.xcstrings | 2190 +++++++++++++---- .../Sources/Extension/View+ItemActions.swift | 12 +- 3 files changed, 1711 insertions(+), 493 deletions(-) diff --git a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift index c01851a8..79eef659 100644 --- a/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift +++ b/Application/Presentation/HomeTab/Sources/Todo/List/TodoListView.swift @@ -18,7 +18,7 @@ struct TodoListView: View { @Environment(\.isiOSAppOnMac) private var isiOSAppOnMac @FocusState private var isSearchFocused: Bool @Namespace private var searchTransition - @Bindable private var store: StoreOf + @State private var store: StoreOf private let windowEvent: TodoEditorWindowEvent? private let onSelectTodo: (String) -> Void diff --git a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings index f9eef478..73e2028a 100644 --- a/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings +++ b/Application/Presentation/PresentationShared/Resources/Localizable.xcstrings @@ -5,10 +5,6 @@ "comment" : "A badge displaying the number of a todo list item. The argument is the item's number.", "isCommentAutoGenerated" : true }, - "%@" : { - "comment" : "A badge that shows the number of items that match the current filter.", - "isCommentAutoGenerated" : true - }, "account_alert_already_linked_message" : { "extractionState" : "manual", "localizations" : { @@ -349,53 +345,53 @@ } } }, - "common_done" : { + "common_copy" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Done" + "value" : "Copy" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "완료" + "value" : "복사" } } } }, - "common_copy" : { + "common_delete" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Copy" + "value" : "Delete" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "복사" + "value" : "삭제" } } } }, - "common_delete" : { + "common_done" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Delete" + "value" : "Done" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "삭제" + "value" : "완료" } } } @@ -451,902 +447,2061 @@ } } }, - "todo_list_delete_toast_message" : { + "development_goal_archive" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Todo deleted." + "value" : "Archive" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Todo를 삭제했어요." + "value" : "보관하기" } } } }, - "profile_recent_empty" : { + "development_goal_archive_alert_message" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "No recently updated todos." + "value" : "Archived goals are read-only until you move them back to in progress." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "최근 수정한 Todo가 없어요." + "value" : "보관하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } } }, - "profile_recent_title" : { + "development_goal_archive_alert_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Recently Updated" + "state" : "translated", + "value" : "Archive this goal?" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "최근 활동" + "value" : "목표를 보관할까요?" } } } }, - "development_goal_title" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Goal" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표" } } - } - }, - "home_development_summary_eyebrow" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Ongoing Development" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이어가는 개발" } } - } - }, - "home_development_summary_empty_message" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a development goal to start recording your progress." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표를 만들고 개발 과정을 기록해보세요." } } - } - }, - "home_development_summary_recent_record" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Latest Record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 기록" } } - } - }, - "home_development_summary_title_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld goals are in progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } - } - }, - "home_development_summary_title_singular_format" : { - "extractionState" : "manual", - "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld goal is in progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 %lld개 있어요" } } - } - }, - "home_development_goal_count_format" : { + "development_goal_complete" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld in progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중 %lld개" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료하기" + } + } } }, - "home_development_goal_create" : { + "development_goal_complete_alert_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "New Goal" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed goals are read-only until you move them back to in progress." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." + } + } } }, - "home_development_goal_empty_message" : { + "development_goal_complete_alert_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a development goal to start recording your progress." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 목표를 만들고 개발 과정을 기록해보세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete this goal?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표를 완료할까요?" + } + } } }, - "home_development_goal_empty_title" : { + "development_goal_completion_draft_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No goals in progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 목표가 없어요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm every remaining draft, then try completing the goal again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남아 있는 초안을 모두 확정한 뒤 목표 완료를 다시 시도해주세요." + } + } } }, - "home_development_goal_recent_record" : { + "development_goal_completion_draft_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Latest Record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "최근 기록" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm the remaining drafts" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "남은 초안을 확정해주세요" + } + } } }, - "home_development_goal_section_title" : { + "development_goal_completion_record_required_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Goals in Progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중인 개발" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add and confirm at least one development record before completing the goal." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 기록을 하나 이상 작성하고 확정한 뒤 목표를 완료할 수 있어요." + } + } } }, - "home_development_goal_todo_empty" : { + "development_goal_completion_record_required_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No linked Todos" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo 없음" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A confirmed record is required" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확정된 기록이 필요해요" + } + } } }, - "home_development_goal_todo_progress_format" : { + "development_goal_completion_version_required_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld of %lld Todos complete" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo %lld/%lld 완료" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm the latest development record before completing the goal." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마지막 개발 기록을 먼저 확정한 뒤 목표를 완료할 수 있어요." + } + } } }, - "home_development_goal_todo_progress_percent_format" : { + "development_goal_completion_version_required_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld%%" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "%lld%%" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm the latest record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마지막 기록을 확정해주세요" + } + } } }, "development_goal_create_description_label" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Description" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "설명" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Description" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설명" + } + } } }, "development_goal_create_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The development goal could not be created. Please try again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 목표를 만들지 못했어요. 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The development goal could not be created. Please try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 목표를 만들지 못했어요. 다시 시도해주세요." + } + } } }, "development_goal_create_markdown_hint" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Markdown is supported." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown을 지원해요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown is supported." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown을 지원해요." + } + } } }, "development_goal_create_preview_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Write a description to preview it." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "설명을 작성하면 미리볼 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write a description to preview it." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "설명을 작성하면 미리볼 수 있어요." + } + } } }, "development_goal_create_preview_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No description yet" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성한 설명이 없어요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No description yet" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작성한 설명이 없어요" + } + } } }, "development_goal_create_status" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Status" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "상태" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Status" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태" + } + } } }, "development_goal_create_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "New Development Goal" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 개발 목표" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Development Goal" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 개발 목표" + } + } } }, "development_goal_create_title_label" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Title" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "제목" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + } } }, "development_goal_create_title_placeholder" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Enter a goal title" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 제목을 입력하세요" } } - } - }, - "development_goal_create_todo_hint" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a goal title" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표 제목을 입력하세요" + } + } + } + }, + "development_goal_create_todo_hint" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "You can link Todos later." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "나중에 연결할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can link Todos later." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "나중에 연결할 수 있어요." + } + } } }, "development_goal_create_todo_label" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Linked Todo" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linked Todo" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결된 Todo" + } + } } }, "development_goal_create_todo_link_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The goal was created, but some Todo links could not be saved. You can link them again from the goal details." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표는 만들었지만 일부 Todo를 연결하지 못했어요. 목표 상세에서 다시 연결할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The goal was created, but some Todo links could not be saved. You can link them again from the goal details." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표는 만들었지만 일부 Todo를 연결하지 못했어요. 목표 상세에서 다시 연결할 수 있어요." + } + } } }, "development_goal_create_todo_optional" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Optional" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "선택" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Optional" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택" + } + } } }, "development_goal_create_todo_select" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Select Todos" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo 선택" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select Todos" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 선택" + } + } } }, "development_goal_create_todo_selected_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld selected" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개 선택됨" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld selected" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld개 선택됨" + } + } } }, "development_goal_description_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No goal description has been written." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성된 목표 설명이 없어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No goal description has been written." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작성된 목표 설명이 없어요." + } + } } }, "development_goal_description_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Goal Description" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 설명" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goal Description" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표 설명" + } + } } }, - "development_goal_save" : { + "development_goal_linked_todos" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Save Goal" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 저장" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linked Todos" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결된 Todo" + } + } } }, - "development_goal_linked_todos" : { + "development_goal_resume" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Linked Todos" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move to In Progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중으로 되돌리기" + } + } } }, - "development_goal_todo_available_empty_message" : { + "development_goal_resume_alert_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Create a Todo first, then return here to link it." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo를 만든 뒤 이곳에서 목표와 연결할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can add and edit development records again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중으로 되돌리면 개발 기록을 다시 작성하고 수정할 수 있어요." + } + } } }, - "development_goal_todo_available_empty_title" : { + "development_goal_resume_alert_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "There are no Todos to link" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결할 Todo가 없어요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume this goal?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표를 다시 진행할까요?" + } + } } }, - "development_goal_todo_clear_selection" : { + "development_goal_save" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Clear Selection" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "선택 해제" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save Goal" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표 저장" + } + } } }, - "development_goal_todo_completed" : { + "development_goal_status_archived" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료됨" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Archived" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보관" + } + } } }, - "development_goal_todo_count_format" : { + "development_goal_status_completed" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + } } }, - "development_goal_todo_done" : { + "development_goal_status_in_progress" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Done" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In Progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중" + } + } } }, - "development_goal_todo_empty_message" : { + "development_goal_status_menu" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Use Manage to link supporting work when needed." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "필요할 때 관리에서 관련 Todo를 연결할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change Goal Status" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표 상태 변경" + } + } } }, - "development_goal_todo_empty_title" : { + "development_goal_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No linked Todos" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "연결된 Todo가 없어요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Development Goal" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 목표" + } + } } }, - "development_goal_todo_incomplete" : { + "development_goal_todo_available_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Incomplete" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a Todo first, then return here to link it." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 만든 뒤 이곳에서 목표와 연결할 수 있어요." + } + } } }, - "development_goal_todo_link_title" : { + "development_goal_todo_available_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Link Todos" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo 연결" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There are no Todos to link" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결할 Todo가 없어요" + } + } } }, - "development_goal_todo_load_error_message" : { + "development_goal_todo_clear_selection" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Todos could not be loaded. Please try again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo를 불러오지 못했어요. 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear Selection" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택 해제" + } + } } }, - "development_goal_todo_manage" : { + "development_goal_todo_completed" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Manage" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "관리" } } - } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completed" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료됨" + } + } + } }, - "development_goal_todo_optional_footer" : { + "development_goal_todo_count_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "You can write goals and records without linking Todos." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo를 연결하지 않아도 목표와 기록을 작성할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld개" + } + } } }, - "development_goal_todo_pinned" : { + "development_goal_todo_done" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Pinned" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "중요 Todo" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료" + } + } } }, - "development_goal_todo_retry" : { + "development_goal_todo_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use Manage to link supporting work when needed." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "필요할 때 관리에서 관련 Todo를 연결할 수 있어요." + } + } } }, - "development_goal_todo_search" : { + "development_goal_todo_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Search Todos" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Todo 검색" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No linked Todos" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결된 Todo가 없어요" + } + } } }, - "development_goal_todo_selected_count_format" : { + "development_goal_todo_incomplete" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld selected" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "%1$lld개 선택됨" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Incomplete" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중" + } + } } }, - "development_goal_todo_update_error_message" : { + "development_goal_todo_link_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Some Todo links could not be updated. The current links were loaded again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "일부 Todo 연결을 변경하지 못해 현재 연결 상태를 다시 불러왔어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link Todos" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 연결" + } + } } }, - "development_goal_archive" : { + "development_goal_todo_load_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Archive" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관하기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos could not be loaded. Please try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 불러오지 못했어요. 다시 시도해주세요." + } + } } }, - "development_goal_archive_alert_message" : { + "development_goal_todo_manage" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Archived goals are read-only until you move them back to in progress." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "관리" + } + } } }, - "development_goal_archive_alert_title" : { + "development_goal_todo_optional_footer" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Archive this goal?" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 보관할까요?" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can write goals and records without linking Todos." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 연결하지 않아도 목표와 기록을 작성할 수 있어요." + } + } } }, - "development_goal_complete" : { + "development_goal_todo_pinned" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료하기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pinned" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "중요 Todo" + } + } } }, - "development_goal_complete_alert_message" : { + "development_goal_todo_retry" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed goals are read-only until you move them back to in progress." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료하면 다시 진행 중으로 되돌릴 때까지 기록을 수정할 수 없어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 시도" + } + } } }, - "development_goal_complete_alert_title" : { + "development_goal_todo_search" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Complete this goal?" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 완료할까요?" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search Todos" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo 검색" + } + } } }, - "development_goal_completion_draft_message" : { + "development_goal_todo_selected_count_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm every remaining draft, then try completing the goal again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "남아 있는 초안을 모두 확정한 뒤 목표 완료를 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld selected" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld개 선택됨" + } + } } }, - "development_goal_completion_draft_title" : { + "development_goal_todo_update_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the remaining drafts" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "남은 초안을 확정해주세요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Some Todo links could not be updated. The current links were loaded again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일부 Todo 연결을 변경하지 못해 현재 연결 상태를 다시 불러왔어요." + } + } } }, - "development_goal_completion_record_required_message" : { + "development_goal_transition_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Add and confirm at least one development record before completing the goal." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 하나 이상 작성하고 확정한 뒤 목표를 완료할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The goal status could not be changed. Please try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "목표 상태를 변경하지 못했어요. 다시 시도해주세요." + } + } } }, - "development_goal_completion_record_required_title" : { + "development_record_add" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "A confirmed record is required" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정된 기록이 필요해요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 추가" + } + } } }, - "development_goal_completion_version_required_message" : { + "development_record_confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확정하기" + } + } + } + }, + "development_record_confirm_correction" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the latest development record before completing the goal." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 개발 기록을 먼저 확정한 뒤 목표를 완료할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm as New Version" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 버전으로 확정" + } + } } }, - "development_goal_completion_version_required_title" : { + "development_record_confirm_hint_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the latest record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "마지막 기록을 확정해주세요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "It will be saved as %@ when confirmed." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확정하면 %@으로 기록돼요." + } + } } }, - "development_goal_resume" : { + "development_record_confirmed" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Move to In Progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중으로 되돌리기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmed" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확정" + } + } } }, - "development_goal_resume_alert_message" : { + "development_record_confirmed_version_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "You can add and edit development records again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중으로 되돌리면 개발 기록을 다시 작성하고 수정할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmed · %@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확정 · %@" + } + } } }, - "development_goal_resume_alert_title" : { + "development_record_content_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Resume this goal?" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표를 다시 진행할까요?" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Content" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작성한 내용 없음" + } + } } }, - "development_goal_status_archived" : { + "development_record_continue" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Archived" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "보관" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Writing" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 이어서 작성" + } + } } }, - "development_goal_status_completed" : { + "development_record_correct" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Completed" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "완료" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correct Record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 정정" + } + } } }, - "development_goal_status_in_progress" : { + "development_record_current_version" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "In Progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "진행 중" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current Version" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 버전" + } + } } }, - "development_goal_status_menu" : { + "development_record_current_version_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Change Goal Status" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 상태 변경" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirmed versions cannot be edited or deleted." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확정된 버전은 변경하거나 삭제하지 않아요." + } + } } }, - "development_goal_transition_error_message" : { + "development_record_detail_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The goal status could not be changed. Please try again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "목표 상태를 변경하지 못했어요. 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The development record could not be loaded. Please try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 기록을 불러오지 못했어요. 다시 시도해주세요." + } + } } }, - "development_record_add" : { + "development_record_detail_retry" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Add Record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 추가" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 시도" + } + } } }, - "development_record_confirm" : { + "development_record_draft" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정하기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Draft" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초안" + } + } } }, - "development_record_confirm_correction" : { + "development_record_draft_status" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm as New Version" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 버전으로 확정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Draft in progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "초안 작성 중" + } + } } }, - "development_record_confirm_hint_format" : { + "development_record_editor_correction_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "It will be saved as %@ when confirmed." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정하면 %@으로 기록돼요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correct Record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 기록 정정" + } + } } }, - "development_record_confirmed" : { + "development_record_editor_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirmed" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The development record could not be saved. Your input is still available." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 기록을 저장하지 못했어요. 작성한 내용은 그대로 유지돼요." + } + } } }, - "development_record_confirmed_version_format" : { + "development_record_editor_mode" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editor Mode" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작성 방식" + } + } + } + }, + "development_record_editor_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Development Record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 기록 작성" + } + } + } + }, + "development_record_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirmed · %@" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정 · %@" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add the first record for this goal." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 목표의 첫 번째 기록을 추가해보세요." + } + } } }, - "development_record_content_empty_title" : { + "development_record_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No Content" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성한 내용 없음" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Development Records" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "아직 개발 기록이 없어요" + } + } } }, - "development_record_continue" : { + "development_record_history_footer" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Continue Writing" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 이어서 작성" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select an earlier version to review it and restore its content as a new version." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 버전을 선택하면 내용을 확인하고 새 버전으로 되돌릴 수 있어요." + } + } } }, - "development_record_correct" : { + "development_record_history_read_only_footer" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Correct Record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 정정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move the goal back to in progress to restore an earlier version." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 버전으로 되돌리려면 목표를 진행 중으로 되돌려주세요." + } + } } }, - "development_record_current_version" : { + "development_record_history_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Current Version" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "현재 버전" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version History" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버전 이력" + } + } } }, - "development_record_current_version_message" : { + "development_record_markdown_hint" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirmed versions cannot be edited or deleted." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "확정된 버전은 변경하거나 삭제하지 않아요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown supported." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown 문법을 사용할 수 있어요." + } + } } }, - "development_record_detail_error_message" : { + "development_record_preview" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The development record could not be loaded. Please try again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 불러오지 못했어요. 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preview" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리보기" + } + } } }, - "development_record_detail_retry" : { + "development_record_preview_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write Markdown content to preview it." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markdown 내용을 작성하면 미리 볼 수 있어요." + } + } } }, - "development_record_draft" : { + "development_record_preview_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Draft" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "초안" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing to Preview" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "미리 볼 내용이 없어요" + } + } } }, - "development_record_draft_status" : { + "development_record_previous_version_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Draft in progress" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "초안 작성 중" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Previous Version" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 버전" + } + } } }, - "development_record_editor_error_message" : { + "development_record_read_only_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The development record could not be saved. Your input is still available." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록을 저장하지 못했어요. 작성한 내용은 그대로 유지돼요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move the goal back to in progress to change this record." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기록을 변경하려면 목표를 진행 중으로 되돌려주세요." + } + } } }, - "development_record_editor_correction_title" : { + "development_record_restore_alert_confirm" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Correct Record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 정정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm as New Version" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 버전으로 확정" + } + } } }, - "development_record_editor_mode" : { + "development_record_restore_alert_message_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Editor Mode" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성 방식" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The content of %@ will be confirmed as a new version. Existing versions will be preserved." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@의 내용으로 새 버전을 확정합니다. 기존 버전은 그대로 보존돼요." + } + } } }, - "development_record_editor_title" : { + "development_record_restore_alert_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Development Record" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 작성" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore this previous version?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이전 버전으로 되돌릴까요?" + } + } } }, - "development_record_empty_message" : { + "development_record_restore_button" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Add the first record for this goal." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 목표의 첫 번째 기록을 추가해보세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore This Version" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 버전으로 되돌리기" + } + } } }, - "development_record_empty_title" : { + "development_record_restore_current_version_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "No Development Records" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "아직 개발 기록이 없어요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The current version is %@. Restoring this content creates a new version." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재 버전은 %@이에요. 이 내용으로 되돌리면 새 버전이 생성돼요." + } + } } }, - "development_record_history_footer" : { + "development_record_restore_draft_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Select an earlier version to review it and restore its content as a new version." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전을 선택하면 내용을 확인하고 새 버전으로 되돌릴 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Confirm the saved correction draft before restoring an earlier version." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장된 정정 초안을 먼저 새 버전으로 확정해야 되돌릴 수 있어요." + } + } } }, - "development_record_history_title" : { + "development_record_restore_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Version History" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전 이력" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The selected version could not be restored. Please try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "선택한 버전으로 되돌리지 못했어요. 다시 시도해주세요." + } + } } }, - "development_record_history_read_only_footer" : { + "development_record_result_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Move the goal back to in progress to restore an earlier version." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전으로 되돌리려면 목표를 진행 중으로 되돌려주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Result" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구현 결과" + } + } } }, - "development_record_markdown_hint" : { + "development_record_save" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Markdown supported." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown 문법을 사용할 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "저장" + } + } } }, - "development_record_preview" : { + "development_record_timeline_error_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Preview" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "미리보기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The development record timeline could not be loaded. Please try again." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "개발 기록 타임라인을 불러오지 못했어요. 다시 시도해주세요." + } + } } }, - "development_record_preview_empty_message" : { + "development_record_timeline_retry" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Write Markdown content to preview it." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "Markdown 내용을 작성하면 미리 볼 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 시도" + } + } } }, - "development_record_preview_empty_title" : { + "development_record_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing to Preview" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "미리 볼 내용이 없어요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목" + } + } } }, - "development_record_previous_version_title" : { + "development_record_title_placeholder" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Previous Version" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a record title" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록 제목을 입력하세요" + } + } } }, - "development_record_read_only_message" : { + "development_record_version" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Move the goal back to in progress to change this record." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 기록을 변경하려면 목표를 진행 중으로 되돌려주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "버전" + } + } } }, - "development_record_result_title" : { + "development_record_version_correction" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Result" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "구현 결과" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correction" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "정정" + } + } } }, - "development_record_save" : { + "development_record_version_current" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Save" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Current" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "현재" + } + } } }, - "development_record_restore_alert_confirm" : { + "development_record_version_initial" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm as New Version" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "새 버전으로 확정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Initial" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최초 확정" + } + } } }, - "development_record_restore_alert_message_format" : { + "development_record_version_rollback" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The content of %@ will be confirmed as a new version. Existing versions will be preserved." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "%@의 내용으로 새 버전을 확정합니다. 기존 버전은 그대로 보존돼요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restored" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "되돌리기" + } + } } }, - "development_record_restore_alert_title" : { + "development_record_write" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Restore this previous version?" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이전 버전으로 되돌릴까요?" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Write" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작성" + } + } } }, - "development_record_restore_button" : { + "home_development_goal_count_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Restore This Version" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "이 버전으로 되돌리기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld in progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중 %lld개" + } + } } }, - "development_record_restore_current_version_format" : { + "home_development_goal_create" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The current version is %@. Restoring this content creates a new version." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "현재 버전은 %@이에요. 이 내용으로 되돌리면 새 버전이 생성돼요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "New Goal" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 목표" + } + } } }, - "development_record_restore_draft_message" : { + "home_development_goal_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Confirm the saved correction draft before restoring an earlier version." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "저장된 정정 초안을 먼저 새 버전으로 확정해야 되돌릴 수 있어요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a development goal to start recording your progress." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 목표를 만들고 개발 과정을 기록해보세요." + } + } } }, - "development_record_restore_error_message" : { + "home_development_goal_empty_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The selected version could not be restored. Please try again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "선택한 버전으로 되돌리지 못했어요. 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No goals in progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중인 목표가 없어요" + } + } } }, - "development_record_timeline_error_message" : { + "home_development_goal_recent_record" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "The development record timeline could not be loaded. Please try again." } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "개발 기록 타임라인을 불러오지 못했어요. 다시 시도해주세요." } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest Record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 기록" + } + } } }, - "development_record_timeline_retry" : { + "home_development_goal_section_title" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goals in Progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중인 개발" + } + } } }, - "development_record_title" : { + "home_development_goal_todo_empty" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Title" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "제목" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No linked Todos" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결된 Todo 없음" + } + } } }, - "development_record_title_placeholder" : { + "home_development_goal_todo_progress_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Enter a record title" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "기록 제목을 입력하세요" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld of %lld Todos complete" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo %lld/%lld 완료" + } + } } }, - "development_record_version" : { + "home_development_goal_todo_progress_percent_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Version" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "버전" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%%" + } + } } }, - "development_record_version_correction" : { + "home_development_summary_empty_message" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Correction" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "정정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Create a development goal to start recording your progress." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "새 목표를 만들고 개발 과정을 기록해보세요." + } + } } }, - "development_record_version_current" : { + "home_development_summary_eyebrow" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Current" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "현재" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ongoing Development" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이어가는 개발" + } + } } }, - "development_record_version_initial" : { + "home_development_summary_recent_record" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Initial" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "최초 확정" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Latest Record" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 기록" + } + } } }, - "development_record_version_rollback" : { + "home_development_summary_title_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Restored" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "되돌리기" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld goals are in progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중인 목표가 %lld개 있어요" + } + } } }, - "development_record_write" : { + "home_development_summary_title_singular_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Write" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "작성" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld goal is in progress" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "진행 중인 목표가 %lld개 있어요" + } + } } }, "home_select_detail" : { @@ -1774,28 +2929,38 @@ } } }, - "profile_quarter_format" : { + "profile_goal_retry" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Q%1$lld" + "value" : "Try Again" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld분기" + "value" : "다시 시도" } } } }, - "profile_goal_retry" : { + "profile_quarter_format" : { "extractionState" : "manual", "localizations" : { - "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, - "ko" : { "stringUnit" : { "state" : "translated", "value" : "다시 시도" } } + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Q%1$lld" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld분기" + } + } } }, "profile_quarterly_activity" : { @@ -1815,6 +2980,40 @@ } } }, + "profile_recent_empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No recently updated todos." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 수정한 Todo가 없어요." + } + } + } + }, + "profile_recent_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Recently Updated" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "최근 활동" + } + } + } + }, "profile_select_detail" : { "extractionState" : "manual", "localizations" : { @@ -1934,87 +3133,87 @@ } } }, - "push_notification_todo_due_title" : { + "push_mark_read" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Todo Reminder" + "value" : "Read" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Todo 알림" + "value" : "읽음" } } } }, - "push_notification_todo_due_tomorrow_format" : { + "push_mark_unread" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ is due tomorrow." + "value" : "Unread" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "'%1$@'의 마감일이 내일입니다." + "value" : "안 읽음" } } } }, - "push_notification_todo_due_tomorrow_without_title" : { + "push_notification_todo_due_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "An untitled Todo is due tomorrow." + "value" : "Todo Reminder" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "제목 없는 Todo의 마감일이 내일입니다." + "value" : "Todo 알림" } } } }, - "push_mark_read" : { + "push_notification_todo_due_tomorrow_format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Read" + "value" : "%1$@ is due tomorrow." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "읽음" + "value" : "'%1$@'의 마감일이 내일입니다." } } } }, - "push_mark_unread" : { + "push_notification_todo_due_tomorrow_without_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unread" + "value" : "An untitled Todo is due tomorrow." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "안 읽음" + "value" : "제목 없는 Todo의 마감일이 내일입니다." } } } @@ -2631,87 +3830,87 @@ } } }, - "search_result_count_format" : { + "search_prompt" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld results" + "value" : "Search (e.g. #123)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$lld개" + "value" : "검색 (예: #123)" } } } }, - "search_results_title" : { + "search_recent" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search Results" + "value" : "Recent Searches" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "검색 결과" + "value" : "최근 검색" } } } }, - "search_scope_instruction" : { + "search_result_count_format" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Only Todos are searched.\nDocuments and other content aren't included." + "value" : "%1$lld results" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Todo만 검색됩니다.\n문서나 다른 콘텐츠는 검색되지 않아요." + "value" : "%1$lld개" } } } }, - "search_prompt" : { + "search_results_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search (e.g. #123)" + "value" : "Search Results" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "검색 (예: #123)" + "value" : "검색 결과" } } } }, - "search_recent" : { + "search_scope_instruction" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Recent Searches" + "value" : "Only Todos are searched.\nDocuments and other content aren't included." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "최근 검색" + "value" : "Todo만 검색됩니다.\n문서나 다른 콘텐츠는 검색되지 않아요." } } } @@ -2886,36 +4085,36 @@ } } }, - "settings_delete_account" : { + "settings_app_section" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Delete Account" + "value" : "App Settings" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "회원 탈퇴" + "value" : "앱 설정" } } } }, - "settings_delete_account_subtitle" : { + "settings_delete_account" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "All data will be deleted." + "value" : "Delete Account" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "모든 데이터가 삭제됩니다." + "value" : "회원 탈퇴" } } } @@ -2937,19 +4136,19 @@ } } }, - "settings_app_section" : { + "settings_delete_account_subtitle" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "App Settings" + "value" : "All data will be deleted." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "앱 설정" + "value" : "모든 데이터가 삭제됩니다." } } } @@ -3362,155 +4561,155 @@ } } }, - "today_pinned_first" : { + "today_filter_important" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Pinned First" + "value" : "Focus" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "중요 항목 우선" + "value" : "중요" } } } }, - "today_section_later" : { + "today_filter_remaining" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Later" + "value" : "Remaining" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "나중 일정" + "value" : "남은 일" } } } }, - "today_section_overdue" : { + "today_pinned_first" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Overdue Tasks" + "value" : "Pinned First" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기한이 지난 Todo" + "value" : "중요 항목 우선" } } } }, - "today_section_today" : { + "today_section_later" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Due Today" + "value" : "Later" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "오늘까지" + "value" : "나중 일정" } } } }, - "today_section_upcoming" : { + "today_section_overdue" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Upcoming" + "value" : "Overdue Tasks" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "다가오는 일정" + "value" : "기한이 지난 Todo" } } } }, - "today_section_unscheduled" : { + "today_section_today" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unscheduled" + "value" : "Due Today" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기한 없음" + "value" : "오늘까지" } } } }, - "today_select_detail" : { + "today_section_unscheduled" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Select a todo." + "value" : "Unscheduled" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Todo를 선택해주세요." + "value" : "기한 없음" } } } }, - "today_filter_remaining" : { + "today_section_upcoming" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Remaining" + "value" : "Upcoming" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "남은 일" + "value" : "다가오는 일정" } } } }, - "today_filter_important" : { + "today_select_detail" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Focus" + "value" : "Select a todo." } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "중요" + "value" : "Todo를 선택해주세요." } } } @@ -3560,23 +4759,6 @@ } } }, - "todo_category_manage" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Manage Categories" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "카테고리 관리" - } - } - } - }, "todo_category_doc" : { "comment" : "Todo category: Documentation", "extractionState" : "manual", @@ -3667,6 +4849,23 @@ } } }, + "todo_category_manage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manage Categories" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "카테고리 관리" + } + } + } + }, "todo_category_research" : { "comment" : "Todo category: Research", "extractionState" : "manual", @@ -4061,6 +5260,23 @@ } } }, + "todo_list_delete_toast_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo deleted." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 삭제했어요." + } + } + } + }, "todo_list_empty" : { "extractionState" : "manual", "localizations" : { @@ -4499,4 +5715,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift b/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift index 62a3da2b..c1de1873 100644 --- a/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift +++ b/Application/Presentation/PresentationShared/Sources/Extension/View+ItemActions.swift @@ -78,6 +78,7 @@ private struct ItemActionsModifier: ViewModifier { } } .padding(.leading, 12) + .padding(.trailing, ItemActionsFeature.trailingPadding) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width in store.send(.view(.setTrayWidth(width))) } @@ -123,8 +124,7 @@ private struct ItemActionsModifier: ViewModifier { } private struct ItemActionButtonStyle: ButtonStyle { - @ScaledMetric(relativeTo: .body) private var width = 88 - @ScaledMetric(relativeTo: .body) private var height = 44 + @ScaledMetric(relativeTo: .body) private var size = 44 let color: Color @@ -132,8 +132,8 @@ private struct ItemActionButtonStyle: ButtonStyle { configuration.label .font(.callout) .foregroundStyle(Color.white) - .frame(width: width, height: height) - .background(color, in: Capsule()) + .frame(width: size, height: size) + .background(color, in: .circle) } } @@ -147,6 +147,8 @@ private struct ItemActionWidthsPreferenceKey: PreferenceKey { @Reducer private struct ItemActionsFeature { + static let trailingPadding = CGFloat(16) + @ObservableState struct State: Equatable { var trayWidth = CGFloat.zero @@ -195,7 +197,7 @@ private struct ItemActionsFeature { let wasRevealed = state.isRevealed let base = wasRevealed ? -state.trayWidth : 0 let releasedOffset = max(-state.trayWidth, min(0, base + translation)) - let revealedWidth = -releasedOffset + let revealedWidth = max(0, -releasedOffset - Self.trailingPadding) if wasRevealed { var fullyHiddenCount = 0 var trailingDistance = CGFloat.zero From 5be824b545e35685a463eaf15fbcef1f67d638e5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Mon, 28 Sep 2026 17:22:07 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20Todo=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=9C=EA=B3=84=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift index 38dfd9c3..83c6191a 100644 --- a/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift +++ b/Application/Presentation/HomeTab/Tests/Todo/TodoListFeatureTestDoubles.swift @@ -49,6 +49,7 @@ final class TodoListStoreTestAdapter { $0.todoListDeleteTodoUseCase = deleteUseCase $0.todoListUndoDeleteTodoUseCase = undoDeleteUseCase $0.trackAnalyticsEventUseCase = trackAnalyticsEventUseCase + $0.continuousClock = ContinuousClock() configureDependencies?(&$0) } store.exhaustivity = .off(showSkippedAssertions: false)