From 526811c645fce994c9e49cc323807c4c8609b866 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 12:35:19 +0900 Subject: [PATCH 01/16] =?UTF-8?q?refactor:=20ViewBuilder=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=B4=20=EC=BD=94=EB=93=9C=20=EA=B0=80?= =?UTF-8?q?=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 69 +++++++++---------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 7b5b433a..e550bcee 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -82,50 +82,43 @@ struct PushNotificationListView: View { } } + @ViewBuilder private var notificationList: some View { - let visibleNotifications = viewModel.state.notifications.filter { !$0.isHidden } - return List { - Group { - if visibleNotifications.isEmpty { - HStack { - Spacer() - Text(String(localized: "push_notifications_empty")) - .foregroundStyle(Color.gray) - Spacer() - } - .listRowSeparator(.hidden) - } else { - ForEach( - Array(zip(visibleNotifications.indices, visibleNotifications)), - id: \.1.id - ) { index, notification in - Button { - viewModel.send(.tapNotification(notification)) - } label: { - notificationRow(notification) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - .onAppear { - let lastId = visibleNotifications.last?.id - if notification.id == lastId, viewModel.state.hasMore { - viewModel.send(.loadNextPage) - } + let notifications = viewModel.state.notifications.filter { !$0.isHidden } + if notifications.isEmpty { + HStack { + Spacer() + Text(String(localized: "push_notifications_empty")) + .foregroundStyle(Color.gray) + Spacer() + } + } else { + List( + Array(zip(notifications.indices, notifications)), + id: \.1.id, + selection: selectedNotificationIdBinding + ) { index, notification in + notificationRow(notification) + .padding(.vertical, 8) + .tag(notification.id) + .onAppear { + let lastId = notifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { + viewModel.send(.loadNextPage) } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if index == 0 { - Divider() - .padding(.horizontal, -16) - } + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if index == 0 { + Divider() + .padding(.horizontal, -16) } } } - } + .listSectionSeparator(.hidden, edges: .top) + .listRowBackground(Color.clear) } - .listSectionSeparator(.hidden, edges: .top) - .listRowBackground(Color.clear) } } From 24fd487c41e20a9614844ffd1710825b52b0793a Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 14:19:56 +0900 Subject: [PATCH 02/16] =?UTF-8?q?fix:=20NavigationSplitView=EC=9D=98=20det?= =?UTF-8?q?ails=20=ED=8C=8C=ED=8A=B8=EC=99=80=20selection=EC=9D=B4=20?= =?UTF-8?q?=EC=97=B0=EA=B3=84=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index e550bcee..834ed385 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -96,28 +96,32 @@ struct PushNotificationListView: View { List( Array(zip(notifications.indices, notifications)), id: \.1.id, - selection: selectedNotificationIdBinding + selection: Binding( + get: { viewModel.state.selectedNotificationId }, + set: { viewModel.send(.selectNotification($0)) } + ) ) { index, notification in - notificationRow(notification) - .padding(.vertical, 8) - .tag(notification.id) - .onAppear { - let lastId = notifications.last?.id - if notification.id == lastId, viewModel.state.hasMore { - viewModel.send(.loadNextPage) + NavigationLink(value: notification.id) { + notificationRow(notification) + .padding(.vertical, 8) + .onAppear { + let lastId = notifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { + viewModel.send(.loadNextPage) + } } - } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if index == 0 { - Divider() - .padding(.horizontal, -16) + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if index == 0 { + Divider() + .padding(.horizontal, -16) + } } } - } - .listSectionSeparator(.hidden, edges: .top) - .listRowBackground(Color.clear) + } + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .listSectionSeparator(.hidden, edges: .top) + .listRowBackground(Color.clear) } } } From ffbc7141aaf4bf8562c4d70e253074483cbb0f82 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 14:56:47 +0900 Subject: [PATCH 03/16] =?UTF-8?q?ui:=20NavigationSplitView=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListViewModel.swift | 24 ++-- DevLog/Resource/Localizable.xcstrings | 17 +++ .../PushNotificationListView.swift | 119 +++++++++--------- 3 files changed, 93 insertions(+), 67 deletions(-) diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index f369bab4..a5af7766 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -21,6 +21,7 @@ final class PushNotificationListViewModel: Store { var hasMore: Bool = false var nextCursor: PushNotificationCursor? var query: PushNotificationQuery + var selectedNotificationId: String? var selectedTodoId: TodoIdItem? } @@ -42,8 +43,7 @@ final class PushNotificationListViewModel: Store { case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly case resetFilters - case tapNotification(PushNotificationItem) - case setSelectedTodoId(TodoIdItem?) + case selectNotification(String?) } enum SideEffect { @@ -97,10 +97,10 @@ final class PushNotificationListViewModel: Store { switch action { case .deleteNotification, .toggleRead, .undoDelete, .setAlert, .toggleSortOption, - .setTimeFilter, .toggleUnreadOnly, .resetFilters, .tapNotification: + .setTimeFilter, .toggleUnreadOnly, .resetFilters, .selectNotification: effects = reduceByUser(action, state: &state) - case .fetchNotifications, .setToast, .setSelectedTodoId, .loadNextPage: + case .fetchNotifications, .setToast, .loadNextPage: effects = reduceByView(action, state: &state) case .setLoading, .appendNotifications, .resetPagination, .setHasMore, @@ -221,9 +221,19 @@ private extension PushNotificationListViewModel { updateQueryUseCase.execute(state.query) state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] - case .tapNotification(let item): + case .selectNotification(let notificationId): + state.selectedNotificationId = notificationId + guard let notificationId else { + state.selectedTodoId = nil + return [] + } + guard let index = state.notifications.firstIndex(where: { $0.id == notificationId }) else { + state.selectedTodoId = nil + return [] + } + let item = state.notifications[index] state.selectedTodoId = TodoIdItem(id: item.todoId) - if let index = state.notifications.firstIndex(where: { $0.id == item.id }), !item.isRead { + if !item.isRead { state.notifications[index].isRead.toggle() return [.toggleRead(item.todoId)] } @@ -247,8 +257,6 @@ private extension PushNotificationListViewModel { state.notifications.removeAll { $0.isHidden } self.undoNotificationId = nil } - case .setSelectedTodoId(let todoId): - state.selectedTodoId = todoId default: break } diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 5de1745e..10eadb24 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -1064,6 +1064,23 @@ } } }, + "push_notifications_select_detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a notification." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림을 선택해주세요." + } + } + } + }, "push_period" : { "extractionState" : "manual", "localizations" : { diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 834ed385..1028cf08 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -8,7 +8,6 @@ import SwiftUI struct PushNotificationListView: View { - @State private var router = NavigationRouter() @State var viewModel: PushNotificationListViewModel @Environment(\.colorScheme) private var colorScheme @Environment(\.diContainer) private var container: DIContainer @@ -18,66 +17,47 @@ struct PushNotificationListView: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) var body: some View { - NavigationStack(path: $router.path) { + NavigationSplitView { notificationList - .listStyle(.plain) - .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) - .onScrollOffsetChange { offset in - guard isScrollTrackingEnabled else { return } - headerOffset = max(0, -offset) - } - .safeAreaInset(edge: .top) { safeAreaHeader } - .background(Color(.secondarySystemBackground)) - .onAppear { viewModel.send(.fetchNotifications) } - .refreshable { viewModel.send(.fetchNotifications) } - .navigationTitle(String(localized: "nav_push_notifications")) - .alert( - "", - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert(isPresented: $0)) } - )) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) - } - .toast( - isPresented: Binding( - get: { viewModel.state.showToast }, - set: { viewModel.send(.setToast(isPresented: $0)) }), - duration: 5, - action: { viewModel.send(.undoDelete) } - ) { - Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") - .font(.caption) - .multilineTextAlignment(.center) - .lineLimit(3) - } - .sheet(item: Binding( - get: { viewModel.state.selectedTodoId }, - set: { viewModel.send(.setSelectedTodoId($0)) } - )) { item in - NavigationStack { - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: item.id, - showEditButton: false - )) - .toolbar { - ToolbarLeadingButton { - viewModel.send(.setSelectedTodoId(nil)) - } - } - } - .background(Color(.secondarySystemBackground)) - .presentationDragIndicator(.visible) - } - .overlay { - if viewModel.state.isLoading { - LoadingView() + .listStyle(.sidebar) + .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) + .onScrollOffsetChange { offset in + guard isScrollTrackingEnabled else { return } + headerOffset = max(0, -offset) } + .safeAreaInset(edge: .top) { safeAreaHeader } + .onAppear { viewModel.send(.fetchNotifications) } + .refreshable { viewModel.send(.fetchNotifications) } + .navigationTitle(String(localized: "nav_push_notifications")) + } detail: { + detailContent + } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + .alert( + "", + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + )) { + Button(String(localized: "common_close"), role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) }), + duration: 5, + action: { viewModel.send(.undoDelete) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + .font(.caption) + .multilineTextAlignment(.center) + .lineLimit(3) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() } } } @@ -126,6 +106,26 @@ struct PushNotificationListView: View { } } + @ViewBuilder + private var detailContent: some View { + if let todoIdItem = viewModel.state.selectedTodoId { + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoIdItem.id, + showEditButton: false + )) + .id(todoIdItem.id) + } else { + ContentUnavailableView( + String(localized: "push_notifications_select_detail"), + systemImage: "bell.badge" + ) + .background(Color(.secondarySystemBackground)) + } + } + private var safeAreaHeader: some View { VStack(spacing: 4) { headerView @@ -278,6 +278,7 @@ struct PushNotificationListView: View { VStack(alignment: .leading, spacing: 5) { Text(item.title) .font(.headline) + .foregroundStyle(Color(.label)) .lineLimit(1) Text(item.body) .font(.subheadline) From 17571773a1fe58f4f9f7f425fc6d837f9e9cf23b Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 17:28:30 +0900 Subject: [PATCH 04/16] =?UTF-8?q?ui:=20compact=20ui=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=EB=8A=94=20=EC=8B=9C=ED=8A=B8,=20=EA=B7=B8=EB=A0=87=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EC=9D=84=EB=95=8C=EB=8A=94=20detail=EB=A1=9C=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=9E=8C=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=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 --- .../PushNotificationListView.swift | 117 +++++++++++------- 1 file changed, 72 insertions(+), 45 deletions(-) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 1028cf08..f4adc089 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -8,18 +8,18 @@ import SwiftUI struct PushNotificationListView: View { - @State var viewModel: PushNotificationListViewModel @Environment(\.colorScheme) private var colorScheme @Environment(\.diContainer) private var container: DIContainer + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @ScaledMetric(relativeTo: .body) private var headerHeight = 41 + @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 + @State var viewModel: PushNotificationListViewModel @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false - @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) var body: some View { NavigationSplitView { notificationList - .listStyle(.sidebar) .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) .onScrollOffsetChange { offset in guard isScrollTrackingEnabled else { return } @@ -30,7 +30,15 @@ struct PushNotificationListView: View { .refreshable { viewModel.send(.fetchNotifications) } .navigationTitle(String(localized: "nav_push_notifications")) } detail: { - detailContent + if let todoIdItem = viewModel.state.selectedTodoId { + todoDetailView(todoId: todoIdItem.id) + } else { + ContentUnavailableView( + String(localized: "push_notifications_select_detail"), + systemImage: "bell.badge" + ) + .background(Color(.secondarySystemBackground)) + } } .background(Color(.secondarySystemBackground).ignoresSafeArea()) .alert( @@ -55,6 +63,25 @@ struct PushNotificationListView: View { .multilineTextAlignment(.center) .lineLimit(3) } + .sheet(item: Binding( + get: { isCompactLayout ? viewModel.state.selectedTodoId : nil }, + set: { item in + if item == nil { + viewModel.send(.selectNotification(nil)) + } + } + )) { item in + NavigationStack { + todoDetailView(todoId: item.id) + .toolbar { + ToolbarLeadingButton { + viewModel.send(.selectNotification(nil)) + } + } + } + .background(Color(.secondarySystemBackground)) + .presentationDragIndicator(.visible) + } .overlay { if viewModel.state.isLoading { LoadingView() @@ -62,6 +89,22 @@ struct PushNotificationListView: View { } } + private var isCompactLayout: Bool { + horizontalSizeClass == .compact + } + + @ViewBuilder + private func todoDetailView(todoId: String) -> some View { + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoId, + showEditButton: false + )) + .id(todoId) + } + @ViewBuilder private var notificationList: some View { let notifications = viewModel.state.notifications.filter { !$0.isHidden } @@ -75,57 +118,38 @@ struct PushNotificationListView: View { } else { List( Array(zip(notifications.indices, notifications)), - id: \.1.id, - selection: Binding( - get: { viewModel.state.selectedNotificationId }, - set: { viewModel.send(.selectNotification($0)) } - ) + id: \.1.id ) { index, notification in - NavigationLink(value: notification.id) { - notificationRow(notification) - .padding(.vertical, 8) - .onAppear { - let lastId = notifications.last?.id - if notification.id == lastId, viewModel.state.hasMore { - viewModel.send(.loadNextPage) - } + Button { + viewModel.send(.selectNotification(notification.id)) + } label: { + notificationRow( + notification, + isSelected: !isCompactLayout && viewModel.state.selectedNotificationId == notification.id + ) + .onAppear { + let lastId = notifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { + viewModel.send(.loadNextPage) } - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if index == 0 { - Divider() - .padding(.horizontal, -16) - } + } + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if index == 0 { + Divider() + .padding(.horizontal, -16) } } + } } - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + .buttonStyle(.plain) + .listRowInsets(EdgeInsets()) .listSectionSeparator(.hidden, edges: .top) .listRowBackground(Color.clear) } } } - @ViewBuilder - private var detailContent: some View { - if let todoIdItem = viewModel.state.selectedTodoId { - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoIdItem.id, - showEditButton: false - )) - .id(todoIdItem.id) - } else { - ContentUnavailableView( - String(localized: "push_notifications_select_detail"), - systemImage: "bell.badge" - ) - .background(Color(.secondarySystemBackground)) - } - } - private var safeAreaHeader: some View { VStack(spacing: 4) { headerView @@ -257,7 +281,10 @@ struct PushNotificationListView: View { } // swiftlint:disable function_body_length - private func notificationRow(_ item: PushNotificationItem) -> some View { + private func notificationRow( + _ item: PushNotificationItem, + isSelected: Bool + ) -> some View { HStack { VStack { let todoCategoryItem = TodoCategoryItem(from: item.todoCategory) From 55ca2bd30a53cb2e79f8dbadbf50abc57bb13082 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 17:29:02 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20=ED=91=B8=EC=8B=9C=EC=95=8C?= =?UTF-8?q?=EB=9E=8C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=83=AD?= =?UTF-8?q?=ED=96=88=EC=9D=84=20=EB=95=8C=20=EC=84=A0=ED=83=9D=EB=90=98?= =?UTF-8?q?=EC=97=88=EB=8A=94=EC=A7=80=20=ED=99=95=EC=9D=B8=ED=95=98?= =?UTF-8?q?=EB=8A=94=20ui=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UI/PushNotification/PushNotificationListView.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index f4adc089..a4ebb95a 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -305,7 +305,7 @@ struct PushNotificationListView: View { VStack(alignment: .leading, spacing: 5) { Text(item.title) .font(.headline) - .foregroundStyle(Color(.label)) + .foregroundStyle(isSelected ? Color.white : Color(.label)) .lineLimit(1) Text(item.body) .font(.subheadline) @@ -321,8 +321,12 @@ struct PushNotificationListView: View { .foregroundStyle(Color.gray) } } - .padding(.vertical, 5) - .contentShape(.rect) + .padding(8) + .contentShape(RoundedRectangle(cornerRadius: 8)) + .background { + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.blue : .clear) + } .swipeActions(edge: .leading) { Button { viewModel.send(.toggleRead(item)) From f7bd845b5a761e214e1a670d2712e9fa5982b8da Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 17:47:14 +0900 Subject: [PATCH 06/16] =?UTF-8?q?fix:=20PushtNotificationListView=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=91=B8=EC=8B=9C=EC=95=8C=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?=ED=83=AD=ED=96=88=EC=9D=84=20=EB=95=8C=20=EC=83=89=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=98=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Todo/SystemTodoCategoryItem.swift | 18 +++++++++--------- .../Structure/Todo/TodoCategoryItem.swift | 2 +- .../PushNotificationListView.swift | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift index 0a9da6c6..e81d9e53 100644 --- a/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift +++ b/DevLog/Presentation/Structure/Todo/SystemTodoCategoryItem.swift @@ -42,16 +42,16 @@ struct SystemTodoCategoryItem: Identifiable, Hashable { } } - var color: Color { + var color: UIColor { switch systemTodoCategory { - case .issue: return .red - case .feature: return .green - case .improvement: return .cyan - case .review: return .orange - case .test: return .purple - case .doc: return .yellow - case .research: return .teal - case .etc: return .gray + case .issue: return .systemRed + case .feature: return .systemGreen + case .improvement: return .systemCyan + case .review: return .systemOrange + case .test: return .systemPurple + case .doc: return .systemYellow + case .research: return .systemTeal + case .etc: return .systemGray } } } diff --git a/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift b/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift index 4c3c4374..af9da76c 100644 --- a/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift +++ b/DevLog/Presentation/Structure/Todo/TodoCategoryItem.swift @@ -64,7 +64,7 @@ struct TodoCategoryItem: Identifiable, Hashable { var color: Color { switch category { case .system(let systemTodoCategory): - return SystemTodoCategoryItem(from: systemTodoCategory).color + return Color(SystemTodoCategoryItem(from: systemTodoCategory).color) case .user(let userTodoCategory): return UserTodoCategoryItem(from: userTodoCategory).color } diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index a4ebb95a..31a1d203 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -309,7 +309,7 @@ struct PushNotificationListView: View { .lineLimit(1) Text(item.body) .font(.subheadline) - .foregroundStyle(Color.gray) + .foregroundStyle(isSelected ? Color.white : .gray) .lineLimit(1) } @@ -318,7 +318,7 @@ struct PushNotificationListView: View { TimelineView(.periodic(from: .now, by: 1.0)) { context in Text(timeAgoText(from: item.receivedAt, now: context.date)) .font(.caption2) - .foregroundStyle(Color.gray) + .foregroundStyle(isSelected ? Color.white : .gray) } } .padding(8) From e0dee3a571798e45273e70d5d1b2a9c4df5ff583 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 18:02:49 +0900 Subject: [PATCH 07/16] =?UTF-8?q?ui:=20MainView=EC=97=90=20NavigationSplit?= =?UTF-8?q?View=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 229 ++++++++++++++++++++++++-------- 1 file changed, 175 insertions(+), 54 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index c0d717ae..33cceb74 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -9,80 +9,201 @@ import SwiftUI struct MainView: View { @Environment(\.diContainer) var container: DIContainer + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel @Binding var selectedTab: MainTab var body: some View { + content + .onAppear { + viewModel.send(.onAppear) + } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button(String(localized: "common_close"), role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + } + + @ViewBuilder + private var content: some View { + if isCompactLayout { + tabView + } else { + sidebarView + } + } + + private var isCompactLayout: Bool { + horizontalSizeClass == .compact + } + + private var sidebarSelection: Binding { + Binding( + get: { selectedTab }, + set: { tab in + if let tab { + selectedTab = tab + } + } + ) + } + + private var tabView: some View { TabView(selection: $selectedTab) { - HomeView(viewModel: HomeViewModel( - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), - addWebPageUseCase: container.resolve(AddWebPageUseCase.self), - deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), - undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self) - )) + homeView .tabItem { - Image(systemName: "house.fill") - Text(String(localized: "nav_home")) + tabLabel(.home) } .tag(MainTab.home) - TodayView(viewModel: TodayViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), - updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) - )) + + todayView .tabItem { - Image(systemName: "sun.max.fill") - Text(String(localized: "nav_today")) + tabLabel(.today) } .tag(MainTab.today) - PushNotificationListView(viewModel: PushNotificationListViewModel( - fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), - deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), - undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), - toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), - fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), - updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) - )) + + notificationView .tabItem { - Image(systemName: "bell.fill") - Text(String(localized: "nav_notifications")) + tabLabel(.notification) } .badge(viewModel.state.unreadPushCount) .tag(MainTab.notification) - ProfileView(viewModel: ProfileViewModel( - fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), - networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), - fetchHeatmapActivityTypesUseCase: container.resolve(FetchHeatmapActivityTypesUseCase.self), - updateHeatmapActivityTypesUseCase: container.resolve(UpdateHeatmapActivityTypesUseCase.self) - )) + + profileView .tabItem { - Image(systemName: "person.crop.circle.fill") - Text(String(localized: "nav_profile")) + tabLabel(.profile) } .tag(MainTab.profile) } - .onAppear { - viewModel.send(.onAppear) + } + + private var sidebarView: some View { + NavigationSplitView { + List(selection: sidebarSelection) { + sidebarRow(.home) + sidebarRow(.today) + sidebarRow(.notification) + sidebarRow(.profile) + } + .listStyle(.sidebar) + .navigationTitle(Text(verbatim: "DevLog")) + } detail: { + selectedTabView + } + } + + @ViewBuilder + private var selectedTabView: some View { + switch selectedTab { + case .home: + homeView + case .today: + todayView + case .notification: + notificationView + case .profile: + profileView } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) + } + + @ViewBuilder + private func sidebarRow(_ tab: MainTab) -> some View { + if tab == .notification { + tabLabel(tab) + .badge(viewModel.state.unreadPushCount) + .tag(tab) + } else { + tabLabel(tab) + .tag(tab) + } + } + + private func tabLabel(_ tab: MainTab) -> some View { + Label { + Text(tab.title) + } icon: { + Image(systemName: tab.symbolName) + } + } + + private var homeView: some View { + HomeView(viewModel: HomeViewModel( + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), + updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), + addWebPageUseCase: container.resolve(AddWebPageUseCase.self), + deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), + undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), + networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self) + )) + } + + private var todayView: some View { + TodayView(viewModel: TodayViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), + updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) + )) + } + + private var notificationView: some View { + PushNotificationListView(viewModel: PushNotificationListViewModel( + fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), + deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), + undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), + toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), + fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), + updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) + )) + } + + private var profileView: some View { + ProfileView(viewModel: ProfileViewModel( + fetchUserDataUseCase: container.resolve(FetchUserDataUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + upsertStatusMessageUseCase: container.resolve(UpsertStatusMessageUseCase.self), + networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self), + fetchHeatmapActivityTypesUseCase: container.resolve(FetchHeatmapActivityTypesUseCase.self), + updateHeatmapActivityTypesUseCase: container.resolve(UpdateHeatmapActivityTypesUseCase.self) + )) + } +} + +private extension MainTab { + var title: String { + switch self { + case .home: + String(localized: "nav_home") + case .today: + String(localized: "nav_today") + case .notification: + String(localized: "nav_notifications") + case .profile: + String(localized: "nav_profile") + } + } + + var symbolName: String { + switch self { + case .home: + "house.fill" + case .today: + "sun.max.fill" + case .notification: + "bell.fill" + case .profile: + "person.crop.circle.fill" } } } From 33ea5647032203b248f02f9c6c0aa4ac387b5232 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 18:16:46 +0900 Subject: [PATCH 08/16] =?UTF-8?q?style:=20=EC=9D=B8=EB=8D=B4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 34 ++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 33cceb74..eba87056 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -58,29 +58,29 @@ struct MainView: View { private var tabView: some View { TabView(selection: $selectedTab) { homeView - .tabItem { - tabLabel(.home) - } - .tag(MainTab.home) + .tabItem { + tabLabel(.home) + } + .tag(MainTab.home) todayView - .tabItem { - tabLabel(.today) - } - .tag(MainTab.today) + .tabItem { + tabLabel(.today) + } + .tag(MainTab.today) notificationView - .tabItem { - tabLabel(.notification) - } - .badge(viewModel.state.unreadPushCount) - .tag(MainTab.notification) + .tabItem { + tabLabel(.notification) + } + .badge(viewModel.state.unreadPushCount) + .tag(MainTab.notification) profileView - .tabItem { - tabLabel(.profile) - } - .tag(MainTab.profile) + .tabItem { + tabLabel(.profile) + } + .tag(MainTab.profile) } } From a2608a7cd8f58651ed44a1ff8ead6ad52fee8d66 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 22:49:38 +0900 Subject: [PATCH 09/16] =?UTF-8?q?ui:=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=EB=B7=B0=EB=8A=94=202=EB=8B=A8,=20=EB=82=98=EB=A8=B8=EC=A7=80?= =?UTF-8?q?=EB=8A=94=203=EB=8B=A8=20=ED=98=95=ED=83=9C=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 107 ++++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index eba87056..68120c27 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -84,21 +84,83 @@ struct MainView: View { } } + @ViewBuilder private var sidebarView: some View { - NavigationSplitView { - List(selection: sidebarSelection) { - sidebarRow(.home) - sidebarRow(.today) - sidebarRow(.notification) - sidebarRow(.profile) + switch selectedTab.mainTabSplitStyle { + case .detailOnly: + NavigationSplitView { + mainSidebar + } detail: { + selectedTabView + } + case .contentDetail: + switch selectedTab { + case .home: + NavigationSplitView { + mainSidebar + } content: { + homeView + } detail: { + EmptyView() + } + case .today: + NavigationSplitView { + mainSidebar + } content: { + todayView + } detail: { + EmptyView() + } + case .notification: + let viewModel = makePushNotificationListViewModel() + NavigationSplitView { + mainSidebar + } content: { + PushNotificationListView( + viewModel: viewModel, + isCompactLayout: isCompactLayout + ) + } detail: { + Group { + if let todoId = viewModel.state.selectedTodoId?.id { + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoId, + showEditButton: false + )) + .id(todoId) + } else { + ContentUnavailableView( + String(localized: "push_notifications_select_detail"), + systemImage: "bell.badge" + ) + .background(Color(.secondarySystemBackground)) + } + } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + } + case .profile: + NavigationSplitView { + mainSidebar + } detail: { + selectedTabView + } } - .listStyle(.sidebar) - .navigationTitle(Text(verbatim: "DevLog")) - } detail: { - selectedTabView } } + private var mainSidebar: some View { + List(selection: sidebarSelection) { + sidebarRow(.home) + sidebarRow(.today) + sidebarRow(.notification) + sidebarRow(.profile) + } + .listStyle(.sidebar) + } + @ViewBuilder private var selectedTabView: some View { switch selectedTab { @@ -158,14 +220,21 @@ struct MainView: View { } private var notificationView: some View { - PushNotificationListView(viewModel: PushNotificationListViewModel( + PushNotificationListView( + viewModel: makePushNotificationListViewModel(), + isCompactLayout: isCompactLayout + ) + } + + private func makePushNotificationListViewModel() -> PushNotificationListViewModel { + PushNotificationListViewModel( fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) - )) + ) } private var profileView: some View { @@ -180,7 +249,21 @@ struct MainView: View { } } +private enum MainTabSplitStyle { + case detailOnly + case contentDetail +} + private extension MainTab { + var mainTabSplitStyle: MainTabSplitStyle { + switch self { + case .home, .today, .notification: + .contentDetail + case .profile: + .detailOnly + } + } + var title: String { switch self { case .home: From fa47718e7bc7495092056cf747b950737a6f3959 Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 22:50:10 +0900 Subject: [PATCH 10/16] =?UTF-8?q?ui:=203=EB=8B=A8=20=ED=98=95=ED=83=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EC=84=B1=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 142 ++++++++++-------- 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 31a1d203..b3f12d57 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -10,36 +10,18 @@ import SwiftUI struct PushNotificationListView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.diContainer) private var container: DIContainer - @Environment(\.horizontalSizeClass) private var horizontalSizeClass @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = 34 @State var viewModel: PushNotificationListViewModel @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false + let isCompactLayout: Bool var body: some View { - NavigationSplitView { - notificationList - .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) - .onScrollOffsetChange { offset in - guard isScrollTrackingEnabled else { return } - headerOffset = max(0, -offset) - } - .safeAreaInset(edge: .top) { safeAreaHeader } - .onAppear { viewModel.send(.fetchNotifications) } - .refreshable { viewModel.send(.fetchNotifications) } - .navigationTitle(String(localized: "nav_push_notifications")) - } detail: { - if let todoIdItem = viewModel.state.selectedTodoId { - todoDetailView(todoId: todoIdItem.id) - } else { - ContentUnavailableView( - String(localized: "push_notifications_select_detail"), - systemImage: "bell.badge" - ) - .background(Color(.secondarySystemBackground)) - } + NavigationStack { + notificationListContent } + .listStyle(.sidebar) .background(Color(.secondarySystemBackground).ignoresSafeArea()) .alert( "", @@ -72,12 +54,19 @@ struct PushNotificationListView: View { } )) { item in NavigationStack { - todoDetailView(todoId: item.id) - .toolbar { - ToolbarLeadingButton { - viewModel.send(.selectNotification(nil)) - } + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: item.id, + showEditButton: false + )) + .id(item.id) + .toolbar { + ToolbarLeadingButton { + viewModel.send(.selectNotification(nil)) } + } } .background(Color(.secondarySystemBackground)) .presentationDragIndicator(.visible) @@ -89,20 +78,17 @@ struct PushNotificationListView: View { } } - private var isCompactLayout: Bool { - horizontalSizeClass == .compact - } - - @ViewBuilder - private func todoDetailView(todoId: String) -> some View { - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId, - showEditButton: false - )) - .id(todoId) + private var notificationListContent: some View { + notificationList + .background(NavigationBarConfigurator(.secondarySystemBackground, alwaysVisible: true)) + .onScrollOffsetChange { offset in + guard isScrollTrackingEnabled else { return } + headerOffset = max(0, -offset) + } + .safeAreaInset(edge: .top) { safeAreaHeader } + .onAppear { viewModel.send(.fetchNotifications) } + .refreshable { viewModel.send(.fetchNotifications) } + .navigationTitle(String(localized: "nav_push_notifications")) } @ViewBuilder @@ -120,29 +106,7 @@ struct PushNotificationListView: View { Array(zip(notifications.indices, notifications)), id: \.1.id ) { index, notification in - Button { - viewModel.send(.selectNotification(notification.id)) - } label: { - notificationRow( - notification, - isSelected: !isCompactLayout && viewModel.state.selectedNotificationId == notification.id - ) - .onAppear { - let lastId = notifications.last?.id - if notification.id == lastId, viewModel.state.hasMore { - viewModel.send(.loadNextPage) - } - } - .overlay(alignment: .top) { - if #available(iOS 26.0, *) { - if index == 0 { - Divider() - .padding(.horizontal, -16) - } - } - } - } - .buttonStyle(.plain) + notificationListRow(notification, index: index, notifications: notifications) .listRowInsets(EdgeInsets()) .listSectionSeparator(.hidden, edges: .top) .listRowBackground(Color.clear) @@ -150,6 +114,56 @@ struct PushNotificationListView: View { } } + @ViewBuilder + private func notificationListRow( + _ notification: PushNotificationItem, + index: Int, + notifications: [PushNotificationItem] + ) -> some View { + if isCompactLayout { + Button { + viewModel.send(.selectNotification(notification.id)) + } label: { + notificationRowContent(notification, index: index, notifications: notifications) + } + .buttonStyle(.plain) + } else { + notificationRowContent(notification, index: index, notifications: notifications) + .onTapGesture { + viewModel.send(.selectNotification(notification.id)) + } + .accessibilityAddTraits(.isButton) + .accessibilityAction { + viewModel.send(.selectNotification(notification.id)) + } + } + } + + private func notificationRowContent( + _ notification: PushNotificationItem, + index: Int, + notifications: [PushNotificationItem] + ) -> some View { + notificationRow( + notification, + isSelected: !isCompactLayout && viewModel.state.selectedNotificationId == notification.id + ) + .onAppear { + let lastId = notifications.last?.id + if notification.id == lastId, viewModel.state.hasMore { + viewModel.send(.loadNextPage) + } + } + .overlay(alignment: .top) { + if #available(iOS 26.0, *) { + if index == 0 { + Divider() + .padding(.horizontal, -16) + } + } + } + } + private var safeAreaHeader: some View { VStack(spacing: 4) { headerView From fa447c6f01c4302ed58546c1ecf712f349a3130b Mon Sep 17 00:00:00 2001 From: opficdev Date: Thu, 7 May 2026 23:26:43 +0900 Subject: [PATCH 11/16] =?UTF-8?q?fix:=20PushNotificationListView=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=91=B8=EC=8B=9C=EC=95=8C=EB=A6=BC=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=ED=83=AD=20=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?detail=EC=9D=B4=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 5 ++++- .../PushNotificationListView.swift | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 68120c27..87134f3a 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -11,6 +11,7 @@ struct MainView: View { @Environment(\.diContainer) var container: DIContainer @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel + @State private var todoIdToPresent: TodoIdItem? @Binding var selectedTab: MainTab var body: some View { @@ -118,11 +119,12 @@ struct MainView: View { } content: { PushNotificationListView( viewModel: viewModel, + todoIdToPresent: $todoIdToPresent, isCompactLayout: isCompactLayout ) } detail: { Group { - if let todoId = viewModel.state.selectedTodoId?.id { + if let todoId = todoIdToPresent?.id { TodoDetailView(viewModel: TodoDetailViewModel( fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), @@ -222,6 +224,7 @@ struct MainView: View { private var notificationView: some View { PushNotificationListView( viewModel: makePushNotificationListViewModel(), + todoIdToPresent: $todoIdToPresent, isCompactLayout: isCompactLayout ) } diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index b3f12d57..25cc63e1 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -15,6 +15,7 @@ struct PushNotificationListView: View { @State var viewModel: PushNotificationListViewModel @State private var headerOffset: CGFloat = 0 @State private var isScrollTrackingEnabled = false + @Binding var todoIdToPresent: TodoIdItem? let isCompactLayout: Bool var body: some View { @@ -46,10 +47,10 @@ struct PushNotificationListView: View { .lineLimit(3) } .sheet(item: Binding( - get: { isCompactLayout ? viewModel.state.selectedTodoId : nil }, + get: { isCompactLayout ? todoIdToPresent : nil }, set: { item in if item == nil { - viewModel.send(.selectNotification(nil)) + selectNotification(nil) } } )) { item in @@ -64,7 +65,7 @@ struct PushNotificationListView: View { .id(item.id) .toolbar { ToolbarLeadingButton { - viewModel.send(.selectNotification(nil)) + selectNotification(nil) } } } @@ -122,7 +123,7 @@ struct PushNotificationListView: View { ) -> some View { if isCompactLayout { Button { - viewModel.send(.selectNotification(notification.id)) + selectNotification(notification.id) } label: { notificationRowContent(notification, index: index, notifications: notifications) } @@ -130,11 +131,11 @@ struct PushNotificationListView: View { } else { notificationRowContent(notification, index: index, notifications: notifications) .onTapGesture { - viewModel.send(.selectNotification(notification.id)) + selectNotification(notification.id) } .accessibilityAddTraits(.isButton) .accessibilityAction { - viewModel.send(.selectNotification(notification.id)) + selectNotification(notification.id) } } } @@ -390,4 +391,9 @@ struct PushNotificationListView: View { ) } } + + private func selectNotification(_ notificationId: String?) { + viewModel.send(.selectNotification(notificationId)) + todoIdToPresent = viewModel.state.selectedTodoId + } } From d3ca010bdd550a47d491e0c1763d34b44dadbf13 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 8 May 2026 09:40:03 +0900 Subject: [PATCH 12/16] =?UTF-8?q?ui:=20HomeView=EC=97=90=203=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20NavigationSplitView=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Resource/Localizable.xcstrings | 17 ++++ DevLog/UI/Common/MainView.swift | 75 ++++++++++++++++- DevLog/UI/Home/HomeView.swift | 112 +++++++++++++++++++++----- DevLog/UI/Home/TodoListView.swift | 23 ++++-- 4 files changed, 198 insertions(+), 29 deletions(-) diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index 10eadb24..b106a70b 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -425,6 +425,23 @@ } } }, + "home_select_detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select an item." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "항목을 선택해주세요." + } + } + } + }, "home_recent_title" : { "extractionState" : "manual", "localizations" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 87134f3a..c0065e15 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -11,6 +11,7 @@ struct MainView: View { @Environment(\.diContainer) var container: DIContainer @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel + @State private var homeDetailDestination: HomeDetailDestination? @State private var todoIdToPresent: TodoIdItem? @Binding var selectedTab: MainTab @@ -102,7 +103,7 @@ struct MainView: View { } content: { homeView } detail: { - EmptyView() + homeDetailView } case .today: NavigationSplitView { @@ -198,7 +199,15 @@ struct MainView: View { } private var homeView: some View { - HomeView(viewModel: HomeViewModel( + HomeView( + viewModel: makeHomeViewModel(), + homeDetailDestination: $homeDetailDestination, + isCompactLayout: isCompactLayout + ) + } + + private func makeHomeViewModel() -> HomeViewModel { + HomeViewModel( fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), updatePreferencesUseCase: container.resolve(UpdateTodoCategoryPreferencesUseCase.self), addWebPageUseCase: container.resolve(AddWebPageUseCase.self), @@ -208,7 +217,67 @@ struct MainView: View { fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self) - )) + ) + } + + @ViewBuilder + private var homeDetailView: some View { + Group { + switch homeDetailDestination { + case .category(let item): + TodoListView( + viewModel: TodoListViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), + undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), + category: item.todoCategory + ), + todoIdToPresent: Binding( + get: { + if case .todo(let item) = homeDetailDestination { + item + } else { + nil + } + }, + set: { item in + if let item { + homeDetailDestination = .todo(item) + } + } + ), + isCompactLayout: false + ) + .id(item.id) + case .todo(let item): + TodoDetailView(viewModel: TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: item.id + )) + .id(item.id) + case .webPage(let item): + WebView(url: item.url) + .navigationBarTitleDisplayMode(.inline) + .ignoresSafeArea() + .toolbar { + ToolbarItem(placement: .principal) { + Text(item.title) + .bold() + } + } + case .none: + ContentUnavailableView( + String(localized: "home_select_detail"), + systemImage: "house" + ) + .background(Color(.secondarySystemBackground)) + } + } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) } private var todayView: some View { diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index 64a6e655..dd8f6b92 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -11,6 +11,8 @@ struct HomeView: View { @Environment(\.diContainer) var container: any DIContainer @State private var router = NavigationRouter() @State var viewModel: HomeViewModel + @Binding var homeDetailDestination: HomeDetailDestination? + let isCompactLayout: Bool @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) var body: some View { @@ -25,14 +27,31 @@ struct HomeView: View { .navigationDestination(for: Path.self) { path in switch path { case .category(let item): - TodoListView(viewModel: TodoListViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), - undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), - category: item.todoCategory - )) + TodoListView( + viewModel: TodoListViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), + undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), + category: item.todoCategory + ), + todoIdToPresent: Binding( + get: { + if case .todo(let item) = homeDetailDestination { + item + } else { + nil + } + }, + set: { item in + if let item { + homeDetailDestination = .todo(item) + } + } + ), + isCompactLayout: isCompactLayout + ) .environment(router) case .detail(let todoId): TodoDetailView(viewModel: TodoDetailViewModel( @@ -168,13 +187,7 @@ struct HomeView: View { } else { let preferences = viewModel.state.preferences ForEach(preferences.filter { $0.isVisible }, id: \.id) { item in - NavigationLink(value: Path.category(item)) { - labelImage( - text: item.localizedName, - systemName: item.symbolName, - imageColor: item.color - ) - } + todoCategoryRow(item) } } }, header: { @@ -209,9 +222,7 @@ struct HomeView: View { } } else { ForEach(viewModel.state.recentTodos, id: \.id) { todo in - NavigationLink(value: Path.detail(todo.id)) { - RecentTodoRow(todo: todo) - } + recentTodoRow(todo) } } } header: { @@ -290,9 +301,63 @@ struct HomeView: View { } } + @ViewBuilder + private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { + if isCompactLayout { + NavigationLink(value: Path.category(item)) { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + } else { + Button { + homeDetailDestination = .category(item) + } label: { + labelImage( + text: item.localizedName, + systemName: item.symbolName, + imageColor: item.color + ) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder + private func recentTodoRow(_ item: RecentTodoItem) -> some View { + if isCompactLayout { + NavigationLink(value: Path.detail(item.id)) { + RecentTodoRow(todo: item) + } + } else { + Button { + homeDetailDestination = .todo(TodoIdItem(id: item.id)) + } label: { + RecentTodoRow(todo: item) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } + } + + @ViewBuilder private func webResultRow(_ item: WebPageItem) -> some View { - NavigationLink(value: Path.web(item)) { - WebItemRow(item: item, showsChevron: false) + Group { + if isCompactLayout { + NavigationLink(value: Path.web(item)) { + WebItemRow(item: item, showsChevron: false) + } + } else { + Button { + homeDetailDestination = .webPage(item) + } label: { + WebItemRow(item: item, showsChevron: false) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { @@ -381,6 +446,7 @@ struct HomeView: View { Spacer() } .padding(.vertical, -6) + .contentShape(.rect) } private enum Path: Hashable { @@ -390,6 +456,12 @@ struct HomeView: View { } } +enum HomeDetailDestination: Hashable { + case category(TodoCategoryItem) + case todo(TodoIdItem) + case webPage(WebPageItem) +} + private struct RecentTodoRow: View { @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) let todo: RecentTodoItem diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 2b2d59c8..8d17c4f5 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -8,13 +8,15 @@ import SwiftUI struct TodoListView: View { - @State var viewModel: TodoListViewModel - @Environment(NavigationRouter.self) var router + @Environment(NavigationRouter.self) var router: NavigationRouter? @Environment(\.diContainer) var container: DIContainer @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @State private var headerOffset: CGFloat = .zero @State private var isScrollTrackingEnabled = false + @State var viewModel: TodoListViewModel + @Binding var todoIdToPresent: TodoIdItem? + let isCompactLayout: Bool var body: some View { Group { @@ -121,10 +123,11 @@ struct TodoListView: View { .task { viewModel.send(.onAppear) } } + @ViewBuilder private var todoListContent: some View { let visibleTodos = viewModel.state.todos.filter { !$0.isHidden } - return ZStack { + ZStack { List { Group { if visibleTodos.isEmpty, !viewModel.state.isLoading { @@ -138,7 +141,7 @@ struct TodoListView: View { } else { ForEach(Array(zip(visibleTodos.indices, visibleTodos)), id: \.1.id) { idx, todo in Button { - router.push(Path.detail(todo.id)) + selectTodo(todo.id) } label: { TodoItemRow(todo) } @@ -268,7 +271,7 @@ struct TodoListView: View { LazyVStack(spacing: 0) { ForEach(displayedTodos) { todo in Button { - router.push(Path.detail(todo.id)) + selectTodo(todo.id) } label: { VStack(spacing: 0) { TodoItemRow(todo) @@ -418,7 +421,15 @@ struct TodoListView: View { .background(Circle().fill(backgroundColor)) } -private enum Path: Hashable { + private func selectTodo(_ todoId: String) { + if isCompactLayout { + router?.push(Path.detail(todoId)) + } else { + todoIdToPresent = TodoIdItem(id: todoId) + } + } + + private enum Path: Hashable { case detail(String) } } From 605d014eaa2a33e3e86d8be3a3543b3d2d010f39 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 8 May 2026 12:32:12 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20HomeView=EC=9D=98=20details=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=EC=9D=B4=20=EB=82=B4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=EC=9D=B4=20=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=ED=98=84=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 144 ++++++++++------- DevLog/UI/Home/HomeView.swift | 259 +++++++++++++----------------- DevLog/UI/Home/TodoListView.swift | 25 +-- 3 files changed, 205 insertions(+), 223 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index c0065e15..e8222075 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -11,7 +11,7 @@ struct MainView: View { @Environment(\.diContainer) var container: DIContainer @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel - @State private var homeDetailDestination: HomeDetailDestination? + @State private var homeNavigationState = HomeNavigationState() @State private var todoIdToPresent: TodoIdItem? @Binding var selectedTab: MainTab @@ -103,8 +103,9 @@ struct MainView: View { } content: { homeView } detail: { - homeDetailView + homeRegularDetailView } + .environment(homeNavigationState) case .today: NavigationSplitView { mainSidebar @@ -198,10 +199,26 @@ struct MainView: View { } } + @ViewBuilder private var homeView: some View { + Group { + if isCompactLayout { + NavigationStack(path: $homeNavigationState.path) { + homeContentView + .navigationDestination(for: HomeRoute.self) { homeRoute in + homeDestinationView(homeRoute) + } + } + } else { + homeContentView + } + } + .environment(homeNavigationState) + } + + private var homeContentView: some View { HomeView( viewModel: makeHomeViewModel(), - homeDetailDestination: $homeDetailDestination, isCompactLayout: isCompactLayout ) } @@ -220,66 +237,79 @@ struct MainView: View { ) } + private var homeDetailPath: Binding<[HomeRoute]> { + Binding( + get: { homeNavigationState.detailPath }, + set: { homeNavigationState.detailPath = $0 } + ) + } + @ViewBuilder - private var homeDetailView: some View { - Group { - switch homeDetailDestination { - case .category(let item): - TodoListView( - viewModel: TodoListViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), - undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), - category: item.todoCategory - ), - todoIdToPresent: Binding( - get: { - if case .todo(let item) = homeDetailDestination { - item - } else { - nil - } - }, - set: { item in - if let item { - homeDetailDestination = .todo(item) - } - } - ), - isCompactLayout: false - ) - .id(item.id) - case .todo(let item): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: item.id - )) - .id(item.id) - case .webPage(let item): - WebView(url: item.url) - .navigationBarTitleDisplayMode(.inline) - .ignoresSafeArea() - .toolbar { - ToolbarItem(placement: .principal) { - Text(item.title) - .bold() - } - } - case .none: - ContentUnavailableView( - String(localized: "home_select_detail"), - systemImage: "house" - ) - .background(Color(.secondarySystemBackground)) + private var homeRegularDetailView: some View { + NavigationStack(path: homeDetailPath) { + Group { + if let homeRoute = homeNavigationState.root { + homeDestinationView(homeRoute) + } else { + ContentUnavailableView( + String(localized: "home_select_detail"), + systemImage: "house" + ) + .background(Color(.secondarySystemBackground)) + } + } + .navigationDestination(for: HomeRoute.self) { homeRoute in + homeDestinationView(homeRoute) } } .background(Color(.secondarySystemBackground).ignoresSafeArea()) } + @ViewBuilder + private func homeDestinationView(_ homeRoute: HomeRoute) -> some View { + switch homeRoute { + case .category(let item): + TodoListView( + viewModel: makeTodoListViewModel(category: item.todoCategory) + ) + .id(item.id) + case .todo(let item): + TodoDetailView(viewModel: makeTodoDetailViewModel(todoId: item.id)) + .id(item.id) + case .webPage(let item): + WebView(url: item.url) + .navigationBarTitleDisplayMode(.inline) + .ignoresSafeArea() + .toolbar(.hidden, for: .tabBar) + .toolbar { + ToolbarItem(placement: .principal) { + Text(item.title) + .bold() + } + } + } + } + + private func makeTodoListViewModel(category: TodoCategory) -> TodoListViewModel { + TodoListViewModel( + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), + upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), + deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), + undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), + category: category + ) + } + + private func makeTodoDetailViewModel(todoId: String) -> TodoDetailViewModel { + TodoDetailViewModel( + fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), + upsertUseCase: container.resolve(UpsertTodoUseCase.self), + todoId: todoId + ) + } + private var todayView: some View { TodayView(viewModel: TodayViewModel( fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index dd8f6b92..f2e4baed 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -9,147 +9,95 @@ import SwiftUI struct HomeView: View { @Environment(\.diContainer) var container: any DIContainer - @State private var router = NavigationRouter() + @Environment(HomeNavigationState.self) var homeNavigationState @State var viewModel: HomeViewModel - @Binding var homeDetailDestination: HomeDetailDestination? let isCompactLayout: Bool @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) var body: some View { - NavigationStack(path: $router.path) { - List { - todoSection - recentTodoSection - webPageSection - } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_home")) - .navigationDestination(for: Path.self) { path in - switch path { - case .category(let item): - TodoListView( - viewModel: TodoListViewModel( - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), - upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), - deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), - undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), - category: item.todoCategory - ), - todoIdToPresent: Binding( - get: { - if case .todo(let item) = homeDetailDestination { - item - } else { - nil - } - }, - set: { item in - if let item { - homeDetailDestination = .todo(item) - } - } - ), - isCompactLayout: isCompactLayout - ) - .environment(router) - case .detail(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId - )) - case .web(let page): - WebView(url: page.url) - .navigationBarTitleDisplayMode(.inline) - .ignoresSafeArea() - .toolbar(.hidden, for: .tabBar) - .toolbar { - ToolbarItem(placement: .principal) { - Text(page.title) - .bold() - } - } - } - } - .toolbar { toolbar } - .sheet(isPresented: Binding( - get: { viewModel.state.reorderTodo }, - set: { viewModel.send(.setPresentation(.reorderTodo, $0)) } - )) { - TodoManageView( - viewModel: TodoManageViewModel(viewModel.state.preferences), - onDismiss: { array in - viewModel.send(.setPresentation(.reorderTodo, false)) - withAnimation { - viewModel.send(.orderTodoCategory(array)) - } + List { + todoSection + recentTodoSection + webPageSection + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nav_home")) + .toolbar { toolbar } + .sheet(isPresented: Binding( + get: { viewModel.state.reorderTodo }, + set: { viewModel.send(.setPresentation(.reorderTodo, $0)) } + )) { + TodoManageView( + viewModel: TodoManageViewModel(viewModel.state.preferences), + onDismiss: { array in + viewModel.send(.setPresentation(.reorderTodo, false)) + withAnimation { + viewModel.send(.orderTodoCategory(array)) } - ) - } - .sheet(isPresented: Binding( - get: { viewModel.state.showContentPicker }, - set: { _, _ in } - )) { - contentPicker - } - .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showTodoEditor }, - set: { viewModel.send(.setPresentation(.todoEditor, $0)) } - )) { - if let selectedCategory = viewModel.state.selectedTodoCategory { - TodoEditorView( - viewModel: TodoEditorViewModel( - category: selectedCategory, - fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) - ), - onSubmit: { viewModel.send(.addTodo($0)) } - ) } - } - .fullScreenCover(isPresented: Binding( - get: { viewModel.state.showSearchView }, - set: { viewModel.send(.setPresentation(.searchView, $0)) } - )) { - SearchView(viewModel: SearchViewModel( - fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), - fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), - fetchRecentSearchQueriesUseCase: container.resolve(FetchRecentSearchQueriesUseCase.self), - updateRecentSearchQueriesUseCase: container.resolve(UpdateRecentSearchQueriesUseCase.self) - )) - } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert(isPresented: $0)) } + ) + } + .sheet(isPresented: Binding( + get: { viewModel.state.showContentPicker }, + set: { _, _ in } + )) { + contentPicker + } + .fullScreenCover(isPresented: Binding( + get: { viewModel.state.showTodoEditor }, + set: { viewModel.send(.setPresentation(.todoEditor, $0)) } + )) { + if let selectedCategory = viewModel.state.selectedTodoCategory { + TodoEditorView( + viewModel: TodoEditorViewModel( + category: selectedCategory, + fetchPreferencesUseCase: container.resolve(FetchTodoCategoryPreferencesUseCase.self), + fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self) + ), + onSubmit: { viewModel.send(.addTodo($0)) } ) - ) { - alertButtons - } message: { - Text(viewModel.state.alertMessage) - } - .toast( - isPresented: Binding( - get: { viewModel.state.showToast }, - set: { viewModel.send(.setToast(isPresented: $0)) } - ), - duration: 5, - action: { viewModel.send(.undoDeleteWebPage) } - ) { - Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") - .font(.caption) - .multilineTextAlignment(.center) - } - .onAppear { - viewModel.send(.onAppear) } - .overlay { - if viewModel.state.isAppending { - LoadingView() - } + } + .fullScreenCover(isPresented: Binding( + get: { viewModel.state.showSearchView }, + set: { viewModel.send(.setPresentation(.searchView, $0)) } + )) { + SearchView(viewModel: SearchViewModel( + fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self), + fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), + fetchRecentSearchQueriesUseCase: container.resolve(FetchRecentSearchQueriesUseCase.self), + updateRecentSearchQueriesUseCase: container.resolve(UpdateRecentSearchQueriesUseCase.self) + )) + } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert(isPresented: $0)) } + ) + ) { + alertButtons + } message: { + Text(viewModel.state.alertMessage) + } + .toast( + isPresented: Binding( + get: { viewModel.state.showToast }, + set: { viewModel.send(.setToast(isPresented: $0)) } + ), + duration: 5, + action: { viewModel.send(.undoDeleteWebPage) } + ) { + Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") + .font(.caption) + .multilineTextAlignment(.center) + } + .onAppear { + viewModel.send(.onAppear) + } + .overlay { + if viewModel.state.isAppending { + LoadingView() } } } @@ -304,7 +252,7 @@ struct HomeView: View { @ViewBuilder private func todoCategoryRow(_ item: TodoCategoryItem) -> some View { if isCompactLayout { - NavigationLink(value: Path.category(item)) { + NavigationLink(value: HomeRoute.category(item)) { labelImage( text: item.localizedName, systemName: item.symbolName, @@ -313,7 +261,7 @@ struct HomeView: View { } } else { Button { - homeDetailDestination = .category(item) + homeNavigationState.show(.category(item)) } label: { labelImage( text: item.localizedName, @@ -328,12 +276,12 @@ struct HomeView: View { @ViewBuilder private func recentTodoRow(_ item: RecentTodoItem) -> some View { if isCompactLayout { - NavigationLink(value: Path.detail(item.id)) { + NavigationLink(value: HomeRoute.todo(TodoIdItem(id: item.id))) { RecentTodoRow(todo: item) } } else { Button { - homeDetailDestination = .todo(TodoIdItem(id: item.id)) + homeNavigationState.show(.todo(TodoIdItem(id: item.id))) } label: { RecentTodoRow(todo: item) .frame(maxWidth: .infinity, alignment: .leading) @@ -346,12 +294,12 @@ struct HomeView: View { private func webResultRow(_ item: WebPageItem) -> some View { Group { if isCompactLayout { - NavigationLink(value: Path.web(item)) { + NavigationLink(value: HomeRoute.webPage(item)) { WebItemRow(item: item, showsChevron: false) } } else { Button { - homeDetailDestination = .webPage(item) + homeNavigationState.show(.webPage(item)) } label: { WebItemRow(item: item, showsChevron: false) .frame(maxWidth: .infinity, alignment: .leading) @@ -449,14 +397,39 @@ struct HomeView: View { .contentShape(.rect) } - private enum Path: Hashable { - case category(TodoCategoryItem) - case detail(String) - case web(WebPageItem) +} + +@Observable +final class HomeNavigationState { + var path: [HomeRoute] = [] + + var root: HomeRoute? { + path.first + } + + var detailPath: [HomeRoute] { + get { + Array(path.dropFirst()) + } + set { + if let homeRoute = root { + path = [homeRoute] + newValue + } else { + path = newValue + } + } + } + + func show(_ homeRoute: HomeRoute) { + path = [homeRoute] + } + + func push(_ homeRoute: HomeRoute) { + path.append(homeRoute) } } -enum HomeDetailDestination: Hashable { +enum HomeRoute: Hashable { case category(TodoCategoryItem) case todo(TodoIdItem) case webPage(WebPageItem) diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 8d17c4f5..efe324ee 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -8,15 +8,13 @@ import SwiftUI struct TodoListView: View { - @Environment(NavigationRouter.self) var router: NavigationRouter? + @Environment(HomeNavigationState.self) private var homeNavigationState @Environment(\.diContainer) var container: DIContainer @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @State private var headerOffset: CGFloat = .zero @State private var isScrollTrackingEnabled = false @State var viewModel: TodoListViewModel - @Binding var todoIdToPresent: TodoIdItem? - let isCompactLayout: Bool var body: some View { Group { @@ -53,17 +51,6 @@ struct TodoListView: View { ) } } - .navigationDestination(for: Path.self) { path in - switch path { - case .detail(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId - )) - } - } .alert( viewModel.state.alertTitle, isPresented: Binding( @@ -422,14 +409,6 @@ struct TodoListView: View { } private func selectTodo(_ todoId: String) { - if isCompactLayout { - router?.push(Path.detail(todoId)) - } else { - todoIdToPresent = TodoIdItem(id: todoId) - } - } - - private enum Path: Hashable { - case detail(String) + homeNavigationState.push(.todo(TodoIdItem(id: todoId))) } } From 9b9c6238435ed3a085e2eca3c909f0bc2a9d73d6 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 8 May 2026 12:46:09 +0900 Subject: [PATCH 14/16] =?UTF-8?q?ui:=20TodayView=EB=A5=BC=203=EB=8B=A8=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=ED=98=95=ED=83=9C=EC=9D=98=20SplitView?= =?UTF-8?q?=EB=A1=9C=20=EA=B5=AC=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/Resource/Localizable.xcstrings | 17 ++++ DevLog/UI/Common/MainView.swift | 69 +++++++++++++- DevLog/UI/Today/TodayView.swift | 131 ++++++++++++++++---------- 3 files changed, 165 insertions(+), 52 deletions(-) diff --git a/DevLog/Resource/Localizable.xcstrings b/DevLog/Resource/Localizable.xcstrings index b106a70b..6469af01 100644 --- a/DevLog/Resource/Localizable.xcstrings +++ b/DevLog/Resource/Localizable.xcstrings @@ -1931,6 +1931,23 @@ } } }, + "today_select_detail" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a todo." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todo를 선택해주세요." + } + } + } + }, "today_due_overdue" : { "extractionState" : "manual", "localizations" : { diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index e8222075..bad40514 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -12,6 +12,7 @@ struct MainView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel @State private var homeNavigationState = HomeNavigationState() + @State private var todayNavigationState = TodayNavigationState() @State private var todoIdToPresent: TodoIdItem? @Binding var selectedTab: MainTab @@ -112,8 +113,9 @@ struct MainView: View { } content: { todayView } detail: { - EmptyView() + todayRegularDetailView } + .environment(todayNavigationState) case .notification: let viewModel = makePushNotificationListViewModel() NavigationSplitView { @@ -310,14 +312,75 @@ struct MainView: View { ) } + @ViewBuilder private var todayView: some View { - TodayView(viewModel: TodayViewModel( + Group { + if isCompactLayout { + NavigationStack(path: $todayNavigationState.path) { + todayContentView + .navigationDestination(for: TodayRoute.self) { todayRoute in + todayDestinationView(todayRoute) + } + } + } else { + todayContentView + } + } + .environment(todayNavigationState) + } + + private var todayContentView: some View { + TodayView( + viewModel: makeTodayViewModel(), + isCompactLayout: isCompactLayout + ) + } + + private func makeTodayViewModel() -> TodayViewModel { + TodayViewModel( fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), fetchTodayDisplayOptionsUseCase: container.resolve(FetchTodayDisplayOptionsUseCase.self), updateTodayDisplayOptionsUseCase: container.resolve(UpdateTodayDisplayOptionsUseCase.self) - )) + ) + } + + private var todayDetailPath: Binding<[TodayRoute]> { + Binding( + get: { todayNavigationState.detailPath }, + set: { todayNavigationState.detailPath = $0 } + ) + } + + @ViewBuilder + private var todayRegularDetailView: some View { + NavigationStack(path: todayDetailPath) { + Group { + if let todayRoute = todayNavigationState.root { + todayDestinationView(todayRoute) + } else { + ContentUnavailableView( + String(localized: "today_select_detail"), + systemImage: "sun.max" + ) + .background(Color(.secondarySystemBackground)) + } + } + .navigationDestination(for: TodayRoute.self) { todayRoute in + todayDestinationView(todayRoute) + } + } + .background(Color(.secondarySystemBackground).ignoresSafeArea()) + } + + @ViewBuilder + private func todayDestinationView(_ todayRoute: TodayRoute) -> some View { + switch todayRoute { + case .todo(let item): + TodoDetailView(viewModel: makeTodoDetailViewModel(todoId: item.id)) + .id(item.id) + } } private var notificationView: some View { diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index fff76f17..11dfa546 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -8,54 +8,41 @@ import SwiftUI struct TodayView: View { - @Environment(\.diContainer) private var container: any DIContainer - @State private var router = NavigationRouter() + @Environment(TodayNavigationState.self) private var todayNavigationState @State var viewModel: TodayViewModel + let isCompactLayout: Bool var body: some View { - NavigationStack(path: $router.path) { - List { - summarySection - if viewModel.sections.isEmpty, !viewModel.state.isLoading { - emptySection - } else { - ForEach(viewModel.sections) { section in - todoSection(section.title, items: section.items) - } - } - } - .listStyle(.insetGrouped) - .navigationTitle(String(localized: "nav_today")) - .toolbar { toolbarContent } - .navigationDestination(for: Path.self) { path in - switch path { - case .detail(let todoId): - TodoDetailView(viewModel: TodoDetailViewModel( - fetchTodoUseCase: container.resolve(FetchTodoByIdUseCase.self), - fetchReferenceItemsUseCase: container.resolve(FetchReferenceItemsUseCase.self), - upsertUseCase: container.resolve(UpsertTodoUseCase.self), - todoId: todoId - )) + List { + summarySection + if viewModel.sections.isEmpty, !viewModel.state.isLoading { + emptySection + } else { + ForEach(viewModel.sections) { section in + todoSection(section.title, items: section.items) } } - .background(NavigationBarConfigurator()) - .refreshable { viewModel.send(.refresh) } - .onAppear { viewModel.send(.onAppear) } - .alert( - viewModel.state.alertTitle, - isPresented: Binding( - get: { viewModel.state.showAlert }, - set: { viewModel.send(.setAlert($0)) } - ) - ) { - Button(String(localized: "common_close"), role: .cancel) { } - } message: { - Text(viewModel.state.alertMessage) - } - .overlay { - if viewModel.state.isLoading { - LoadingView() - } + } + .listStyle(.insetGrouped) + .navigationTitle(String(localized: "nav_today")) + .toolbar { toolbarContent } + .background(NavigationBarConfigurator()) + .refreshable { viewModel.send(.refresh) } + .onAppear { viewModel.send(.onAppear) } + .alert( + viewModel.state.alertTitle, + isPresented: Binding( + get: { viewModel.state.showAlert }, + set: { viewModel.send(.setAlert($0)) } + ) + ) { + Button(String(localized: "common_close"), role: .cancel) { } + } message: { + Text(viewModel.state.alertMessage) + } + .overlay { + if viewModel.state.isLoading { + LoadingView() } } } @@ -145,10 +132,7 @@ struct TodayView: View { if !items.isEmpty { Section { ForEach(items) { item in - NavigationLink(value: Path.detail(item.id)) { - TodayTodoRow(item: item) - .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) - } + todoRow(item) .swipeActions(edge: .leading, allowsFullSwipe: false) { Button { viewModel.send(.togglePinned(item)) @@ -173,6 +157,25 @@ struct TodayView: View { } } + @ViewBuilder + private func todoRow(_ item: TodayTodoItem) -> some View { + if isCompactLayout { + NavigationLink(value: TodayRoute.todo(TodoIdItem(id: item.id))) { + TodayTodoRow(item: item) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + } else { + Button { + todayNavigationState.show(.todo(TodoIdItem(id: item.id))) + } label: { + TodayTodoRow(item: item) + .frame(maxWidth: .infinity, alignment: .leading) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + .buttonStyle(.plain) + } + } + private var emptyStateContent: EmptyStateContent { switch viewModel.state.selectedSectionScope { case .all: @@ -208,10 +211,40 @@ struct TodayView: View { let title: String let message: String } +} + +@Observable +final class TodayNavigationState { + var path: [TodayRoute] = [] - private enum Path: Hashable { - case detail(String) + var root: TodayRoute? { + path.first } + + var detailPath: [TodayRoute] { + get { + Array(path.dropFirst()) + } + set { + if let todayRoute = root { + path = [todayRoute] + newValue + } else { + path = newValue + } + } + } + + func show(_ todayRoute: TodayRoute) { + path = [todayRoute] + } + + func push(_ todayRoute: TodayRoute) { + path.append(todayRoute) + } +} + +enum TodayRoute: Hashable { + case todo(TodoIdItem) } private extension TodayDisplayOptions.DueDateVisibility { From 788b071b4f085b0683add87cab9b9c53507bb423 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 8 May 2026 13:38:41 +0900 Subject: [PATCH 15/16] =?UTF-8?q?refactor:=20Home,=20Today=EC=9D=98=20?= =?UTF-8?q?=EA=B0=81=20State=EB=A5=BC=20NavigationRouter=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=ED=95=98=EC=97=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 28 +++++++++--------- DevLog/UI/Common/NavigationRouter.swift | 29 +++++++++++++++---- DevLog/UI/Home/HomeView.swift | 38 +++---------------------- DevLog/UI/Home/TodoListView.swift | 4 +-- DevLog/UI/Profile/ProfileView.swift | 22 ++++++++------ DevLog/UI/Search/SearchView.swift | 2 +- DevLog/UI/Setting/SettingView.swift | 16 +++++------ DevLog/UI/Today/TodayView.swift | 34 ++-------------------- 8 files changed, 68 insertions(+), 105 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index bad40514..91318a68 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -11,8 +11,8 @@ struct MainView: View { @Environment(\.diContainer) var container: DIContainer @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State var viewModel: MainViewModel - @State private var homeNavigationState = HomeNavigationState() - @State private var todayNavigationState = TodayNavigationState() + @State private var homeNavigationRouter = NavigationRouter() + @State private var todayNavigationRouter = NavigationRouter() @State private var todoIdToPresent: TodoIdItem? @Binding var selectedTab: MainTab @@ -106,7 +106,7 @@ struct MainView: View { } detail: { homeRegularDetailView } - .environment(homeNavigationState) + .environment(homeNavigationRouter) case .today: NavigationSplitView { mainSidebar @@ -115,7 +115,7 @@ struct MainView: View { } detail: { todayRegularDetailView } - .environment(todayNavigationState) + .environment(todayNavigationRouter) case .notification: let viewModel = makePushNotificationListViewModel() NavigationSplitView { @@ -205,7 +205,7 @@ struct MainView: View { private var homeView: some View { Group { if isCompactLayout { - NavigationStack(path: $homeNavigationState.path) { + NavigationStack(path: $homeNavigationRouter.path) { homeContentView .navigationDestination(for: HomeRoute.self) { homeRoute in homeDestinationView(homeRoute) @@ -215,7 +215,7 @@ struct MainView: View { homeContentView } } - .environment(homeNavigationState) + .environment(homeNavigationRouter) } private var homeContentView: some View { @@ -241,8 +241,8 @@ struct MainView: View { private var homeDetailPath: Binding<[HomeRoute]> { Binding( - get: { homeNavigationState.detailPath }, - set: { homeNavigationState.detailPath = $0 } + get: { homeNavigationRouter.detailPath }, + set: { homeNavigationRouter.detailPath = $0 } ) } @@ -250,7 +250,7 @@ struct MainView: View { private var homeRegularDetailView: some View { NavigationStack(path: homeDetailPath) { Group { - if let homeRoute = homeNavigationState.root { + if let homeRoute = homeNavigationRouter.root { homeDestinationView(homeRoute) } else { ContentUnavailableView( @@ -316,7 +316,7 @@ struct MainView: View { private var todayView: some View { Group { if isCompactLayout { - NavigationStack(path: $todayNavigationState.path) { + NavigationStack(path: $todayNavigationRouter.path) { todayContentView .navigationDestination(for: TodayRoute.self) { todayRoute in todayDestinationView(todayRoute) @@ -326,7 +326,7 @@ struct MainView: View { todayContentView } } - .environment(todayNavigationState) + .environment(todayNavigationRouter) } private var todayContentView: some View { @@ -348,8 +348,8 @@ struct MainView: View { private var todayDetailPath: Binding<[TodayRoute]> { Binding( - get: { todayNavigationState.detailPath }, - set: { todayNavigationState.detailPath = $0 } + get: { todayNavigationRouter.detailPath }, + set: { todayNavigationRouter.detailPath = $0 } ) } @@ -357,7 +357,7 @@ struct MainView: View { private var todayRegularDetailView: some View { NavigationStack(path: todayDetailPath) { Group { - if let todayRoute = todayNavigationState.root { + if let todayRoute = todayNavigationRouter.root { todayDestinationView(todayRoute) } else { ContentUnavailableView( diff --git a/DevLog/UI/Common/NavigationRouter.swift b/DevLog/UI/Common/NavigationRouter.swift index 1713ed69..956ab997 100644 --- a/DevLog/UI/Common/NavigationRouter.swift +++ b/DevLog/UI/Common/NavigationRouter.swift @@ -8,12 +8,31 @@ import SwiftUI @Observable -final class NavigationRouter { - var path = NavigationPath() +final class NavigationRouter { + var path: [Route] = [] - func push(_ element: any Hashable) { - Task { @MainActor in - path.append(element) + var root: Route? { + path.first + } + + var detailPath: [Route] { + get { + Array(path.dropFirst()) } + set { + if let route = root { + path = [route] + newValue + } else { + path = newValue + } + } + } + + func show(_ route: Route) { + path = [route] + } + + func push(_ route: Route) { + path.append(route) } } diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index f2e4baed..a8199bf1 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -9,7 +9,7 @@ import SwiftUI struct HomeView: View { @Environment(\.diContainer) var container: any DIContainer - @Environment(HomeNavigationState.self) var homeNavigationState + @Environment(NavigationRouter.self) private var router @State var viewModel: HomeViewModel let isCompactLayout: Bool @ScaledMetric(relativeTo: .largeTitle) private var labelWidth = CGFloat(34) @@ -261,7 +261,7 @@ struct HomeView: View { } } else { Button { - homeNavigationState.show(.category(item)) + router.show(.category(item)) } label: { labelImage( text: item.localizedName, @@ -281,7 +281,7 @@ struct HomeView: View { } } else { Button { - homeNavigationState.show(.todo(TodoIdItem(id: item.id))) + router.show(.todo(TodoIdItem(id: item.id))) } label: { RecentTodoRow(todo: item) .frame(maxWidth: .infinity, alignment: .leading) @@ -299,7 +299,7 @@ struct HomeView: View { } } else { Button { - homeNavigationState.show(.webPage(item)) + router.show(.webPage(item)) } label: { WebItemRow(item: item, showsChevron: false) .frame(maxWidth: .infinity, alignment: .leading) @@ -399,36 +399,6 @@ struct HomeView: View { } -@Observable -final class HomeNavigationState { - var path: [HomeRoute] = [] - - var root: HomeRoute? { - path.first - } - - var detailPath: [HomeRoute] { - get { - Array(path.dropFirst()) - } - set { - if let homeRoute = root { - path = [homeRoute] + newValue - } else { - path = newValue - } - } - } - - func show(_ homeRoute: HomeRoute) { - path = [homeRoute] - } - - func push(_ homeRoute: HomeRoute) { - path.append(homeRoute) - } -} - enum HomeRoute: Hashable { case category(TodoCategoryItem) case todo(TodoIdItem) diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index efe324ee..9dfcb9d8 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -8,7 +8,7 @@ import SwiftUI struct TodoListView: View { - @Environment(HomeNavigationState.self) private var homeNavigationState + @Environment(NavigationRouter.self) private var router @Environment(\.diContainer) var container: DIContainer @Environment(\.colorScheme) private var colorScheme @ScaledMetric(relativeTo: .body) private var headerHeight = 41 @@ -409,6 +409,6 @@ struct TodoListView: View { } private func selectTodo(_ todoId: String) { - homeNavigationState.push(.todo(TodoIdItem(id: todoId))) + router.push(.todo(TodoIdItem(id: todoId))) } } diff --git a/DevLog/UI/Profile/ProfileView.swift b/DevLog/UI/Profile/ProfileView.swift index 947f612f..6d2e64dc 100644 --- a/DevLog/UI/Profile/ProfileView.swift +++ b/DevLog/UI/Profile/ProfileView.swift @@ -9,7 +9,7 @@ import SwiftUI struct ProfileView: View { @State var viewModel: ProfileViewModel - @State private var router = NavigationRouter() + @State private var router = NavigationRouter() @Environment(\.diContainer) private var container @FocusState private var focused: Bool @@ -88,14 +88,14 @@ struct ProfileView: View { ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 0) { Button { - router.push(Path.settings) + router.push(.settings) } label: { Image(systemName: "gearshape") } } } } - .navigationDestination(for: Path.self) { path in + .navigationDestination(for: ProfileRoute.self) { path in switch path { case .settings: SettingView(viewModel: SettingViewModel( @@ -116,6 +116,8 @@ struct ProfileView: View { todoId: todoId, showEditButton: false )) + case .theme, .pushNotification, .account: + EmptyView() } } .onAppear { viewModel.send(.onAppear) } @@ -350,7 +352,7 @@ struct ProfileView: View { ForEach(activities) { activity in Button { if !activity.isDeleted { - router.push(Path.activity(activity.todoId)) + router.push(.activity(activity.todoId)) } } label: { let item = TodoCategoryItem(from: activity.category) @@ -395,8 +397,12 @@ struct ProfileView: View { .padding(.top, 4) } - private enum Path: Hashable { - case settings - case activity(String) - } +} + +enum ProfileRoute: Hashable { + case settings + case activity(String) + case theme + case pushNotification + case account } diff --git a/DevLog/UI/Search/SearchView.swift b/DevLog/UI/Search/SearchView.swift index 8db33875..fc43299a 100644 --- a/DevLog/UI/Search/SearchView.swift +++ b/DevLog/UI/Search/SearchView.swift @@ -10,7 +10,7 @@ import SwiftUI struct SearchView: View { @Environment(\.dismiss) private var dismiss @Environment(\.diContainer) private var container: DIContainer - @State private var router = NavigationRouter() + @State private var router = NavigationRouter() @State var viewModel: SearchViewModel var body: some View { diff --git a/DevLog/UI/Setting/SettingView.swift b/DevLog/UI/Setting/SettingView.swift index e0e1d542..2d9f8baa 100644 --- a/DevLog/UI/Setting/SettingView.swift +++ b/DevLog/UI/Setting/SettingView.swift @@ -9,15 +9,15 @@ import SwiftUI struct SettingView: View { @Environment(\.diContainer) var container: DIContainer + @Environment(NavigationRouter.self) private var router @State var viewModel: SettingViewModel - @Environment(NavigationRouter.self) var router var body: some View { let connected = viewModel.state.isNetworkConnected Form { Section { Button { - router.push(Path.theme) + router.push(.theme) } label: { HStack { Text(String(localized: "settings_theme")) @@ -29,7 +29,7 @@ struct SettingView: View { } Button { - router.push(Path.pushNotification) + router.push(.pushNotification) } label: { Text(String(localized: "settings_notifications")) .foregroundStyle(connected ? Color.primary : Color.secondary) @@ -83,7 +83,7 @@ struct SettingView: View { Section { Button { - router.push(Path.account) + router.push(.account) } label: { Text(String(localized: "settings_account")) } @@ -110,7 +110,7 @@ struct SettingView: View { } .navigationTitle(String(localized: "nav_settings")) .navigationBarTitleDisplayMode(.inline) - .navigationDestination(for: Path.self) { path in + .navigationDestination(for: ProfileRoute.self) { path in switch path { case .theme: ThemeView( @@ -134,6 +134,8 @@ struct SettingView: View { unlinkProviderUseCase: container.resolve(UnlinkAuthProviderUseCase.self) ) ) + case .settings, .activity: + EmptyView() } } .alert( @@ -156,10 +158,6 @@ struct SettingView: View { } } - private enum Path: Hashable { - case theme, pushNotification, account - } - @ViewBuilder private var alertButtons: some View { switch viewModel.state.alertType { diff --git a/DevLog/UI/Today/TodayView.swift b/DevLog/UI/Today/TodayView.swift index 11dfa546..ab7fc181 100644 --- a/DevLog/UI/Today/TodayView.swift +++ b/DevLog/UI/Today/TodayView.swift @@ -8,7 +8,7 @@ import SwiftUI struct TodayView: View { - @Environment(TodayNavigationState.self) private var todayNavigationState + @Environment(NavigationRouter.self) private var router @State var viewModel: TodayViewModel let isCompactLayout: Bool @@ -166,7 +166,7 @@ struct TodayView: View { } } else { Button { - todayNavigationState.show(.todo(TodoIdItem(id: item.id))) + router.show(.todo(TodoIdItem(id: item.id))) } label: { TodayTodoRow(item: item) .frame(maxWidth: .infinity, alignment: .leading) @@ -213,36 +213,6 @@ struct TodayView: View { } } -@Observable -final class TodayNavigationState { - var path: [TodayRoute] = [] - - var root: TodayRoute? { - path.first - } - - var detailPath: [TodayRoute] { - get { - Array(path.dropFirst()) - } - set { - if let todayRoute = root { - path = [todayRoute] + newValue - } else { - path = newValue - } - } - } - - func show(_ todayRoute: TodayRoute) { - path = [todayRoute] - } - - func push(_ todayRoute: TodayRoute) { - path.append(todayRoute) - } -} - enum TodayRoute: Hashable { case todo(TodoIdItem) } From c9a280b6e50e543a7eb6a5244135ce59a79d87b5 Mon Sep 17 00:00:00 2001 From: opficdev Date: Fri, 8 May 2026 14:43:30 +0900 Subject: [PATCH 16/16] =?UTF-8?q?refactor:=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=AA=A8=EB=94=94=ED=8C=8C=EC=9D=B4=EC=96=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DevLog/UI/Common/MainView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 91318a68..8191e8fc 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -142,7 +142,6 @@ struct MainView: View { String(localized: "push_notifications_select_detail"), systemImage: "bell.badge" ) - .background(Color(.secondarySystemBackground)) } } .background(Color(.secondarySystemBackground).ignoresSafeArea()) @@ -257,7 +256,6 @@ struct MainView: View { String(localized: "home_select_detail"), systemImage: "house" ) - .background(Color(.secondarySystemBackground)) } } .navigationDestination(for: HomeRoute.self) { homeRoute in @@ -364,7 +362,6 @@ struct MainView: View { String(localized: "today_select_detail"), systemImage: "sun.max" ) - .background(Color(.secondarySystemBackground)) } } .navigationDestination(for: TodayRoute.self) { todayRoute in