diff --git a/Dietto/Dietto/Data/Network/NetworkError.swift b/Dietto/Dietto/Data/Network/NetworkError.swift index 8ab133a..68aab1c 100644 --- a/Dietto/Dietto/Data/Network/NetworkError.swift +++ b/Dietto/Dietto/Data/Network/NetworkError.swift @@ -33,14 +33,25 @@ public enum NetworkError: LocalizedError { return "서버 오류가 발생했습니다. (\(code))" case .invalidResponse: return "서버 응답이 유효하지 않습니다." - case .decodingFailed(let error): + case .decodingFailed( _): return "잠시 후 다시 시도해주세요." case .requestCancelled: return "요청이 취소되었습니다." - case .unknown(let error): + case .unknown(let _): return "알 수 없는 에러가 발생했습니다. 관리자에게 문의하세요." case .unacceptableStatusCode: return "에러가 발생했습니다. 잠시후 다시 시도하세요." } } } + +extension NetworkError { + var asToast: ToastEntity { + return ToastEntity( + type: .error, + title: "오류 발생", + message: self.errorDescription ?? "알 수 없는 오류입니다.", + duration: 3 + ) + } +} diff --git a/Dietto/Dietto/Data/Repository/NetworkRepository.swift b/Dietto/Dietto/Data/Repository/NetworkRepository.swift index 336037c..cbe02ba 100644 --- a/Dietto/Dietto/Data/Repository/NetworkRepository.swift +++ b/Dietto/Dietto/Data/Repository/NetworkRepository.swift @@ -7,11 +7,11 @@ import Foundation -//MARK: - Alan Protocol +//MARK: - Alan Interface protocol NetworkRepository { func fetch(promptType: PromptType, rawValues: [Any], outputType: T.Type) async throws -> T} -//MARK: - Alan Protocol Implement +//MARK: - Alan Implement final class NetworkRepositoryImpl: NetworkRepository { private let promptManager: PromptManager diff --git a/Dietto/Dietto/Domain/Entities/ToastEntity.swift b/Dietto/Dietto/Domain/Entities/ToastEntity.swift new file mode 100644 index 0000000..07c986c --- /dev/null +++ b/Dietto/Dietto/Domain/Entities/ToastEntity.swift @@ -0,0 +1,15 @@ +// +// ToastEntity.swift +// Dietto +// +// Created by 안세훈 on 6/4/25. +// + +import Foundation + +struct ToastEntity: Equatable { + var type: ToastStyle + var title: String + var message: String + var duration: Double = 3 +} diff --git a/Dietto/Dietto/Domain/Usecases/AlanUsecase.swift b/Dietto/Dietto/Domain/Usecases/AlanUsecase.swift index e237769..10a9752 100644 --- a/Dietto/Dietto/Domain/Usecases/AlanUsecase.swift +++ b/Dietto/Dietto/Domain/Usecases/AlanUsecase.swift @@ -7,7 +7,7 @@ import Foundation -//MARK: - Usecase +//MARK: - Interface protocol AlanUsecase { func fetchRecommend(ingredients: [IngredientEntity]) async throws -> [RecommendEntity] @@ -26,14 +26,12 @@ final class AlanUsecaseImpl : AlanUsecase { //추천식단 func fetchRecommend(ingredients: [IngredientEntity]) async throws -> [RecommendEntity] { let wrapper = try await repository.fetch(promptType: .recommend, rawValues: ingredients, outputType: RecommendDTO.self) - return wrapper.recommendation } //아티클 func fetchArticle(topics: [InterestEntity]) async throws -> [ArticleEntity] { let wrapper = try await repository.fetch(promptType: .article, rawValues: topics, outputType: ArticleDTO.self) - return wrapper.articles } diff --git a/Dietto/Dietto/Extensions/Font+.swift b/Dietto/Dietto/Extensions/Font+.swift index 9465672..90d6a40 100644 --- a/Dietto/Dietto/Extensions/Font+.swift +++ b/Dietto/Dietto/Extensions/Font+.swift @@ -11,6 +11,7 @@ import SwiftUI extension Font { static let AppLogo: Font = .custom("NerkoOne-regular", size: 32) static let NerkoOne40: Font = .custom("NerkoOne-regular", size: 40) + static let NerkoOne80: Font = .custom("NerkoOne-regular", size: 80) static let pretendardBlack32: Font = .custom("Pretendard-Black", size: 32) static let pretendardBlack28: Font = .custom("Pretendard-Black", size: 28) diff --git a/Dietto/Dietto/Presentation/Article/View/ArticleView.swift b/Dietto/Dietto/Presentation/Article/View/ArticleView.swift index e8e43bd..7229872 100644 --- a/Dietto/Dietto/Presentation/Article/View/ArticleView.swift +++ b/Dietto/Dietto/Presentation/Article/View/ArticleView.swift @@ -37,6 +37,7 @@ struct ArticleView: View { .background(Color.appMain) .clipShape(Capsule()) } + } .padding([.leading, .trailing], 16) Text("내 관심사") diff --git a/Dietto/Dietto/Presentation/Article/ViewModel/ArticleViewModel.swift b/Dietto/Dietto/Presentation/Article/ViewModel/ArticleViewModel.swift index 721aa46..6a2a4d3 100644 --- a/Dietto/Dietto/Presentation/Article/ViewModel/ArticleViewModel.swift +++ b/Dietto/Dietto/Presentation/Article/ViewModel/ArticleViewModel.swift @@ -10,6 +10,7 @@ import SwiftUI final class ArticleViewModel: ObservableObject { @Published var selectedInterests: [InterestEntity] = [] @Published var articles: [ArticleEntity] = [] + @Published var isLoading : Bool = false private let alanUsecase: AlanUsecase private let storageUsecase: InterestsUsecase @@ -64,6 +65,5 @@ final class ArticleViewModel: ObservableObject { addInterest(title) storageUsecase.insertInterests(InterestEntity(title: title)) } - print(selectedInterests) } } diff --git a/Dietto/Dietto/Presentation/Common/ContainerView.swift b/Dietto/Dietto/Presentation/Common/ContainerView.swift index a41ae19..f6f44d3 100644 --- a/Dietto/Dietto/Presentation/Common/ContainerView.swift +++ b/Dietto/Dietto/Presentation/Common/ContainerView.swift @@ -10,7 +10,7 @@ import SwiftUI struct ContainerView: View { var paddingSize: CGFloat var height: CGFloat - let content: () -> Content // ✅ 변수명은 소문자! + let content: () -> Content init(paddingSize: CGFloat, height: CGFloat, @ViewBuilder content: @escaping () -> Content) { self.paddingSize = paddingSize diff --git a/Dietto/Dietto/Presentation/Common/LogoProgress.swift b/Dietto/Dietto/Presentation/Common/LogoProgress.swift new file mode 100644 index 0000000..983e809 --- /dev/null +++ b/Dietto/Dietto/Presentation/Common/LogoProgress.swift @@ -0,0 +1,120 @@ +// +// ProgressViews.swift +// Dietto +// +// Created by 안세훈 on 6/2/25. +// + +import SwiftUI + +struct LogoProgress: View { + @Binding var isAnimated: Bool + var width: CGFloat = 190 + var message : String + + var body: some View { + VStack { + Spacer() + ZStack { + //가려진 뷰 + VStack(spacing: 3) { + Text("Dietto") + .font(.NerkoOne80) + .frame(width: width) + .scaledToFit() + .foregroundStyle(.black.opacity(0.3)) + Text(message) + .font(.pretendardBlack12) + .frame(width: width) + .scaledToFit() + .foregroundStyle(.black.opacity(0.3)) + } + //진행중인 뷰 + VStack(spacing: 3) { + Text("Dietto") + .font(.NerkoOne80) + .frame(width: width) + .scaledToFit() + .foregroundStyle(.appMain) + .mask { + Rectangle() + .frame(width: isAnimated ? width : 0) + .offset(x: isAnimated ? 0 : -width) + } + Text(message) + .font(.pretendardBlack12) + .frame(width: width) + .scaledToFit() + .foregroundStyle(.appMain) + .mask { + Rectangle() + .frame(width: isAnimated ? width : 0) + .offset(x: isAnimated ? 0 : -width) + } + } + } + Spacer() + } + .frame(maxWidth: .infinity) + .background(Color.black.opacity(0.25)) + } +} +//MARK: - ViewModifer + +struct LogoProgressModifier: ViewModifier { + @Binding var isPresented: Bool + @State private var isAnimated = false + @State var message: String = "TEST" + + var speed: Double = 0.3 + var delayTime: Double = 0.3 + + func body(content: Content) -> some View { + ZStack { + content + if isPresented { + Rectangle() + .fill(Color.black.opacity(0.3)) + .ignoresSafeArea() + + LogoProgress(isAnimated: $isAnimated, message: message) + .onAppear { + isAnimated = true + } + .animation( + .easeInOut + .speed(speed) + .delay(delayTime) + .repeatForever(autoreverses: false), + value: isAnimated + ) + } + } + } +} + + +////MARK: - 프리뷰 하려고 임시로 만들어놓은 것. +// +//#Preview { +// AnimatedLoadingPreview() +//} +// +//struct AnimatedLoadingPreview: View { +// @State private var isAnimated = false +// @State var message = "카리나카리나카리나카리나카리나" +// +// var body: some View { +// LogoProgress(isAnimated: $isAnimated, message: message) +// .onAppear { +// withAnimation( +// .easeInOut +// .speed(0.3) +// .delay(0.3) +// .repeatForever(autoreverses: false) +// ) { +// isAnimated = true +// } +// } +// } +//} diff --git a/Dietto/Dietto/Presentation/Common/PillText.swift b/Dietto/Dietto/Presentation/Common/PillText.swift index 0156672..d2d2788 100644 --- a/Dietto/Dietto/Presentation/Common/PillText.swift +++ b/Dietto/Dietto/Presentation/Common/PillText.swift @@ -13,7 +13,7 @@ struct PillText: View { var onDelete: (() -> Void)? = nil // X 버튼 클릭 액션 var body: some View { - HStack() { + HStack(spacing : 2) { Button(action: { onAdd?() }) { @@ -29,11 +29,11 @@ struct PillText: View { Image(systemName: "xmark") .font(.pretendardBold12) .foregroundStyle(.white) - .padding(4) + .padding(2) } .buttonStyle(PlainButtonStyle()) } - .padding(.horizontal, 10) + .padding(.horizontal, 8) .padding(.vertical, 4) .background(Capsule().fill(Color.appMain)) .fixedSize() diff --git a/Dietto/Dietto/Presentation/Common/SubViews.swift b/Dietto/Dietto/Presentation/Common/SubViews.swift new file mode 100644 index 0000000..ec46a78 --- /dev/null +++ b/Dietto/Dietto/Presentation/Common/SubViews.swift @@ -0,0 +1,20 @@ +// +// SubView.swift +// Dietto +// +// Created by 안세훈 on 6/4/25. +// + +import SwiftUI + +extension View { + //MARK: - ProgressView Modifer + func progressOverlay(isPresented: Binding, message: String = "") -> some View { + self.modifier(LogoProgressModifier(isPresented: isPresented, message: message)) + } + + //MARK: - ToastView Modifer + func toastView(toast: Binding) -> some View { + self.modifier(ToastModifier(toast: toast)) + } +} diff --git a/Dietto/Dietto/Presentation/Common/Toast.swift b/Dietto/Dietto/Presentation/Common/Toast.swift new file mode 100644 index 0000000..7e0dffe --- /dev/null +++ b/Dietto/Dietto/Presentation/Common/Toast.swift @@ -0,0 +1,157 @@ +// +// Toast.swift +// Dietto +// +// Created by 안세훈 on 6/3/25. +// + +import SwiftUI + +enum ToastStyle { + case error + case warning + case success + case info +} + +extension ToastStyle { + var themeColor: Color { + switch self { + case .error: return Color.red + case .warning: return Color.orange + case .info: return Color.blue + case .success: return Color.appMain + } + } + + var iconFileName: String { + switch self { + case .info: return "info.circle.fill" + case .warning: return "exclamationmark.triangle.fill" + case .success: return "checkmark.circle.fill" + case .error: return "xmark.circle.fill" + } + } +} + +struct ToastView: View { + var type: ToastStyle + var title: String + var message: String + var onCancelTapped: (() -> Void) + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .top) { + Image(systemName: type.iconFileName) + .foregroundColor(type.themeColor) + + VStack(alignment: .leading) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + + Text(message) + .font(.system(size: 12)) + .foregroundColor(Color.black.opacity(0.6)) + } + + Spacer(minLength: 10) + + Button { + onCancelTapped() + } label: { + Image(systemName: "xmark") + .foregroundColor(Color.black) + } + } + .padding() + } + .background(Color.white) + .overlay( + Rectangle() + .fill(type.themeColor) + .frame(width: 6) + .clipped() + , alignment: .leading + ) + .frame(minWidth: 0, maxWidth: .infinity) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.25), radius: 4, x: 0, y: 1) + .padding(.horizontal, 16) + } +} + +struct ToastModifier: ViewModifier { + @Binding var toast: ToastEntity? + @State private var workItem: DispatchWorkItem? + + func body(content: Content) -> some View { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay( + ZStack { + mainToastView() + .offset(y: -30) + }.animation(.spring(), value: toast) + ) + + .onChange(of: toast){ + showToast() + } + } + + @ViewBuilder func mainToastView() -> some View { + if let toast = toast { + VStack { + Spacer() + ToastView( + type: toast.type, + title: toast.title, + message: toast.message) { + dismissToast() + } + } + .transition(.move(edge: .bottom)) + } + } + + private func showToast() { + guard let toast = toast else { return } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + + if toast.duration > 0 { + workItem?.cancel() + + let task = DispatchWorkItem { + dismissToast() + } + workItem = task + DispatchQueue.main.asyncAfter(deadline: .now() + toast.duration, execute: task) + } + } + + private func dismissToast() { + withAnimation { + toast = nil + } + + workItem?.cancel() + workItem = nil + } +} + + +#Preview { + ToastView(type: .error, title: "에러", message: "에러 메세지") { + print("취소") + } + ToastView(type: .info, title: "정보표시", message: "정보표시 메세지") { + print("취소") + } + ToastView(type: .success, title: "성공", message: "성공 메세지") { + print("취소") + } + ToastView(type: .warning, title: "경고", message: "경고 메세지") { + print("취소") + } +} diff --git a/Dietto/Dietto/Presentation/Dietary/View/DietaryView.swift b/Dietto/Dietto/Presentation/Dietary/View/DietaryView.swift index fce246b..6bda697 100644 --- a/Dietto/Dietto/Presentation/Dietary/View/DietaryView.swift +++ b/Dietto/Dietto/Presentation/Dietary/View/DietaryView.swift @@ -20,10 +20,6 @@ struct DietaryView: View { @State private var PushToRecommandView : Bool = false // 화면이동 - @State private var isLoading : Bool = false - -#warning("상태값 관리.") - var body: some View { NavigationStack{ ZStack{ @@ -57,7 +53,6 @@ struct DietaryView: View { dietartViewModel.addpresentIngredients(newfood) newfood = "" } - } .font(.pretendardBold12) .foregroundStyle(.white) @@ -124,7 +119,6 @@ struct DietaryView: View { if !isFoldMyRefrigerlator{ FlowLayout(spacing: 4, lineSpacing: 3, contentHeight: $myRefrigerlatorflowlayout) { - ForEach(dietartViewModel.pastIngredients) { ingredient in PillText(text: ingredient.ingredient, onAdd:{ @@ -162,9 +156,7 @@ struct DietaryView: View { Button("식단 추천받기") { print("식단 추천 받기 버튼이 클릭댐") if !dietartViewModel.presentIngredients.isEmpty { - isLoading = true dietartViewModel.fetchRecommendations(ingredients: dietartViewModel.presentIngredients) - isLoading = false PushToRecommandView = true }else{ print("비어있음 현재 식재료가 ") @@ -180,23 +172,8 @@ struct DietaryView: View { .padding(.horizontal, 16) .padding(.vertical, 40) - Spacer() - } } - .overlay(content: { - ///loading indicator - if isLoading { - Color.black.opacity(0.4) - .ignoresSafeArea() - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - .foregroundStyle(.white) - .padding() - .background(Color.gray.opacity(0.8)) - - } - }) .navigationDestination(isPresented: $PushToRecommandView) { RecommendView().environmentObject(dietartViewModel) } diff --git a/Dietto/Dietto/Presentation/Dietary/View/RecommendView.swift b/Dietto/Dietto/Presentation/Dietary/View/RecommendView.swift index 4a181c9..76940b6 100644 --- a/Dietto/Dietto/Presentation/Dietary/View/RecommendView.swift +++ b/Dietto/Dietto/Presentation/Dietary/View/RecommendView.swift @@ -80,11 +80,14 @@ struct RecommendView: View { .navigationTitle("추천 식사") .navigationBarTitleDisplayMode(.inline) .font(.pretendardBold16) + .progressOverlay(isPresented: $viewModel.isPresneted,message: "Alan이 식단을 생성하고 있어요 !") //로딩 + .toastView(toast: $viewModel.toast) } } -#Preview { - NavigationStack{ - RecommendView() - } -} +//디버깅용 +//#Preview { +// NavigationStack{ +// RecommendView() +// } +//} diff --git a/Dietto/Dietto/Presentation/Dietary/ViewModel/DietaryViewModel.swift b/Dietto/Dietto/Presentation/Dietary/ViewModel/DietaryViewModel.swift index 5040f5f..d0e7ccb 100644 --- a/Dietto/Dietto/Presentation/Dietary/ViewModel/DietaryViewModel.swift +++ b/Dietto/Dietto/Presentation/Dietary/ViewModel/DietaryViewModel.swift @@ -8,11 +8,9 @@ import SwiftUI class DietaryViewModel: ObservableObject { - - //현재 - @Published var presentIngredients: [IngredientEntity] = [] - - //과거 + @Published var isPresneted : Bool = false //로딩 여부 + @Published var toast: ToastEntity? //toast팝업 + @Published var presentIngredients: [IngredientEntity] = [] //현재 @Published var pastIngredients : [IngredientEntity] = [ IngredientEntity(ingredient: "오징어"), IngredientEntity(ingredient: "꼴뚜기"), @@ -76,19 +74,38 @@ class DietaryViewModel: ObservableObject { //MARK: - 현재 재료를 통해 식단 추천 받기. func fetchRecommendations(ingredients: [IngredientEntity]) { + + isPresneted = true + Task { do { let result = try await usecase.fetchRecommend(ingredients: ingredients) await MainActor.run { self.recommendList = result + self.toast = ToastEntity( + type: .success, + title: "완료", + message: "식단 추천을 완료하였습니다.", + duration: 2 + ) + isPresneted = false + } + } catch let error as NetworkError { + await MainActor.run { + self.toast = error.asToast + isPresneted = false + } + }catch { + await MainActor.run { + self.toast = ToastEntity( + type: .error, + title: "에러", + message: error.localizedDescription + ) + isPresneted = false } - } catch { - print(#file,#function,#line, error.localizedDescription) } } + } - } - -// Thread.isMainThread -// MainActor.preconditionIsolated() //메인엑터 확인. diff --git a/Dietto/Dietto/Presentation/Onboarding/View/TutorialView.swift b/Dietto/Dietto/Presentation/Onboarding/View/TutorialView.swift index c60368f..bc20ecc 100644 --- a/Dietto/Dietto/Presentation/Onboarding/View/TutorialView.swift +++ b/Dietto/Dietto/Presentation/Onboarding/View/TutorialView.swift @@ -13,7 +13,6 @@ struct TutorialView: View { @StateObject var viewModel : OnboardingViewModel @Environment(\.dismiss) private var dismiss - var body: some View { ZStack { Color(.backGround).ignoresSafeArea(edges: .all)