diff --git a/Projects/Core/CommonKit/Sources/AppCoordinator.swift b/Projects/Core/CommonKit/Sources/AppCoordinator.swift index 06ce403..e52c7a4 100644 --- a/Projects/Core/CommonKit/Sources/AppCoordinator.swift +++ b/Projects/Core/CommonKit/Sources/AppCoordinator.swift @@ -21,7 +21,7 @@ public final class AppCoordinator: ObservableObject { //MARK: - Properties public var authState: AuthState = .none - public var userInfo: UserInfo? + @Published public var userInfo: UserInfo? public var needFadeTransition: Bool = false @Published public var navigationStack: [PathType] = [.intro] let authService = AuthService.shared @@ -43,7 +43,6 @@ public final class AppCoordinator: ObservableObject { } } } - startRefreshMyUserInfo() } @MainActor @@ -102,25 +101,17 @@ public final class AppCoordinator: ObservableObject { } } - public func refreshMyUserInfo() async throws { + public func refreshMyUserInfo() async throws -> UserInfo? { if TokenManager.accessToken == nil || TokenManager.accessToken == "" { - return + AuthState.change(.loggedOut) + return nil } let userInfo = try await authService.requestMyUserInfo() await MainActor.run { self.userInfo = userInfo AuthState.change(.login) } - } - - // 20초마다 한번씩 refreshMyUserInfo() 를 호출 - private func startRefreshMyUserInfo() { - Task { - while true { - try? await Task.sleep(for: .seconds(20)) - try? await refreshMyUserInfo() - } - } + return userInfo } public func logout() { diff --git a/Projects/DesignSystem/DesignCore/Sources/Toast/Toast.swift b/Projects/DesignSystem/DesignCore/Sources/Toast/Toast.swift new file mode 100644 index 0000000..826b797 --- /dev/null +++ b/Projects/DesignSystem/DesignCore/Sources/Toast/Toast.swift @@ -0,0 +1,151 @@ +// +// Toast.swift +// DesignCore +// +// Created by 김지수 on 11/17/24. +// Copyright © 2024 com.weave. All rights reserved. +// + +import UIKit +import Toast + +public class ToastHelper { + public enum ToastStyle { + case normal + case error + + var icon: UIImage? { + switch self { + case .normal: return nil + case .error: return DesignCore.Images.alert.uiImage + } + } + + var backgroundColor: UIColor { + switch self { + case .normal: .white + case .error: .init(hex: 0xFFF5F8) + } + } + } + + private static let manager = ToastManager.shared + public static func show( + message: String, + style: ToastStyle = .normal + ) { + manager.showToast(message: message, style: style) + } + + public static func show(_ message: String) { + manager.showToast(message: message, style: .normal) + } + + public static func showErrorMessage( + _ message: String = "에러가 발생했어요 다시 시도해주세요" + ) { + manager.showToast(message: message, style: .error) + } +} + +final class ToastManager { + static let shared = ToastManager() + private var toastWindow: UIWindow? + + private init() {} + + func showToast( + message: String, + style: ToastHelper.ToastStyle = .normal, + duration: TimeInterval = 3.0 + ) { + DispatchQueue.main.async { + self.createToastWindow( + with: message, + style: style + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.5) { + self.clearToast() + } + } + } + + private func createToastWindow( + with message: String, + style: ToastHelper.ToastStyle + ) { + guard toastWindow == nil else { return } + + let toastWindow = UIWindow(frame: UIScreen.main.bounds) + toastWindow.windowLevel = .statusBar + 1 + toastWindow.backgroundColor = .clear + + let containerVC = UIViewController() + containerVC.view.backgroundColor = .clear + + let viewConfig: ToastViewConfiguration = .init( + minHeight: 52, + minWidth: Device.width - 56, + darkBackgroundColor: style.backgroundColor, + lightBackgroundColor: style.backgroundColor, + titleNumberOfLines: 0, + subtitleNumberOfLines: 0, + cornerRadius: 14 + ) + let toastConfig: ToastConfiguration = .init( + direction: .bottom, + dismissBy: [ + .tap, + .swipe(direction: .natural), + .time(time: 3) + ], + enteringAnimation: .default, + exitingAnimation: .default, + allowToastOverlap: false + ) + + let toast: Toast + if let icon = style.icon { + toast = Toast.default( + image: icon, + title: createAttributedTitle(with: message), + viewConfig: viewConfig, + config: toastConfig + ) + } else { + toast = Toast.text( + createAttributedTitle(with: message), + subtitle: nil, + viewConfig: viewConfig, + config: toastConfig + ) + } + + if style == .error { + toast.view.layer.borderWidth = 1 + toast.view.layer.borderColor = UIColor(hex: 0xF2597F) + .withAlphaComponent(0.5) + .cgColor + } + toast.show(haptic: .success) + } + + private func createAttributedTitle(with message: String) -> NSAttributedString { + let attributes: [NSAttributedString.Key: Any] = [ + .font: UIFont.pretendard(._500, size: 16), + .foregroundColor: UIColor(DesignCore.Colors.grey400), + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }() + ] + + return NSAttributedString(string: message, attributes: attributes) + } + + private func clearToast() { + self.toastWindow = nil + } +} diff --git a/Projects/DesignSystem/DesignCore/Sources/Toast/ToastView.swift b/Projects/DesignSystem/DesignCore/Sources/Toast/ToastView.swift index 4ce0045..11f99f3 100644 --- a/Projects/DesignSystem/DesignCore/Sources/Toast/ToastView.swift +++ b/Projects/DesignSystem/DesignCore/Sources/Toast/ToastView.swift @@ -19,7 +19,7 @@ struct ToastViewModifier: ViewModifier { ZStack { VStack { Spacer() - Toast( + ToastView( message: message, isPresent: $isPresented ) @@ -46,7 +46,7 @@ extension View { } } -struct Toast: View { +struct ToastView: View { let message: String @Binding var isPresent: Bool diff --git a/Projects/DesignSystem/Project.swift b/Projects/DesignSystem/Project.swift index 293ad2e..fd296be 100644 --- a/Projects/DesignSystem/Project.swift +++ b/Projects/DesignSystem/Project.swift @@ -14,7 +14,8 @@ let project: Project = .make( product: .framework, useResource: true, dependencies: [ - .external(.nuke) + .external(.nuke), + .external(.toast) ] ), .makeUnitTest( diff --git a/Projects/Features/Home/Sources/HomeMain/HomeMainIntent.swift b/Projects/Features/Home/Sources/HomeMain/HomeMainIntent.swift index 4a6fb33..75affe4 100644 --- a/Projects/Features/Home/Sources/HomeMain/HomeMainIntent.swift +++ b/Projects/Features/Home/Sources/HomeMain/HomeMainIntent.swift @@ -11,6 +11,7 @@ import CommonKit import CoreKit import Model import NetworkKit +import DesignCore //MARK: - Intent class HomeMainIntent { @@ -68,5 +69,7 @@ extension HomeMainIntent: HomeMainIntent.Intentable { func task() async {} // content - func onTapNextButton() {} + func onTapNextButton() { + ToastHelper.show(message: "토스트얍") + } } diff --git a/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift b/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift index 24937f7..fcfbb61 100644 --- a/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift +++ b/Projects/Features/Home/Sources/HomeMain/HomeMainView.swift @@ -64,6 +64,12 @@ public struct HomeMainView: View { // 첫 번째 탭 내용 VStack { Text("첫 번째 탭") + Button { + intent.onTapNextButton() + } label: { + Text("토스트!") + } + } .tag(HomeMainTab.home) diff --git a/Projects/Features/Home/Sources/Profile/ProfileIntent.swift b/Projects/Features/Home/Sources/Profile/ProfileIntent.swift index 6d4cb3a..dc36f1a 100644 --- a/Projects/Features/Home/Sources/Profile/ProfileIntent.swift +++ b/Projects/Features/Home/Sources/Profile/ProfileIntent.swift @@ -11,6 +11,7 @@ import CommonKit import CoreKit import Model import NetworkKit +import DesignCore //MARK: - Intent class ProfileIntent { @@ -40,6 +41,7 @@ extension ProfileIntent { func deleteWidget(_ widget: ProfileWidget) async func onTapNextButton() + func refreshUserInfo() async func fetchUserInfo(_ userInfo: UserInfo) // default @@ -73,11 +75,16 @@ extension ProfileIntent: ProfileIntent.Intentable { do { model?.setLoading(status: true) try await requestDeleteWidget(widget) - try await AppCoordinator.shared.refreshMyUserInfo() + await refreshUserInfo() model?.setLoading(status: false) + try await Task.sleep(for: .milliseconds(500)) + ToastHelper.show("위젯이 삭제되었어요") } catch { print(error) model?.setLoading(status: false) + DispatchQueue.main.asyncAfter(wallDeadline: .now() + 1.0) { + ToastHelper.showErrorMessage() + } } } @@ -85,6 +92,12 @@ extension ProfileIntent: ProfileIntent.Intentable { fetchUserInfo(input.userInfo) } + func refreshUserInfo() async { + if let userInfo = try? await AppCoordinator.shared.refreshMyUserInfo() { + fetchUserInfo(userInfo) + } + } + func fetchUserInfo(_ userInfo: UserInfo) { model?.setUserInfo(userInfo) } @@ -95,6 +108,7 @@ extension ProfileIntent: ProfileIntent.Intentable { func onTapNextButton() {} func requestDeleteWidget(_ widget: ProfileWidget) async throws { + throw NSError(domain: "33", code: 33) try await profileService.requestDeleteProfileWidget(widgetType: widget.widgetType.toDto) } } diff --git a/Projects/Features/Home/Sources/Profile/ProfileView.swift b/Projects/Features/Home/Sources/Profile/ProfileView.swift index 7ca4ac1..840caac 100644 --- a/Projects/Features/Home/Sources/Profile/ProfileView.swift +++ b/Projects/Features/Home/Sources/Profile/ProfileView.swift @@ -15,7 +15,6 @@ import Model public struct ProfileView: View { @StateObject var container: MVIContainer -// @State var isPresentWidgetSelectionView = false private var intent: ProfileIntent.Intentable { container.intent } private var state: ProfileModel.Stateful { container.model } @@ -112,7 +111,7 @@ public struct ProfileView: View { } } else { LazyVGrid(columns: columns, spacing: 16) { - ForEach(userInfo.profileWidgets, id: \.self) { widget in + ForEach(userInfo.profileWidgets, id: \.widgetType.toDto) { widget in ZStack { RoundedRectangle(cornerRadius: 24) .fill(.white) @@ -187,33 +186,19 @@ public struct ProfileView: View { ProgressView() } } - .onChange(of: state.isPresentedAddWidgetModal) { - if !state.isPresentedAddWidgetModal { - if let userInfo = AppCoordinator.shared.userInfo { - intent.fetchUserInfo(userInfo) - } - } - } - .onChange(of: state.isPresentedModifyWidgetView) { - if !state.isPresentedModifyWidgetView { - if let userInfo = AppCoordinator.shared.userInfo { - intent.fetchUserInfo(userInfo) - } - } - } - .onChange(of: state.isPresentedDeleteConfirmSheet) { - if !state.isPresentedDeleteConfirmSheet { - if let userInfo = AppCoordinator.shared.userInfo { - intent.fetchUserInfo(userInfo) - } - } - } .sheet( isPresented: $container.model.isPresentedAddWidgetModal, content: { NavigationStack { WidgetSelectionView( - isPresented: $container.model.isPresentedAddWidgetModal + isPresented: $container.model.isPresentedAddWidgetModal, + successHandler: { + Task { + await intent.refreshUserInfo() + try await Task.sleep(for: .seconds(1)) + ToastHelper.show("위젯이 추가되었어요") + } + } ) } }) @@ -227,7 +212,14 @@ public struct ProfileView: View { isModalPresented: $container.model.isPresentedModifyWidgetView, isPushed: .constant(false), isEditing: true, - contentString: widget.content + contentString: widget.content, + successHandler: { + Task { + await intent.refreshUserInfo() + try await Task.sleep(for: .seconds(1)) + ToastHelper.show("위젯이 수정되었어요") + } + } ) } } @@ -239,10 +231,10 @@ public struct ProfileView: View { if let widget = state.selectedWidgetType { DeleteWidgetConfirmView { Task { - await intent.deleteWidget(widget) await MainActor.run { container.model.isPresentedDeleteConfirmSheet = false } + await intent.deleteWidget(widget) } } cancelHandler: { container.model.isPresentedDeleteConfirmSheet = false @@ -302,15 +294,3 @@ fileprivate struct DeleteWidgetConfirmView: View { .padding(.vertical, 30) } } - -#Preview { - DeleteWidgetConfirmView { - - } cancelHandler: { - - } - -// NavigationView { -// HomeMainView(userInfo: .mock) -// } -} diff --git a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift index 9308815..b2bcc9c 100644 --- a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift +++ b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionIntent.swift @@ -23,6 +23,7 @@ class WidgetSelectionIntent { ) { self.input = input self.model = model + self.model?.setSuccessHandler(handler: input.successHandler) } } @@ -38,7 +39,9 @@ extension WidgetSelectionIntent { func task() async } - struct DataModel {} + struct DataModel { + let successHandler: (() -> Void)? + } } //MARK: - Intentable diff --git a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift index 4742cf2..0ddc2c6 100644 --- a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift +++ b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionModel.swift @@ -20,6 +20,7 @@ final class WidgetSelectionModel: ObservableObject { var isPushWriteContentView: Bool { get set } var isValidated: Bool { get } var selectedWidget: WidgetType? { get } + var successHandler: (() -> Void)? { get } // default var isLoading: Bool { get } @@ -35,6 +36,7 @@ final class WidgetSelectionModel: ObservableObject { @Published var isPushWriteContentView: Bool = false @Published var isValidated: Bool = false @Published var selectedWidget: WidgetType? + var successHandler: (() -> Void)? // default @Published var isLoading: Bool = false @@ -53,6 +55,7 @@ protocol WidgetSelectionModelActionable: AnyObject { func setPushWriteContentView(status: Bool) func setValidation(value: Bool) func setSelectedWidget(widget: WidgetType) + func setSuccessHandler(handler: (() -> Void)?) // default func setLoading(status: Bool) @@ -77,6 +80,9 @@ extension WidgetSelectionModel: WidgetSelectionModelActionable { func setValidation(value: Bool) { isValidated = value } + func setSuccessHandler(handler: (() -> Void)?) { + successHandler = handler + } // default func setLoading(status: Bool) { diff --git a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift index 59ed089..7c61eea 100644 --- a/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift +++ b/Projects/Features/Home/Sources/Widget/WidgetSelection/WidgetSelectionView.swift @@ -13,9 +13,6 @@ import CommonKit import Model public struct WidgetSelectionView: View { - -// @State private var isPushedWriteView: Bool = false - @Binding var isPresentedSelectionView: Bool @StateObject var container: MVIContainer @@ -27,11 +24,14 @@ public struct WidgetSelectionView: View { GridItem(.flexible(), spacing: 16) ] - public init(isPresented: Binding) { + public init( + isPresented: Binding, + successHandler: (() -> Void)? + ) { let model = WidgetSelectionModel() let intent = WidgetSelectionIntent( model: model, - input: .init() + input: .init(successHandler: successHandler) ) let container = MVIContainer( intent: intent as WidgetSelectionIntent.Intentable, @@ -80,7 +80,8 @@ public struct WidgetSelectionView: View { WidgetWritingView( widgetType: widget, isModalPresented: $container.model.isModalPresented, - isPushed: $container.model.isPushWriteContentView + isPushed: $container.model.isPushWriteContentView, + successHandler: state.successHandler ) } } @@ -111,6 +112,8 @@ public struct WidgetSelectionView: View { #Preview { NavigationStack { - WidgetSelectionView(isPresented: .constant(true)) + WidgetSelectionView(isPresented: .constant(true)) { + + } } } diff --git a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift index d5d17e9..d21bbc0 100644 --- a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift +++ b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingIntent.swift @@ -11,6 +11,7 @@ import CommonKit import CoreKit import Model import NetworkKit +import DesignCore //MARK: - Intent class WidgetWritingIntent { @@ -28,6 +29,7 @@ class WidgetWritingIntent { self.model = model self.service = service model.setWidgetType(input.widgetType) + model.setSuccessHandler(handler: input.successHandler) if let contentString = input.content { model.setContentString(contentString) } @@ -51,6 +53,7 @@ extension WidgetWritingIntent { struct DataModel { let widgetType: WidgetType let content: String? + let successHandler: (() -> Void)? } } @@ -84,9 +87,9 @@ extension WidgetWritingIntent: WidgetWritingIntent.Intentable { widget: selectedWidget, content: state.widgetBodyText ) - try await AppCoordinator.shared.refreshMyUserInfo() model?.setLoading(status: false) model?.modalDismiss() + model?.doSuccessAction() } catch { // TODO: 에러처리 model?.setLoading(status: false) diff --git a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift index 063a4f9..6e61d9d 100644 --- a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift +++ b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingModel.swift @@ -26,6 +26,8 @@ final class WidgetWritingModel: ObservableObject { var isFocused: Bool { get } var isCTAButtonEnabled: Bool { get } + var successHandler: (() -> Void)? { get } + // default var isLoading: Bool { get } @@ -41,6 +43,7 @@ final class WidgetWritingModel: ObservableObject { @Published var isPushedWriteContentView: Bool = true @Published var isModalPresented: Bool = true @Published var isFocused: Bool = false + var successHandler: (() -> Void)? var textMaxCount: Int = 40 var isCTAButtonEnabled: Bool { @@ -67,6 +70,8 @@ protocol WidgetWritingModelActionable: AnyObject { func setValidation(value: Bool) func setWidgetType(_ widget: WidgetType) func setContentString(_ content: String) + func setSuccessHandler(handler: (() -> Void)?) + func doSuccessAction() func navigationPop() func modalDismiss() @@ -93,6 +98,12 @@ extension WidgetWritingModel: WidgetWritingModelActionable { func setWidgetType(_ widget: WidgetType) { selectedWidgetType = widget } + func setSuccessHandler(handler: (() -> Void)?) { + successHandler = handler + } + func doSuccessAction() { + successHandler?() + } func navigationPop() { isPushedWriteContentView = false } diff --git a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift index f271219..fc515c2 100644 --- a/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift +++ b/Projects/Features/Home/Sources/Widget/WidgetWriting/WidgetWritingView.swift @@ -42,12 +42,17 @@ public struct WidgetWritingView: View { isModalPresented: Binding, isPushed: Binding, isEditing: Bool = false, - contentString: String? = nil + contentString: String? = nil, + successHandler: (() -> Void)? ) { let model = WidgetWritingModel() let intent = WidgetWritingIntent( model: model, - input: .init(widgetType: widgetType, content: contentString) + input: .init( + widgetType: widgetType, + content: contentString, + successHandler: successHandler + ) ) let container = MVIContainer( intent: intent as WidgetWritingIntent.Intentable, @@ -145,6 +150,8 @@ public struct WidgetWritingView: View { widgetType: .body, isModalPresented: .constant(false), isPushed: .constant(false) - ) + ) { + + } } } diff --git a/Projects/Features/Home/UnitTest/WidgetUnitTest.swift b/Projects/Features/Home/UnitTest/WidgetUnitTest.swift index 0f2e4fc..461749c 100644 --- a/Projects/Features/Home/UnitTest/WidgetUnitTest.swift +++ b/Projects/Features/Home/UnitTest/WidgetUnitTest.swift @@ -22,7 +22,10 @@ struct WidgetUnitTest { self.selectionState = WidgetSelectionModel() self.selectionIntent = WidgetSelectionIntent( model: selectionState, - input: .init() + input: .init( + successHandler: { + } + ) ) self.writeState = WidgetWritingModel() diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 7e54995..393f73a 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -62,6 +62,15 @@ "revision" : "76ae9c621422a1f38c30a07c8b7636994b669484", "version" : "0.14.0" } + }, + { + "identity" : "toast-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/BastiaanJansen/toast-swift.git", + "state" : { + "revision" : "9a40b722b6eeb9683d47e3584501e81525741291", + "version" : "2.1.2" + } } ], "version" : 2 diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 357bea0..7014e0e 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -38,6 +38,10 @@ let package = Package( .package( url: "https://github.com/davdroman/swiftui-navigation-transitions.git", exact: "0.14.0" + ), + .package( + url: "https://github.com/BastiaanJansen/toast-swift.git", + exact: "2.1.2" ) ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Dependency+extensions.swift b/Tuist/ProjectDescriptionHelpers/Dependency+extensions.swift index a13c728..3814797 100644 --- a/Tuist/ProjectDescriptionHelpers/Dependency+extensions.swift +++ b/Tuist/ProjectDescriptionHelpers/Dependency+extensions.swift @@ -11,6 +11,7 @@ public enum ExternalDependency: String { case nuke = "Nuke" case openapiGenerated = "OpenapiGenerated" case navigationTransitions = "NavigationTransitions" + case toast = "Toast" var name: String { return self.rawValue