From f51bd578a25ee30dbcc878a5f5cd334d07368700 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:29:16 +0900 Subject: [PATCH 1/7] chore: APPSTORE_URL -> TESTFLIGHT_URL --- Application/DevLogApp/Sources/Resource/Info.plist | 4 ++-- .../Sources/Settings/SettingsViewModel.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Application/DevLogApp/Sources/Resource/Info.plist b/Application/DevLogApp/Sources/Resource/Info.plist index 0b7c3a8d..5ae3b321 100644 --- a/Application/DevLogApp/Sources/Resource/Info.plist +++ b/Application/DevLogApp/Sources/Resource/Info.plist @@ -2,8 +2,8 @@ - APPSTORE_URL - $(APPSTORE_URL) + TESTFLIGHT_URL + $(TESTFLIGHT_URL) APP_REDIRECT_URL $(APP_REDIRECT_URL) CFBundleDevelopmentRegion diff --git a/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift b/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift index ab2401ed..6098c251 100644 --- a/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift +++ b/Application/DevLogPresentation/Sources/Settings/SettingsViewModel.swift @@ -59,7 +59,7 @@ final class SettingsViewModel: Store { private var cancellables = Set() let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - let appstoreUrl = Bundle.main.object(forInfoDictionaryKey: "APPSTORE_URL") as? String + let appstoreUrl = Bundle.main.object(forInfoDictionaryKey: "TESTFLIGHT_URL") as? String let policyURL = Bundle.main.object(forInfoDictionaryKey: "PRIVACY_POLICY_URL") as? String init( From b7d5dfcc0b970839e083bbc2a2b36c0fedfe0ec0 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:36:56 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EC=95=B1=20xcconfig=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Application/DevLogApp/Project.swift | 2 +- Application/DevLogApp/Sources/App.xcconfig | 2 -- Application/DevLogApp/Sources/Resource/App.xcconfig | 2 ++ 3 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 Application/DevLogApp/Sources/App.xcconfig create mode 100644 Application/DevLogApp/Sources/Resource/App.xcconfig diff --git a/Application/DevLogApp/Project.swift b/Application/DevLogApp/Project.swift index 35f4ee59..fa3d73c7 100644 --- a/Application/DevLogApp/Project.swift +++ b/Application/DevLogApp/Project.swift @@ -36,7 +36,7 @@ let project = Project( DevLogPackages.swiftLintPlugin, ], settings: .devlog( - versionXcconfigPath: "Sources/App.xcconfig", + versionXcconfigPath: "Sources/Resource/App.xcconfig", base: [ "ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon", "CODE_SIGN_STYLE": "Automatic", diff --git a/Application/DevLogApp/Sources/App.xcconfig b/Application/DevLogApp/Sources/App.xcconfig deleted file mode 100644 index e0e8aa65..00000000 --- a/Application/DevLogApp/Sources/App.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Shared/Version.xcconfig" -#include? "Resource/Config.xcconfig" diff --git a/Application/DevLogApp/Sources/Resource/App.xcconfig b/Application/DevLogApp/Sources/Resource/App.xcconfig new file mode 100644 index 00000000..e4f6a6b1 --- /dev/null +++ b/Application/DevLogApp/Sources/Resource/App.xcconfig @@ -0,0 +1,2 @@ +#include "../../../Shared/Version.xcconfig" +#include? "Config.xcconfig" From 2818a04353af14ff09693b6ef97126805fd97680 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:36:48 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20ToastPresenter=20host=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/Component/Toast.swift | 137 +++++++++++++++++- .../Sources/Main/MainView.swift | 1 + 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Common/Component/Toast.swift b/Application/DevLogPresentation/Sources/Common/Component/Toast.swift index 43f8af56..350a4a76 100644 --- a/Application/DevLogPresentation/Sources/Common/Component/Toast.swift +++ b/Application/DevLogPresentation/Sources/Common/Component/Toast.swift @@ -6,7 +6,6 @@ // import SwiftUI -import DevLogDomain extension View { func toast( @@ -31,6 +30,129 @@ extension View { } } +@Observable +final class ToastPresenter { + fileprivate static let presenter = ToastPresenter() + + private(set) var item: ToastItem? + + private init() { } + + static var item: ToastItem? { + presenter.item + } + + static func present( + message: String, + systemImage: String? = nil, + duration: TimeInterval = 2, + font: Font? = nil, + multilineTextAlignment: TextAlignment = .leading, + lineLimit: Int? = nil, + action: (() -> Void)? = nil, + onDismiss: (() -> Void)? = nil + ) { + presenter.present( + ToastItem( + message: message, + systemImage: systemImage, + duration: duration, + font: font, + multilineTextAlignment: multilineTextAlignment, + lineLimit: lineLimit, + action: action, + onDismiss: onDismiss + ) + ) + } + + static func reset() { + presenter.item = nil + } + + private func present(_ item: ToastItem) { + dismissImmediately() + self.item = item + } + + fileprivate func dismiss(itemId: UUID) { + guard let item, + item.id == itemId else { return } + self.item = nil + } + + private func dismissImmediately() { + guard let item else { return } + self.item = nil + item.onDismiss?() + } +} + +struct ToastItem: Identifiable { + let id = UUID() + let message: String + let systemImage: String? + let duration: TimeInterval + let font: Font? + let multilineTextAlignment: TextAlignment + let lineLimit: Int? + let action: (() -> Void)? + let onDismiss: (() -> Void)? +} + +extension View { + func toastHost() -> some View { + modifier(ToastHostModifier()) + } +} + +private struct ToastHostModifier: ViewModifier { + private let toastPresenter = ToastPresenter.presenter + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottom) { + if let item = toastPresenter.item { + ToastOverlayView( + isPresented: Binding( + get: { toastPresenter.item?.id == item.id }, + set: { isPresented in + if !isPresented { + toastPresenter.dismiss(itemId: item.id) + } + } + ), + duration: item.duration, + action: item.action, + onDismiss: item.onDismiss + ) { + ToastItemLabel(item: item) + } + .id(item.id) + .padding(.horizontal, 12) + } + } + } +} + +private struct ToastItemLabel: View { + let item: ToastItem + + var body: some View { + Group { + if let systemImage = item.systemImage { + Label(item.message, systemImage: systemImage) + } else { + Text(item.message) + } + } + .font(item.font) + .multilineTextAlignment(item.multilineTextAlignment) + .lineLimit(item.lineLimit) + } +} + private struct ToastOverlayView: View { @Binding var isPresented: Bool let duration: TimeInterval @@ -41,6 +163,7 @@ private struct ToastOverlayView: View { @State private var yOffset: CGFloat = 0 @State private var opacityValue: Double = 0 @State private var dismissWorkItem: DispatchWorkItem? + @State private var dismissCompletionWorkItem: DispatchWorkItem? @State private var isTapped: Bool = false @State private var isScheduled: Bool = false @@ -65,6 +188,9 @@ private struct ToastOverlayView: View { presentAnimated() scheduleDismissIfNeeded() } + .onDisappear { + cleanupPresentation() + } .onTapGesture { isTapped = true dismissAnimated() @@ -86,6 +212,8 @@ private struct ToastOverlayView: View { private func resetForNewPresentation() { dismissWorkItem?.cancel() dismissWorkItem = nil + dismissCompletionWorkItem?.cancel() + dismissCompletionWorkItem = nil isScheduled = false isTapped = false yOffset = 0 @@ -95,6 +223,8 @@ private struct ToastOverlayView: View { private func cleanupPresentation() { dismissWorkItem?.cancel() dismissWorkItem = nil + dismissCompletionWorkItem?.cancel() + dismissCompletionWorkItem = nil isScheduled = false isTapped = false yOffset = 0 @@ -115,13 +245,14 @@ private struct ToastOverlayView: View { private func dismissAnimated() { dismissWorkItem?.cancel() dismissWorkItem = nil + dismissCompletionWorkItem?.cancel() withAnimation(.easeInOut(duration: 0.2)) { yOffset = 0 opacityValue = 0 } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + let workItem = DispatchWorkItem { isPresented = false isScheduled = false @@ -130,6 +261,8 @@ private struct ToastOverlayView: View { } isTapped = false } + dismissCompletionWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem) } } diff --git a/Application/DevLogPresentation/Sources/Main/MainView.swift b/Application/DevLogPresentation/Sources/Main/MainView.swift index 59222a13..f6c61af8 100644 --- a/Application/DevLogPresentation/Sources/Main/MainView.swift +++ b/Application/DevLogPresentation/Sources/Main/MainView.swift @@ -70,6 +70,7 @@ struct MainView: View { } message: { Text(coordinator.viewModel.state.alertMessage) } + .toastHost() } private var tabView: some View { From a7794f5e2ae91a49bdb565ee6b8f5d14ce7cf69c Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:37:03 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20Home=20Todo=20toast=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Home/Home/HomeView.swift | 12 ---- .../Sources/Home/Home/HomeViewModel.swift | 56 +++++++++---------- .../Sources/Home/TodoListView.swift | 10 ---- .../Sources/Home/TodoListViewModel.swift | 41 +++++++------- .../Tests/WebPage/DeleteWebPageTests.swift | 6 +- 5 files changed, 52 insertions(+), 73 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift index 613af432..8e371f23 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeView.swift @@ -71,18 +71,6 @@ struct HomeView: View { } message: { Text(coordinator.viewModel.state.alertMessage) } - .toast( - isPresented: Binding( - get: { coordinator.viewModel.state.showToast }, - set: { coordinator.viewModel.send(.setToast(isPresented: $0)) } - ), - duration: 5, - action: { coordinator.viewModel.send(.undoDeleteWebPage) } - ) { - Label(coordinator.viewModel.state.toastMessage, systemImage: "arrow.uturn.left") - .font(.caption) - .multilineTextAlignment(.center) - } .overlay { if coordinator.viewModel.state.isAppending { LoadingView() diff --git a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift index 1fa8b7ce..cda4cf3e 100644 --- a/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/Home/HomeViewModel.swift @@ -32,9 +32,6 @@ final class HomeViewModel: Store { var alertTitle: String = "" var alertType: AlertType? var alertMessage: String = "" - var showToast: Bool = false - var toastType: ToastType? - var toastMessage: String = "" } enum Action { @@ -42,11 +39,11 @@ final class HomeViewModel: Store { case networkStatusChanged(Bool) case setPresentation(Presentation, Bool) case setAlert(isPresented: Bool, type: AlertType? = nil) - case setToast(isPresented: Bool, type: ToastType? = nil) case refreshWebPages case setLoading(LoadingTarget, Bool) case setWebPageHidden(URL, Bool) case handleWebPageDeleteFailure(URL) + case finishDeleteWebPageToast(String) case tapTodoCategory(TodoCategory) case orderTodoCategory([TodoCategoryItem]) case setTodoCategory([TodoCategoryItem]) @@ -75,10 +72,6 @@ final class HomeViewModel: Store { case error } - enum ToastType { - case deleteWebPage - } - enum ModalType { case todoEditor case urlInputAlert @@ -143,9 +136,9 @@ final class HomeViewModel: Store { switch action { case .networkStatusChanged(let isConnected): state.isNetworkConnected = isConnected - case .fetchData, .setPresentation, .setAlert, .setToast, .refreshWebPages, + case .fetchData, .setPresentation, .setAlert, .refreshWebPages, .tapTodoCategory, .orderTodoCategory, .updateWebPageURLInput, - .addWebPage, .deleteWebPage, .undoDeleteWebPage: + .addWebPage, .deleteWebPage, .undoDeleteWebPage, .finishDeleteWebPageToast: effects = reduceByView(action, state: &state) case .setLoading, .setWebPageHidden, .handleWebPageDeleteFailure, .setTodoCategory, @@ -269,12 +262,6 @@ private extension HomeViewModel { return [.showModalAfterDelay(.urlInputAlert)] } setAlert(&state, isPresented: presented, type: type) - case .setToast(let isPresented, let type): - setToast(&state, isPresented: isPresented, for: type) - if !isPresented { - state.webPages.removeAll { $0.isHidden } - deletedWebPageURLString = nil - } case .tapTodoCategory(let category): state.selectedTodoCategory = category state.showContentPicker = false @@ -294,9 +281,10 @@ private extension HomeViewModel { return [.addWebPage(normalizedURL)] case .deleteWebPage(let page): if let index = state.webPages.firstIndex(where: { $0.id == page.id }) { - deletedWebPageURLString = page.url.absoluteString + let urlString = page.url.absoluteString + deletedWebPageURLString = urlString state.webPages[index].isHidden = true - setToast(&state, isPresented: true, for: .deleteWebPage) + presentDeleteWebPageToast(urlString) return [.deleteWebPage(page)] } case .undoDeleteWebPage: @@ -308,6 +296,11 @@ private extension HomeViewModel { } self.deletedWebPageURLString = nil return [.undoDeleteWebPage(deletedWebPageURLString)] + case .finishDeleteWebPageToast(let urlString): + state.webPages.removeAll { $0.url.absoluteString == urlString && $0.isHidden } + if deletedWebPageURLString == urlString { + deletedWebPageURLString = nil + } default: break } @@ -388,19 +381,20 @@ private extension HomeViewModel { state.alertType = type } - func setToast( - _ state: inout State, - isPresented: Bool, - for type: ToastType? - ) { - switch type { - case .deleteWebPage: - state.toastMessage = String(localized: "common_undo") - case .none: - state.toastMessage = "" - } - state.showToast = isPresented - state.toastType = type + func presentDeleteWebPageToast(_ urlString: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + font: .caption, + multilineTextAlignment: .center, + action: { [weak self] in + self?.send(.undoDeleteWebPage) + }, + onDismiss: { [weak self] in + self?.send(.finishDeleteWebPageToast(urlString)) + } + ) } func setLoading( diff --git a/Application/DevLogPresentation/Sources/Home/TodoListView.swift b/Application/DevLogPresentation/Sources/Home/TodoListView.swift index 8a71efe5..809677ed 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoListView.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoListView.swift @@ -65,16 +65,6 @@ struct TodoListView: View { } 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") - } .navigationTitle(TodoCategoryItem(from: viewModel.category).localizedName) .fullScreenCover(isPresented: Binding( get: { viewModel.state.showEditor }, diff --git a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift b/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift index 9467e77b..e0da16a6 100644 --- a/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift +++ b/Application/DevLogPresentation/Sources/Home/TodoListViewModel.swift @@ -23,8 +23,6 @@ final class TodoListViewModel: Store { var showAllSearchResults: Bool = false var query: TodoQuery var isLoading: Bool = false - var showToast: Bool = false - var toastMessage: String = "" var hasMore: Bool = false } @@ -41,6 +39,7 @@ final class TodoListViewModel: Store { case resetFilters case setIsSearching(Bool) case setShowAllSearchResults(Bool) + case finishDeleteToast(String) case tapToggleCompleted(TodoListItem) case tapTogglePinned(TodoListItem) case undoDelete @@ -49,7 +48,6 @@ final class TodoListViewModel: Store { case onAppear case loadNextPage case setSearchText(String) - case setToast(isPresented: Bool) // Run case applySearchQuery(String) @@ -133,10 +131,10 @@ final class TodoListViewModel: Store { switch action { case .refresh, .setAlert, .setShowEditor, .swipeTodo, .setSortTarget, .setSortOrder, .togglePinnedOnly, .setCompletionFilter, .resetFilters, .setIsSearching, - .setShowAllSearchResults, .tapToggleCompleted, .tapTogglePinned, .undoDelete: + .setShowAllSearchResults, .finishDeleteToast, .tapToggleCompleted, .tapTogglePinned, .undoDelete: effects = reduceByUser(action, state: &state) - case .onAppear, .loadNextPage, .setSearchText, .setToast: + case .onAppear, .loadNextPage, .setSearchText: effects = reduceByView(action, state: &state) case .applySearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, @@ -282,7 +280,7 @@ private extension TodoListViewModel { if state.todos.contains(where: { $0.id == todo.id }) { self.undoTodoId = todo.id setTodoHidden(&state, todoId: todo.id, isHidden: true) - setToast(&state, isPresented: true) + presentDeleteTodoToast(todo.id) return [.delete(todo)] } case .setSortTarget(let target): @@ -324,6 +322,12 @@ private extension TodoListViewModel { setTodoHidden(&state, todoId: undoTodoId, isHidden: false) self.undoTodoId = nil return [.undoDelete(undoTodoId)] + case .finishDeleteToast(let todoId): + state.todos.removeAll { $0.id == todoId && $0.isHidden } + state.searchResults.removeAll { $0.id == todoId && $0.isHidden } + if self.undoTodoId == todoId { + self.undoTodoId = nil + } default: break } @@ -348,13 +352,6 @@ private extension TodoListViewModel { } else { return [.cancelSearch, .debounceSearch(trimmed)] } - case .setToast(let isPresented): - setToast(&state, isPresented: isPresented) - if !isPresented { - state.todos.removeAll { $0.isHidden } - state.searchResults.removeAll { $0.isHidden } - self.undoTodoId = nil - } default: break } @@ -412,12 +409,18 @@ private extension TodoListViewModel { state.showAlert = isPresented } - func setToast( - _ state: inout State, - isPresented: Bool - ) { - state.toastMessage = String(localized: "common_undo") - state.showToast = isPresented + func presentDeleteTodoToast(_ todoId: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + action: { [weak self] in + self?.send(.undoDelete) + }, + onDismiss: { [weak self] in + self?.send(.finishDeleteToast(todoId)) + } + ) } func setTodoHidden( diff --git a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift b/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift index a6214276..a04cee9f 100644 --- a/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift +++ b/Application/DevLogPresentation/Tests/WebPage/DeleteWebPageTests.swift @@ -14,6 +14,8 @@ import DevLogDomain struct DeleteWebPageTests { @Test("웹페이지를 삭제하면 항목이 즉시 숨겨지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") func 웹페이지를_삭제하면_항목이_즉시_숨겨지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { + ToastPresenter.reset() + let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() @@ -55,7 +57,7 @@ struct DeleteWebPageTests { homeViewModel.send(.deleteWebPage(webPageItem)) #expect(homeViewModel.state.webPages.filter { !$0.isHidden }.isEmpty) - #expect(homeViewModel.state.showToast) + #expect(ToastPresenter.item?.message == String(localized: "common_undo")) await waitUntil { deleteWebPageUseCaseSpy.calledUrlStrings == ["https://openai.com"] @@ -66,6 +68,8 @@ struct DeleteWebPageTests { @Test("웹페이지 삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") func 웹페이지_삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { + ToastPresenter.reset() + let fetchTodoCategoryPreferencesUseCaseSpy = FetchTodoCategoryPreferencesUseCaseSpy() let updateTodoCategoryPreferencesUseCaseSpy = UpdateTodoCategoryPreferencesUseCaseSpy() let addWebPageUseCaseSpy = AddWebPageUseCaseSpy() From e20842bf900bc48f50e6ad0e1a7dfb3e85dd7fc3 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:37:16 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20PushNotification=20Account=20to?= =?UTF-8?q?ast=20=ED=91=9C=EC=8B=9C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PushNotificationListView.swift | 12 ------ .../PushNotificationListViewModel.swift | 42 +++++++++++-------- .../Sources/Settings/AccountView.swift | 6 --- .../Sources/Settings/AccountViewModel.swift | 28 +------------ .../DeletePushNotificationTests.swift | 6 ++- 5 files changed, 31 insertions(+), 63 deletions(-) diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift index 74b6cda2..689e29f6 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListView.swift @@ -47,18 +47,6 @@ struct PushNotificationListView: View { } 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: { isCompactLayout ? coordinator.todoIdToPresent : nil }, set: { item in diff --git a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift index a46c630b..1a5e15da 100644 --- a/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift +++ b/Application/DevLogPresentation/Sources/PushNotification/PushNotificationListViewModel.swift @@ -15,10 +15,8 @@ final class PushNotificationListViewModel: Store { struct State: Equatable { var notifications: [PushNotificationItem] = [] var showAlert: Bool = false - var showToast: Bool = false var alertTitle: String = "" var alertMessage: String = "" - var toastMessage: String = "" var isLoading: Bool = false var hasMore: Bool = false var nextCursor: PushNotificationCursor? @@ -33,8 +31,8 @@ final class PushNotificationListViewModel: Store { case deleteNotification(PushNotificationItem) case toggleRead(PushNotificationItem) case undoDelete + case finishDeleteToast(String) case setAlert(isPresented: Bool) - case setToast(isPresented: Bool) case setLoading(Bool) case appendNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?) case resetPagination @@ -98,11 +96,11 @@ final class PushNotificationListViewModel: Store { var effects: [SideEffect] = [] switch action { - case .deleteNotification, .toggleRead, .undoDelete, .setAlert, .toggleSortOption, + case .deleteNotification, .toggleRead, .undoDelete, .finishDeleteToast, .setAlert, .toggleSortOption, .setTimeFilter, .toggleUnreadOnly, .resetFilters, .selectNotification: effects = reduceByUser(action, state: &state) - case .fetchNotifications, .setToast, .loadNextPage: + case .fetchNotifications, .loadNextPage: effects = reduceByView(action, state: &state) case .setLoading, .appendNotifications, .resetPagination, .setHasMore, @@ -187,7 +185,7 @@ private extension PushNotificationListViewModel { if state.notifications.contains(where: { $0.id == item.id }) { self.undoNotificationId = item.id setNotificationHidden(&state, notificationId: item.id, isHidden: true) - setToast(&state, isPresented: true) + presentDeleteNotificationToast(item.id) return [.delete(item)] } return [] @@ -201,6 +199,11 @@ private extension PushNotificationListViewModel { setNotificationHidden(&state, notificationId: undoNotificationId, isHidden: false) self.undoNotificationId = nil return [.undoDelete(undoNotificationId)] + case .finishDeleteToast(let notificationId): + state.notifications.removeAll { $0.id == notificationId && $0.isHidden } + if self.undoNotificationId == notificationId { + self.undoNotificationId = nil + } case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .toggleSortOption: @@ -253,12 +256,6 @@ private extension PushNotificationListViewModel { case .loadNextPage: guard state.hasMore, !state.isLoading else { return [] } return [.fetchNotifications(state.query, cursor: state.nextCursor)] - case .setToast(let isPresented): - setToast(&state, isPresented: isPresented) - if !isPresented { - state.notifications.removeAll { $0.isHidden } - self.undoNotificationId = nil - } default: break } @@ -306,12 +303,21 @@ private extension PushNotificationListViewModel { state.showAlert = isPresented } - func setToast( - _ state: inout State, - isPresented: Bool - ) { - state.toastMessage = String(localized: "common_undo") - state.showToast = isPresented + func presentDeleteNotificationToast(_ notificationId: String) { + ToastPresenter.present( + message: String(localized: "common_undo"), + systemImage: "arrow.uturn.left", + duration: 5, + font: .caption, + multilineTextAlignment: .center, + lineLimit: 3, + action: { [weak self] in + self?.send(.undoDelete) + }, + onDismiss: { [weak self] in + self?.send(.finishDeleteToast(notificationId)) + } + ) } func setNotificationHidden( diff --git a/Application/DevLogPresentation/Sources/Settings/AccountView.swift b/Application/DevLogPresentation/Sources/Settings/AccountView.swift index 87602858..29eb8883 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountView.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountView.swift @@ -63,12 +63,6 @@ struct AccountView: View { } message: { Text(viewModel.state.alertMessage) } - .toast(isPresented: Binding( - get: { viewModel.state.showToast }, - set: { viewModel.send(.setToast(isPresented: $0)) } - )) { - Text(viewModel.state.toastMessage) - } .overlay { if viewModel.state.isLoading { LoadingView() diff --git a/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift b/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift index 134a561d..75729a19 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountViewModel.swift @@ -18,9 +18,6 @@ final class AccountViewModel: Store { var alertTitle: String = "" var alertType: AlertType? var alertMessage: String = "" - var showToast: Bool = false - var toastType: ToastType? - var toastMessage: String = "" var isLoading: Bool = false } @@ -29,7 +26,6 @@ final class AccountViewModel: Store { case linkWithProvider(AuthProvider) case unlinkFromProvider(AuthProvider) case setAlert(isPresented: Bool, type: AlertType? = nil) - case setToast(isPresented: Bool, type: ToastType? = nil) case setLoading(Bool) case updateProviders(currentProvider: AuthProvider?, allProviders: [AuthProvider]) } @@ -47,11 +43,6 @@ final class AccountViewModel: Store { case error } - enum ToastType { - case linkSuccess - case unlinkSuccess - } - private(set) var state: State = .init() private let fetchProvidersUseCase: FetchAuthProvidersUseCase private let linkProviderUseCase: LinkAuthProviderUseCase @@ -81,8 +72,6 @@ final class AccountViewModel: Store { effects = [.unlink(value)] case .setAlert(let presented, let type): setAlert(&state, isPresented: presented, type: type) - case .setToast(let presented, let type): - setToast(&state, isPresented: presented, type: type) case .setLoading(let value): state.isLoading = value case .updateProviders(let currentProvider, let allProviders): @@ -113,7 +102,7 @@ final class AccountViewModel: Store { do { defer { endLoading(.delayed) } try await linkProviderUseCase.execute(provider) - send(.setToast(isPresented: true, type: .linkSuccess)) + ToastPresenter.present(message: String(localized: "account_toast_link_success")) let (currentProvider, allProviders) = try await fetchProvidersUseCase.execute() send(.updateProviders(currentProvider: currentProvider, allProviders: allProviders)) @@ -128,7 +117,7 @@ final class AccountViewModel: Store { do { defer { endLoading(.delayed) } try await unlinkProviderUseCase.execute(provider) - send(.setToast(isPresented: true, type: .unlinkSuccess)) + ToastPresenter.present(message: String(localized: "account_toast_unlink_success")) let (currentProvider, allProviders) = try await fetchProvidersUseCase.execute() send(.updateProviders(currentProvider: currentProvider, allProviders: allProviders)) @@ -180,19 +169,6 @@ private extension AccountViewModel { state.alertType = type } - func setToast(_ state: inout State, isPresented: Bool, type: ToastType?) { - switch type { - case .linkSuccess: - state.toastMessage = String(localized: "account_toast_link_success") - case .unlinkSuccess: - state.toastMessage = String(localized: "account_toast_unlink_success") - case .none: - state.toastMessage = "" - } - state.showToast = isPresented - state.toastType = type - } - private func beginLoading(_ mode: LoadingState.Mode) { loadingState.begin(mode: mode) { [weak self] isLoading in self?.send(.setLoading(isLoading)) diff --git a/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift b/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift index e4e203ea..d18c3a4e 100644 --- a/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift +++ b/Application/DevLogPresentation/Tests/PushNotification/DeletePushNotificationTests.swift @@ -15,6 +15,8 @@ import DevLogDomain struct DeletePushNotificationTests { @Test("삭제하면 항목이 즉시 숨겨지고 되돌리기 토스트가 표시되며 삭제 유스케이스가 호출된다") func 삭제하면_항목이_즉시_숨겨지고_되돌리기_토스트가_표시되며_삭제_유스케이스가_호출된다() async throws { + ToastPresenter.reset() + let fetchPushNotificationsUseCaseSpy = FetchPushNotificationsUseCaseSpy( pushNotificationPage: PushNotificationPage( items: [ @@ -56,7 +58,7 @@ struct DeletePushNotificationTests { pushNotificationListViewModel.send(.deleteNotification(pushNotificationItem)) #expect(pushNotificationListViewModel.state.notifications.filter { !$0.isHidden }.isEmpty) - #expect(pushNotificationListViewModel.state.showToast) + #expect(ToastPresenter.item?.message == String(localized: "common_undo")) await waitUntil { deletePushNotificationUseCaseSpy.calledNotificationIds == ["notification-1"] @@ -67,6 +69,8 @@ struct DeletePushNotificationTests { @Test("삭제를 되돌리면 되돌리기 유스케이스가 호출되고 숨김 상태가 해제된다") func 삭제를_되돌리면_되돌리기_유스케이스가_호출되고_숨김_상태가_해제된다() async throws { + ToastPresenter.reset() + let fetchPushNotificationsUseCaseSpy = FetchPushNotificationsUseCaseSpy( pushNotificationPage: PushNotificationPage( items: [ From 31a032147b9d88f66b020c48a1941a608d258566 Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:37:39 +0900 Subject: [PATCH 6/7] =?UTF-8?q?refactor:=20legacy=20toast=20modifier=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Common/Component/Toast.swift | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/Application/DevLogPresentation/Sources/Common/Component/Toast.swift b/Application/DevLogPresentation/Sources/Common/Component/Toast.swift index 350a4a76..e111ec43 100644 --- a/Application/DevLogPresentation/Sources/Common/Component/Toast.swift +++ b/Application/DevLogPresentation/Sources/Common/Component/Toast.swift @@ -7,29 +7,6 @@ import SwiftUI -extension View { - func toast( - isPresented: Binding, - duration: TimeInterval = 2, - action: (() -> Void)? = nil, - onDismiss: (() -> Void)? = nil, - @ViewBuilder label: @escaping () -> Label - ) -> some View { - self - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay(alignment: .bottom) { - ToastOverlayView( - isPresented: isPresented, - duration: duration, - action: action, - onDismiss: onDismiss, - label: label - ) - .padding(.horizontal, 12) - } - } -} - @Observable final class ToastPresenter { fileprivate static let presenter = ToastPresenter() From b68a59b7c64a837c3ff208fa2c5869d6769e709b Mon Sep 17 00:00:00 2001 From: opficdev <162981733+opficdev@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:59:10 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20MainActor=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DevLogPresentation/Sources/Common/Component/Toast.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Application/DevLogPresentation/Sources/Common/Component/Toast.swift b/Application/DevLogPresentation/Sources/Common/Component/Toast.swift index e111ec43..4af409ec 100644 --- a/Application/DevLogPresentation/Sources/Common/Component/Toast.swift +++ b/Application/DevLogPresentation/Sources/Common/Component/Toast.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor @Observable final class ToastPresenter { fileprivate static let presenter = ToastPresenter()