From 8e8eca0f732fba3c58b2b0aded8ad0112698fb5b Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 14:21:48 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20AIModelMetadata=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 1 + .../Source/Domain/Entities/AIModelMetadata.swift | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Domain/Entities/AIModelMetadata.swift diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 5bcd060..6214e1e 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Source/Domain/Entities/AIModelMetadata.swift, Source/Domain/Entities/CalendarDay.swift, Source/Domain/Entities/Category.swift, Source/Domain/Entities/Expense.swift, diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AIModelMetadata.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AIModelMetadata.swift new file mode 100644 index 0000000..b7f9ded --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AIModelMetadata.swift @@ -0,0 +1,15 @@ +// +// AIModelMetadata.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +struct AIModelMetadata { + let id: String + let dataCount: Int + let accuracy: Float + let createdAt: Date +} From 1ddbe11ad9ffb50cf8b0d8eb70f6bfa106fd5063 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 14:27:46 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20AI=20UseCase,=20Repository=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 3 ++ .../Data/Repositories/MockAIRepository.swift | 47 +++++++++++++++++++ .../Repositories/AIRepositoryProtocol.swift | 17 +++++++ .../Source/Domain/UseCases/AIUseCase.swift | 33 +++++++++++++ .../Domain/UseCases/AIUseCaseProtocol.swift | 17 +++++++ 5 files changed, 117 insertions(+) create mode 100644 SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift create mode 100644 SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift create mode 100644 SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift create mode 100644 SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 6214e1e..7629968 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -36,8 +36,11 @@ Source/Domain/Entities/CalendarDay.swift, Source/Domain/Entities/Category.swift, Source/Domain/Entities/Expense.swift, + Source/Domain/Repositories/AIRepositoryProtocol.swift, Source/Domain/Repositories/CategoryRepositoryProtocol.swift, Source/Domain/Repositories/ExpenseRepositoryProtocol.swift, + Source/Domain/UseCases/AIUseCase.swift, + Source/Domain/UseCases/AIUseCaseProtocol.swift, Source/Domain/UseCases/CategoryUseCase.swift, Source/Domain/UseCases/CategoryUseCaseProtocol.swift, Source/Domain/UseCases/ExpenseUseCase.swift, diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift new file mode 100644 index 0000000..dff55c6 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift @@ -0,0 +1,47 @@ +// +// MockAIRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class MockAIRepository: AIRepositoryProtocol { + + private let models: [AIModelMetadata] = [ + AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()), + AIModelMetadata(id: "SP260614", dataCount: 980, accuracy: 76, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), + AIModelMetadata(id: "SP260514", dataCount: 750, accuracy: 71, createdAt: Calendar.current.date(byAdding: .month, value: -2, to: Date()) ?? Date()), + ] + + func fetchModels() async -> [AIModelMetadata] { + models + } + + func fetchCurrentModel() async -> AIModelMetadata? { + models.first + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + [ + 1: 15000, 2: 18000, 3: 12000, 4: 22000, 5: 19000, + 6: 8000, 7: 25000, 8: 17000, 9: 21000, 10: 14000, + 11: 19000, 12: 23000, 13: 16000, 14: 20000, 15: 18000, + 16: 22000, 17: 15000, 18: 19000, 19: 24000, 20: 17000, + 21: 21000, 22: 18000, 23: 16000, 24: 22000, 25: 20000, + 26: 15000, 27: 19000, 28: 23000, 29: 17000, 30: 21000, + 31: 18000, + ] + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + [ + "식비": 280000, + "카페/간식": 45000, + "교통": 60000, + "쇼핑": 200000, + "의료/건강": 30000, + ] + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift new file mode 100644 index 0000000..237b939 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// AIRepositoryProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol AIRepositoryProtocol { + /// 저장된 모든 AI 모델 메타데이터 목록 + func fetchModels() async -> [AIModelMetadata] + /// 현재 사용 중인 AI 모델 메타데이터 + func fetchCurrentModel() async -> AIModelMetadata? + /// 이번 달 일별 예측 금액 [일: 예측금액] + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] + /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift new file mode 100644 index 0000000..620c3ee --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift @@ -0,0 +1,33 @@ +// +// AIUseCase.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class AIUseCase: AIUseCaseProtocol { + + private let repository: AIRepositoryProtocol + + init(repository: AIRepositoryProtocol) { + self.repository = repository + } + + func fetchModels() async -> [AIModelMetadata] { + await repository.fetchModels() + } + + func fetchCurrentModel() async -> AIModelMetadata? { + await repository.fetchCurrentModel() + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + await repository.fetchDailyPredictions(year: year, month: month) + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + await repository.fetchCategoryPredictions(year: year, month: month) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift new file mode 100644 index 0000000..564d4bb --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -0,0 +1,17 @@ +// +// AIUseCaseProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol AIUseCaseProtocol { + /// 저장된 모든 AI 모델 메타데이터 목록 + func fetchModels() async -> [AIModelMetadata] + /// 현재 사용 중인 AI 모델 메타데이터 + func fetchCurrentModel() async -> AIModelMetadata? + /// 이번 달 일별 예측 금액 [일: 예측금액] + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] + /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] +} From 3791f652bf885365d93f791a3ca4fa43a31ec425 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 14:42:35 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20AIViewModel=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=B0=94=EC=9D=B8=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/App/SceneDelegate.swift | 7 +- .../Source/Presentation/View/AI/AIView.swift | 141 ++---------------- .../Presentation/View/AI/AIViewModel.swift | 105 +++++++++++++ 3 files changed, 124 insertions(+), 129 deletions(-) create mode 100644 SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift index c5d096a..1fc71d1 100644 --- a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -36,7 +36,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let categoryRepository = SwiftDataCategoryRepository(modelContext: modelContext) let categoryUseCase = CategoryUseCase(repository: categoryRepository) - let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository) + let expenseRepository = MockExpenseRepository() +// let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository) let expenseUseCase = ExpenseUseCase(repository: expenseRepository) let homeViewModel = HomeViewModel(expenseUseCase: expenseUseCase) @@ -47,7 +48,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { selectedImage: UIImage(systemName: "house.fill") ) - let aiViewController = UIHostingController(rootView: AIView()) + let aiUseCase = AIUseCase(repository: MockAIRepository()) + let aiViewModel = AIViewModel(expenseUseCase: expenseUseCase, aiUseCase: aiUseCase) + let aiViewController = UIHostingController(rootView: AIView(viewModel: aiViewModel)) aiViewController.tabBarItem = UITabBarItem( title: "예측", image: UIImage(systemName: "brain"), diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 553b1e6..ba99560 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -9,6 +9,8 @@ import SwiftUI struct AIView: View { + @State var viewModel: AIViewModel + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { @@ -23,144 +25,29 @@ struct AIView: View { .offset(y: -5) AIStatusCardView( - modelId: "CAT13A", - dataCount: 1204, - accuracy: 82, + modelId: viewModel.currentModel?.id ?? "-", + dataCount: viewModel.currentModel?.dataCount ?? 0, + accuracy: viewModel.currentModel?.accuracy ?? 0 ) } - AIInsightCardView(items: [ - AIInsightItemData(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"), - AIInsightItemData(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"), - AIInsightItemData(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"), - ]) - -// AIPredictionCardView( -// data: [ -// PredictionDataPoint(day: 1, actual: 12000, predicted: nil), -// PredictionDataPoint(day: 2, actual: 28000, predicted: nil), -// PredictionDataPoint(day: 3, actual: 45000, predicted: nil), -// PredictionDataPoint(day: 4, actual: 67000, predicted: nil), -// PredictionDataPoint(day: 5, actual: 82000, predicted: nil), -// PredictionDataPoint(day: 6, actual: 95000, predicted: nil), -// PredictionDataPoint(day: 7, actual: 110000, predicted: nil), -// PredictionDataPoint(day: 8, actual: 125000, predicted: nil), -// PredictionDataPoint(day: 9, actual: 138000, predicted: nil), -// PredictionDataPoint(day: 10, actual: 152000, predicted: nil), -// PredictionDataPoint(day: 11, actual: 170000, predicted: nil), -// PredictionDataPoint(day: 12, actual: 185000, predicted: nil), -// PredictionDataPoint(day: 13, actual: 198000, predicted: nil), -// PredictionDataPoint(day: 14, actual: 215000, predicted: nil), -// PredictionDataPoint(day: 15, actual: nil, predicted: nil), -// PredictionDataPoint(day: 16, actual: nil, predicted: nil), -// PredictionDataPoint(day: 17, actual: nil, predicted: nil), -// PredictionDataPoint(day: 18, actual: nil, predicted: nil), -// PredictionDataPoint(day: 19, actual: nil, predicted: nil), -// PredictionDataPoint(day: 20, actual: nil, predicted: nil), -// PredictionDataPoint(day: 21, actual: nil, predicted: nil), -// PredictionDataPoint(day: 22, actual: nil, predicted: nil), -// PredictionDataPoint(day: 23, actual: nil, predicted: nil), -// PredictionDataPoint(day: 24, actual: nil, predicted: nil), -// PredictionDataPoint(day: 25, actual: nil, predicted: nil), -// PredictionDataPoint(day: 26, actual: nil, predicted: nil), -// PredictionDataPoint(day: 27, actual: nil, predicted: nil), -// PredictionDataPoint(day: 28, actual: nil, predicted: nil), -// PredictionDataPoint(day: 29, actual: nil, predicted: nil), -// PredictionDataPoint(day: 30, actual: nil, predicted: nil), -// PredictionDataPoint(day: 31, actual: nil, predicted: nil), -// ], -// today: 14, -// lastDay: 31 -// ) -// -// AICategoryPredictionCardView(data: [ -// CategoryPredictionDataPoint(categoryName: "식비", actual: 320000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "교통", actual: 54000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "카페", actual: 87000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "쇼핑", actual: 152000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "구독", actual: 29000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "의료", actual: 45000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "운동", actual: 62000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "도서", actual: 18000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "영화", actual: 24000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "여행", actual: 0, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "숙박", actual: 0, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "미용", actual: 35000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "반려동물", actual: 55000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "세금", actual: 120000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "보험", actual: 80000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "통신", actual: 55000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "게임", actual: 12000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "음악", actual: 9000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "편의점", actual: 43000, predicted: nil), -// CategoryPredictionDataPoint(categoryName: "기타", actual: 27000, predicted: nil), -// ].sorted { $0.actual > $1.actual }) + AIInsightCardView(items: []) AIPredictionCardView( - data: [ - PredictionDataPoint(day: 1, actual: 12000, predicted: 15000), - PredictionDataPoint(day: 2, actual: 28000, predicted: 30000), - PredictionDataPoint(day: 3, actual: 45000, predicted: 45000), - PredictionDataPoint(day: 4, actual: 67000, predicted: 60000), - PredictionDataPoint(day: 5, actual: 82000, predicted: 75000), - PredictionDataPoint(day: 6, actual: 95000, predicted: 90000), - PredictionDataPoint(day: 7, actual: 110000, predicted: 105000), - PredictionDataPoint(day: 8, actual: 125000, predicted: 120000), - PredictionDataPoint(day: 9, actual: 138000, predicted: 135000), - PredictionDataPoint(day: 10, actual: 152000, predicted: 150000), - PredictionDataPoint(day: 11, actual: 170000, predicted: 165000), - PredictionDataPoint(day: 12, actual: 185000, predicted: 180000), - PredictionDataPoint(day: 13, actual: 198000, predicted: 195000), - PredictionDataPoint(day: 14, actual: 215000, predicted: 210000), - PredictionDataPoint(day: 15, actual: nil, predicted: 225000), - PredictionDataPoint(day: 16, actual: nil, predicted: 240000), - PredictionDataPoint(day: 17, actual: nil, predicted: 255000), - PredictionDataPoint(day: 18, actual: nil, predicted: 270000), - PredictionDataPoint(day: 19, actual: nil, predicted: 285000), - PredictionDataPoint(day: 20, actual: nil, predicted: 300000), - PredictionDataPoint(day: 21, actual: nil, predicted: 315000), - PredictionDataPoint(day: 22, actual: nil, predicted: 330000), - PredictionDataPoint(day: 23, actual: nil, predicted: 345000), - PredictionDataPoint(day: 24, actual: nil, predicted: 360000), - PredictionDataPoint(day: 25, actual: nil, predicted: 375000), - PredictionDataPoint(day: 26, actual: nil, predicted: 390000), - PredictionDataPoint(day: 27, actual: nil, predicted: 405000), - PredictionDataPoint(day: 28, actual: nil, predicted: 420000), - PredictionDataPoint(day: 29, actual: nil, predicted: 435000), - PredictionDataPoint(day: 30, actual: nil, predicted: 450000), - PredictionDataPoint(day: 31, actual: nil, predicted: 465000), - ], - today: 14, - lastDay: 31 + data: viewModel.predictionData, + today: viewModel.today, + lastDay: viewModel.lastDay ) - AICategoryPredictionCardView(data: [ - CategoryPredictionDataPoint(categoryName: "식비", actual: 320000, predicted: 280000), - CategoryPredictionDataPoint(categoryName: "교통", actual: 54000, predicted: 60000), - CategoryPredictionDataPoint(categoryName: "카페", actual: 87000, predicted: 45000), - CategoryPredictionDataPoint(categoryName: "쇼핑", actual: 152000, predicted: 200000), - CategoryPredictionDataPoint(categoryName: "구독", actual: 29000, predicted: 29000), - CategoryPredictionDataPoint(categoryName: "의료", actual: 45000, predicted: 30000), - CategoryPredictionDataPoint(categoryName: "운동", actual: 62000, predicted: 70000), - CategoryPredictionDataPoint(categoryName: "도서", actual: 18000, predicted: 25000), - CategoryPredictionDataPoint(categoryName: "영화", actual: 24000, predicted: 20000), - CategoryPredictionDataPoint(categoryName: "여행", actual: 0, predicted: 150000), - CategoryPredictionDataPoint(categoryName: "숙박", actual: 0, predicted: 80000), - CategoryPredictionDataPoint(categoryName: "미용", actual: 35000, predicted: 40000), - CategoryPredictionDataPoint(categoryName: "반려동물", actual: 55000, predicted: 50000), - CategoryPredictionDataPoint(categoryName: "세금", actual: 120000, predicted: 120000), - CategoryPredictionDataPoint(categoryName: "보험", actual: 80000, predicted: 80000), - CategoryPredictionDataPoint(categoryName: "통신", actual: 55000, predicted: 55000), - CategoryPredictionDataPoint(categoryName: "게임", actual: 12000, predicted: 15000), - CategoryPredictionDataPoint(categoryName: "음악", actual: 9000, predicted: 9000), - CategoryPredictionDataPoint(categoryName: "편의점", actual: 43000, predicted: 35000), - CategoryPredictionDataPoint(categoryName: "기타", actual: 27000, predicted: 0), - ].sorted { $0.actual > $1.actual }) - .padding(.bottom, 20) + AICategoryPredictionCardView(data: viewModel.categoryData) + .padding(.bottom, 20) } .padding(.horizontal, 20) } .background(Color(UIColor.DesignSystem.background)) .scrollIndicators(.hidden) + .onAppear { + viewModel.onAppear() + } } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift new file mode 100644 index 0000000..d506cbd --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -0,0 +1,105 @@ +// +// AIViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +@Observable +final class AIViewModel { + + // MARK: - Output + private(set) var currentModel: AIModelMetadata? = nil + private(set) var models: [AIModelMetadata] = [] + private(set) var predictionData: [PredictionDataPoint] = [] + private(set) var categoryData: [CategoryPredictionDataPoint] = [] + private(set) var today: Int = Calendar.current.component(.day, from: Date()) + private(set) var lastDay: Int = { + let calendar = Calendar.current + let range = calendar.range(of: .day, in: .month, for: Date()) + return range?.count ?? 30 + }() + + private let expenseUseCase: ExpenseUseCaseProtocol + private let aiUseCase: AIUseCaseProtocol + + // MARK: - Init + init( + expenseUseCase: ExpenseUseCaseProtocol, + aiUseCase: AIUseCaseProtocol + ) { + self.expenseUseCase = expenseUseCase + self.aiUseCase = aiUseCase + } + + // MARK: - Input + func onAppear() { + Task { + await load() + } + } + + // MARK: - Private + private func load() async { + let calendar = Calendar.current + let year = calendar.component(.year, from: Date()) + let month = calendar.component(.month, from: Date()) + + async let expenses = expenseUseCase.fetch(year: year, month: month) + async let currentModel = aiUseCase.fetchCurrentModel() + async let models = aiUseCase.fetchModels() + async let dailyPredictions = aiUseCase.fetchDailyPredictions(year: year, month: month) + async let categoryPredictions = aiUseCase.fetchCategoryPredictions(year: year, month: month) + + let ( + fetchedExpenses, + fetchedModel, + fetchedModels, + fetchedDaily, + fetchedCategory + ) = await ( + expenses, + currentModel, + models, + dailyPredictions, + categoryPredictions + ) + + self.currentModel = fetchedModel + self.models = fetchedModels + self.predictionData = makePredictionData(expenses: fetchedExpenses, predictions: fetchedDaily) + self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedCategory) + } + + private func makePredictionData(expenses: [Expense], predictions: [Int: Int]) -> [PredictionDataPoint] { + (1...lastDay).map { day in + let actual = expenses + .filter { Calendar.current.component(.day, from: $0.date) == day } + .reduce(0) { $0 + $1.amount } + return PredictionDataPoint( + day: day, + actual: actual > 0 ? actual : (day <= today ? actual : nil), + predicted: predictions[day] + ) + } + } + + private func makeCategoryData(expenses: [Expense], predictions: [String: Int]) -> [CategoryPredictionDataPoint] { + var categoryTotals: [String: Int] = [:] + for expense in expenses { + categoryTotals[expense.category.name, default: 0] += expense.amount + } + + let allCategories = Set(categoryTotals.keys).union(Set(predictions.keys)) + return allCategories.map { name in + CategoryPredictionDataPoint( + categoryName: name, + actual: categoryTotals[name] ?? 0, + predicted: predictions[name] + ) + } + .sorted { $0.actual > $1.actual } + } +} From ad67a2ca9646c0b17f1a83ede515f54c7f29f5de Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 14:46:27 +0900 Subject: [PATCH 04/14] =?UTF-8?q?fix:=20=EC=B9=B4=ED=85=8C=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=98=88=EC=B8=A1=20=EC=B9=B4=EB=93=9C=20=EB=B7=B0?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=EB=B3=B4=EA=B8=B0=20=EB=B2=84=ED=8A=BC=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 --- .../Presentation/View/AI/AICategoryPredictionCardView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift index a8cad2e..cdc343d 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift @@ -38,7 +38,9 @@ struct AICategoryPredictionCardView: View { emptyView } else { chart - expandButton + if data.count > 5 { + expandButton + } } } .background(Color(UIColor.DesignSystem.surface)) From 95ef2723d5e40102174da2464b611147ff1eb6a3 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 14:57:08 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A4=91=EB=B3=B5=20=EB=AA=A8=EB=8D=B8=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 --- .../View/AI/AIModelSelectView.swift | 17 +-- .../View/AI/AIStatusCardView.swift | 129 +++++++++--------- .../Source/Presentation/View/AI/AIView.swift | 5 +- .../Presentation/View/AI/AIViewModel.swift | 2 +- 4 files changed, 73 insertions(+), 80 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift index 8549f56..5f5a844 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift @@ -7,26 +7,19 @@ import SwiftUI -struct AIModelItem { - let id: String - let dataCount: Int - let accuracy: Float - let createdAt: Date -} - struct AIModelSelectView: View { @Environment(\.dismiss) private var dismiss - let models: [AIModelItem] - var onConfirm: (AIModelItem) -> Void + let models: [AIModelMetadata] + var onConfirm: (AIModelMetadata) -> Void @State private var selectedId: String init( - models: [AIModelItem], + models: [AIModelMetadata], currentModelId: String, - onConfirm: @escaping (AIModelItem) -> Void + onConfirm: @escaping (AIModelMetadata) -> Void ) { self.models = models self.onConfirm = onConfirm @@ -52,7 +45,7 @@ struct AIModelSelectView: View { .background(Color(UIColor.DesignSystem.background)) } - private func modelRow(_ model: AIModelItem) -> some View { + private func modelRow(_ model: AIModelMetadata) -> some View { HStack(spacing: 14) { VStack(alignment: .leading, spacing: 4) { Text(model.id) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index 5bd5004..77ef505 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -9,74 +9,16 @@ import SwiftUI struct AIStatusCardView: View { - let modelId: String - let dataCount: Int - let accuracy: Float + let currentModel: AIModelMetadata? + let models: [AIModelMetadata] @State private var isShowingCreate = false @State private var isShowingSelect = false - private let mockModels: [AIModelItem] = [ - AIModelItem(id: "CAT13A", dataCount: 1204, accuracy: 82, createdAt: Date()), - AIModelItem(id: "CAT12B", dataCount: 980, accuracy: 76, createdAt: Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()), - AIModelItem(id: "CAT11C", dataCount: 750, accuracy: 71, createdAt: Calendar.current.date(byAdding: .month, value: -2, to: Date()) ?? Date()), - ] - var body: some View { VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "cpu") - .resizable() - .scaledToFit() - .frame(width: 64, height: 64) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) - - VStack(alignment: .leading, spacing: 4) { - Text("\(modelId) 예측 모델 (\(Int(accuracy))%)") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) - - Text("학습에 사용된 데이터: \(dataCount)개") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - - ProgressView(value: accuracy / 100) - .tint(Color(UIColor.DesignSystem.accent)) - .scaleEffect(x: 1, y: 2) - .padding(.top, 2) - } - } - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 20) - - HStack(spacing: 12) { - Button { - isShowingCreate = true - } label: { - Text("모델 생성하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color(UIColor.DesignSystem.primary)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - - Button { - isShowingSelect = true - } label: { - Text("모델 교체하기") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - .frame(height: 44) - .background(Color(UIColor.DesignSystem.accent)) - .clipShape(RoundedRectangle(cornerRadius: 14)) - } - } - .padding(.horizontal, 20) - .padding(.bottom, 20) + modelInfo + buttons } .background(Color(UIColor.DesignSystem.surface)) .clipShape(RoundedRectangle(cornerRadius: 20)) @@ -88,13 +30,72 @@ struct AIStatusCardView: View { } message: { Text("데이터를 학습해 예측 모델을 생성할까요?") } + .tint(Color(UIColor.DesignSystem.accent)) .sheet(isPresented: $isShowingSelect) { AIModelSelectView( - models: mockModels, - currentModelId: modelId + models: models, + currentModelId: currentModel?.id ?? "" ) { selected in print("선택된 모델: \(selected.id)") } } } + + private var modelInfo: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "cpu") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + VStack(alignment: .leading, spacing: 4) { + Text("\(currentModel?.id ?? "-") 예측 모델 (\(Int(currentModel?.accuracy ?? 0))%)") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + Text("학습에 사용된 데이터: \(currentModel?.dataCount ?? 0)개") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + + ProgressView(value: (currentModel?.accuracy ?? 0) / 100) + .tint(Color(UIColor.DesignSystem.accent)) + .scaleEffect(x: 1, y: 2) + .padding(.top, 2) + } + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 20) + } + + private var buttons: some View { + HStack(spacing: 12) { + Button { + isShowingCreate = true + } label: { + Text("모델 생성하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.primary)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + + Button { + isShowingSelect = true + } label: { + Text("모델 교체하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.accent)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index ba99560..4016e48 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -25,9 +25,8 @@ struct AIView: View { .offset(y: -5) AIStatusCardView( - modelId: viewModel.currentModel?.id ?? "-", - dataCount: viewModel.currentModel?.dataCount ?? 0, - accuracy: viewModel.currentModel?.accuracy ?? 0 + currentModel: viewModel.currentModel, + models: viewModel.models ) } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index d506cbd..31e5499 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -80,7 +80,7 @@ final class AIViewModel { .reduce(0) { $0 + $1.amount } return PredictionDataPoint( day: day, - actual: actual > 0 ? actual : (day <= today ? actual : nil), + actual: actual > 0 ? actual : nil, predicted: predictions[day] ) } From dda7248aa1342994bd37724b112aed06581dc291 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 15:03:14 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20AIStatusCardView=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/AI/AIStatusCardView.swift | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index 77ef505..a6c3117 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -42,26 +42,42 @@ struct AIStatusCardView: View { } private var modelInfo: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "cpu") - .resizable() - .scaledToFit() - .frame(width: 64, height: 64) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) + Group { + if let model = currentModel { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "cpu") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) - VStack(alignment: .leading, spacing: 4) { - Text("\(currentModel?.id ?? "-") 예측 모델 (\(Int(currentModel?.accuracy ?? 0))%)") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(Color(UIColor.DesignSystem.primary)) + VStack(alignment: .leading, spacing: 4) { + Text("\(model.id) 예측 모델 (\(Int(model.accuracy))%)") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + Text("학습에 사용된 데이터: \(model.dataCount)개") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) - Text("학습에 사용된 데이터: \(currentModel?.dataCount ?? 0)개") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + ProgressView(value: model.accuracy / 100) + .tint(Color(UIColor.DesignSystem.accent)) + .scaleEffect(x: 1, y: 2) + .padding(.top, 2) + } + } + } else { + VStack(alignment: .leading, spacing: 6) { + Text("예측 모델이 없어요") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) - ProgressView(value: (currentModel?.accuracy ?? 0) / 100) - .tint(Color(UIColor.DesignSystem.accent)) - .scaleEffect(x: 1, y: 2) - .padding(.top, 2) + Text("소비를 꾸준히 기록할수록 모델이 더 정확해져요.\n기록이 쌓이면 아래 '모델 생성하기' 버튼을 눌러\n나만의 예측 모델을 만들어보세요!") + .font(.system(size: 13)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.horizontal, 20) From 29984d5e74465f9ec6e6e4a71849ca2c79a1cdbd Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 15:20:31 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8D=B8=20=EC=97=86?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EC=9D=98=20=ED=86=A0=EC=8A=A4=ED=8A=B8=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 --- .../Presentation/Common/ToastView.swift | 64 +++++++++++++++++++ .../View/AI/AIModelSelectView.swift | 37 ++++++++--- .../View/AI/AIStatusCardView.swift | 9 ++- .../Source/Presentation/View/AI/AIView.swift | 5 +- 4 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift diff --git a/SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift new file mode 100644 index 0000000..7c3f543 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/ToastView.swift @@ -0,0 +1,64 @@ +// +// ToastView.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import SwiftUI + +struct ToastModifier: ViewModifier { + + @Binding var isShowing: Bool + let message: String + + @State private var isVisible = false + @State private var task: Task? = nil + + func body(content: Content) -> some View { + content + .overlay(alignment: .bottom) { + if isVisible { + Text(message) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 20) + .padding(.vertical, 12) + .background(Color(UIColor.DesignSystem.primary)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.bottom, 16) + .ignoresSafeArea(edges: .bottom) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + .animation(.easeInOut(duration: 0.3), value: isVisible) + .onChange(of: isShowing) { _, newValue in + guard newValue else { return } + isShowing = false + task?.cancel() + Task { @MainActor in + withTransaction(Transaction(animation: nil)) { + isVisible = false + } + try? await Task.sleep(for: .milliseconds(50)) + withAnimation { + isVisible = true + } + task = Task { + try? await Task.sleep(for: .seconds(2)) + if !Task.isCancelled { + withAnimation { + isVisible = false + } + } + } + } + } + } +} + +extension View { + func toast(isShowing: Binding, message: String) -> some View { + modifier(ToastModifier(isShowing: isShowing, message: message)) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift index 5f5a844..0cbc0d5 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift @@ -28,23 +28,40 @@ struct AIModelSelectView: View { var body: some View { VStack(spacing: 0) { - ScrollView { - VStack(spacing: 12) { - ForEach(models, id: \.id) { model in - modelRow(model) - .onTapGesture { - selectedId = model.id - } + if models.isEmpty { + emptyView + } else { + ScrollView { + VStack(spacing: 12) { + ForEach(models, id: \.id) { model in + modelRow(model) + .onTapGesture { + selectedId = model.id + } + } } + .padding(20) } - .padding(20) - } - confirmButton + confirmButton + } } .background(Color(UIColor.DesignSystem.background)) } + private var emptyView: some View { + VStack(spacing: 8) { + Text("생성된 모델이 없어요") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + + Text("소비를 꾸준히 기록하고 모델을 생성해보세요.") + .font(.system(size: 13)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func modelRow(_ model: AIModelMetadata) -> some View { HStack(spacing: 14) { VStack(alignment: .leading, spacing: 4) { diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index a6c3117..304dbff 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -12,6 +12,7 @@ struct AIStatusCardView: View { let currentModel: AIModelMetadata? let models: [AIModelMetadata] + @Binding var isShowingToast: Bool @State private var isShowingCreate = false @State private var isShowingSelect = false @@ -100,14 +101,18 @@ struct AIStatusCardView: View { } Button { - isShowingSelect = true + if models.isEmpty { + isShowingToast = true + } else { + isShowingSelect = true + } } label: { Text("모델 교체하기") .font(.system(size: 16, weight: .bold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 44) - .background(Color(UIColor.DesignSystem.accent)) + .background(Color(UIColor.DesignSystem.accent).opacity(models.isEmpty ? 0.4 : 1.0)) .clipShape(RoundedRectangle(cornerRadius: 14)) } } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 4016e48..67f8412 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -10,6 +10,7 @@ import SwiftUI struct AIView: View { @State var viewModel: AIViewModel + @State private var isShowingToast = false var body: some View { ScrollView { @@ -26,7 +27,8 @@ struct AIView: View { AIStatusCardView( currentModel: viewModel.currentModel, - models: viewModel.models + models: viewModel.models, + isShowingToast: $isShowingToast ) } @@ -48,5 +50,6 @@ struct AIView: View { .onAppear { viewModel.onAppear() } + .toast(isShowing: $isShowingToast, message: "아직 생성된 예측 모델이 없어요") } } From f6646eda8ee81cb1c3dc95cef0d3c10613e22743 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 15:24:17 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EC=9D=B8=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20Mock=20Repo=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Source/Data/Repositories/MockAIRepository.swift | 8 ++++++++ .../Source/Domain/Repositories/AIRepositoryProtocol.swift | 2 ++ .../SpendLearning/Source/Domain/UseCases/AIUseCase.swift | 4 ++++ .../Source/Domain/UseCases/AIUseCaseProtocol.swift | 2 ++ .../Source/Presentation/View/AI/AIView.swift | 2 +- .../Source/Presentation/View/AI/AIViewModel.swift | 5 +++++ 6 files changed, 22 insertions(+), 1 deletion(-) diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift index dff55c6..1feec36 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift @@ -23,6 +23,14 @@ final class MockAIRepository: AIRepositoryProtocol { models.first } + func fetchInsights() async -> [AIInsightItemData] { + [ + AIInsightItemData(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"), + AIInsightItemData(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"), + AIInsightItemData(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"), + ] + } + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { [ 1: 15000, 2: 18000, 3: 12000, 4: 22000, 5: 19000, diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift index 237b939..697d5fb 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -10,6 +10,8 @@ protocol AIRepositoryProtocol { func fetchModels() async -> [AIModelMetadata] /// 현재 사용 중인 AI 모델 메타데이터 func fetchCurrentModel() async -> AIModelMetadata? + /// 이번 달 인사이트 목록 + func fetchInsights() async -> [AIInsightItemData] /// 이번 달 일별 예측 금액 [일: 예측금액] func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift index 620c3ee..ae97a5f 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift @@ -23,6 +23,10 @@ final class AIUseCase: AIUseCaseProtocol { await repository.fetchCurrentModel() } + func fetchInsights() async -> [AIInsightItemData] { + await repository.fetchInsights() + } + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { await repository.fetchDailyPredictions(year: year, month: month) } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift index 564d4bb..28b199c 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -10,6 +10,8 @@ protocol AIUseCaseProtocol { func fetchModels() async -> [AIModelMetadata] /// 현재 사용 중인 AI 모델 메타데이터 func fetchCurrentModel() async -> AIModelMetadata? + /// 이번 달 인사이트 목록 + func fetchInsights() async -> [AIInsightItemData] /// 이번 달 일별 예측 금액 [일: 예측금액] func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 67f8412..2a7b5a8 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -32,7 +32,7 @@ struct AIView: View { ) } - AIInsightCardView(items: []) + AIInsightCardView(items: viewModel.insights) AIPredictionCardView( data: viewModel.predictionData, diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index 31e5499..29dd045 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -13,6 +13,7 @@ final class AIViewModel { // MARK: - Output private(set) var currentModel: AIModelMetadata? = nil private(set) var models: [AIModelMetadata] = [] + private(set) var insights: [AIInsightItemData] = [] private(set) var predictionData: [PredictionDataPoint] = [] private(set) var categoryData: [CategoryPredictionDataPoint] = [] private(set) var today: Int = Calendar.current.component(.day, from: Date()) @@ -50,6 +51,7 @@ final class AIViewModel { async let expenses = expenseUseCase.fetch(year: year, month: month) async let currentModel = aiUseCase.fetchCurrentModel() async let models = aiUseCase.fetchModels() + async let insights = aiUseCase.fetchInsights() async let dailyPredictions = aiUseCase.fetchDailyPredictions(year: year, month: month) async let categoryPredictions = aiUseCase.fetchCategoryPredictions(year: year, month: month) @@ -57,18 +59,21 @@ final class AIViewModel { fetchedExpenses, fetchedModel, fetchedModels, + fetchedInsights, fetchedDaily, fetchedCategory ) = await ( expenses, currentModel, models, + insights, dailyPredictions, categoryPredictions ) self.currentModel = fetchedModel self.models = fetchedModels + self.insights = fetchedInsights self.predictionData = makePredictionData(expenses: fetchedExpenses, predictions: fetchedDaily) self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedCategory) } From c884224b051372fae783aedf76038fba239f6e24 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 15:40:51 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EB=B7=B0=EC=9D=98=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 11 +++++---- .../Data/Repositories/MockAIRepository.swift | 8 +++---- .../Domain/Entities/AI/AIInsightItem.swift | 23 +++++++++++++++++++ .../Entities/{ => AI}/AIModelMetadata.swift | 0 .../AI/CategoryPredictionDataPoint.swift | 12 ++++++++++ .../Entities/AI/PredictionDataPoint.swift | 12 ++++++++++ .../Entities/{ => Expense}/CalendarDay.swift | 0 .../Entities/{ => Expense}/Category.swift | 0 .../Entities/{ => Expense}/Expense.swift | 0 .../Repositories/AIRepositoryProtocol.swift | 2 +- .../Source/Domain/UseCases/AIUseCase.swift | 2 +- .../Domain/UseCases/AIUseCaseProtocol.swift | 2 +- .../AI/AICategoryPredictionCardView.swift | 6 ----- .../View/AI/AIInsightCardView.swift | 23 +++---------------- .../View/AI/AIPredictionCardView.swift | 12 +++------- .../Presentation/View/AI/AIViewModel.swift | 4 ++-- 16 files changed, 69 insertions(+), 48 deletions(-) create mode 100644 SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift rename SpendLearning/SpendLearning/Source/Domain/Entities/{ => AI}/AIModelMetadata.swift (100%) create mode 100644 SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift create mode 100644 SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift rename SpendLearning/SpendLearning/Source/Domain/Entities/{ => Expense}/CalendarDay.swift (100%) rename SpendLearning/SpendLearning/Source/Domain/Entities/{ => Expense}/Category.swift (100%) rename SpendLearning/SpendLearning/Source/Domain/Entities/{ => Expense}/Expense.swift (100%) diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 7629968..063b14a 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -32,10 +32,13 @@ BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Source/Domain/Entities/AIModelMetadata.swift, - Source/Domain/Entities/CalendarDay.swift, - Source/Domain/Entities/Category.swift, - Source/Domain/Entities/Expense.swift, + Source/Domain/Entities/AI/AIInsightItem.swift, + Source/Domain/Entities/AI/AIModelMetadata.swift, + Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift, + Source/Domain/Entities/AI/PredictionDataPoint.swift, + Source/Domain/Entities/Expense/CalendarDay.swift, + Source/Domain/Entities/Expense/Category.swift, + Source/Domain/Entities/Expense/Expense.swift, Source/Domain/Repositories/AIRepositoryProtocol.swift, Source/Domain/Repositories/CategoryRepositoryProtocol.swift, Source/Domain/Repositories/ExpenseRepositoryProtocol.swift, diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift index 1feec36..6c6d5dd 100644 --- a/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockAIRepository.swift @@ -23,11 +23,11 @@ final class MockAIRepository: AIRepositoryProtocol { models.first } - func fetchInsights() async -> [AIInsightItemData] { + func fetchInsights() async -> [AIInsightItem] { [ - AIInsightItemData(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"), - AIInsightItemData(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"), - AIInsightItemData(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"), + AIInsightItem(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"), + AIInsightItem(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"), + AIInsightItem(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"), ] } diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift new file mode 100644 index 0000000..e3fdb2a --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIInsightItem.swift @@ -0,0 +1,23 @@ +// +// AIInsightItem.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +enum AIInsightItemType { + case abnormal, forecast, unrecorded + + var title: String { + switch self { + case .abnormal: return "이상 지출 감지" + case .forecast: return "지출 예고" + case .unrecorded: return "미기록 감지" + } + } +} + +struct AIInsightItem { + let type: AIInsightItemType + let description: String +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AIModelMetadata.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/AIModelMetadata.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/AI/AIModelMetadata.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift new file mode 100644 index 0000000..ec03ef3 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift @@ -0,0 +1,12 @@ +// +// CategoryPredictionDataPoint.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +struct CategoryPredictionDataPoint { + let categoryName: String + let actual: Int + let predicted: Int? +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift new file mode 100644 index 0000000..ff9ab4e --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift @@ -0,0 +1,12 @@ +// +// PredictionDataPoint.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +struct PredictionDataPoint { + let day: Int + let actual: Int? + let predicted: Int? +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/CalendarDay.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/CalendarDay.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Category.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/Category.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Expense.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift similarity index 100% rename from SpendLearning/SpendLearning/Source/Domain/Entities/Expense.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift index 697d5fb..3837218 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/AIRepositoryProtocol.swift @@ -11,7 +11,7 @@ protocol AIRepositoryProtocol { /// 현재 사용 중인 AI 모델 메타데이터 func fetchCurrentModel() async -> AIModelMetadata? /// 이번 달 인사이트 목록 - func fetchInsights() async -> [AIInsightItemData] + func fetchInsights() async -> [AIInsightItem] /// 이번 달 일별 예측 금액 [일: 예측금액] func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift index ae97a5f..768831f 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift @@ -23,7 +23,7 @@ final class AIUseCase: AIUseCaseProtocol { await repository.fetchCurrentModel() } - func fetchInsights() async -> [AIInsightItemData] { + func fetchInsights() async -> [AIInsightItem] { await repository.fetchInsights() } diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift index 28b199c..524bcc4 100644 --- a/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCaseProtocol.swift @@ -11,7 +11,7 @@ protocol AIUseCaseProtocol { /// 현재 사용 중인 AI 모델 메타데이터 func fetchCurrentModel() async -> AIModelMetadata? /// 이번 달 인사이트 목록 - func fetchInsights() async -> [AIInsightItemData] + func fetchInsights() async -> [AIInsightItem] /// 이번 달 일별 예측 금액 [일: 예측금액] func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] /// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액] diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift index cdc343d..e28f1c7 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift @@ -8,12 +8,6 @@ import SwiftUI import Charts -struct CategoryPredictionDataPoint { - let categoryName: String - let actual: Int - let predicted: Int? -} - struct AICategoryPredictionCardView: View { let data: [CategoryPredictionDataPoint] diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift index 8c50f21..e446897 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift @@ -7,30 +7,13 @@ import SwiftUI -enum AIInsightItemType { - case abnormal, forecast, unrecorded - - var title: String { - switch self { - case .abnormal: return "이상 지출 감지" - case .forecast: return "지출 예고" - case .unrecorded: return "미기록 감지" - } - } -} - -struct AIInsightItemData { - let type: AIInsightItemType - let description: String -} - struct AIInsightCardView: View { - let items: [AIInsightItemData] + let items: [AIInsightItem] private let orderedTypes: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] - private var visibleItems: [AIInsightItemData] { + private var visibleItems: [AIInsightItem] { orderedTypes.compactMap { type in items.first { $0.type == type } } @@ -84,7 +67,7 @@ struct AIInsightCardView: View { } } - private func rowView(item: AIInsightItemData) -> some View { + private func rowView(item: AIInsightItem) -> some View { VStack(alignment: .leading, spacing: 4) { Text(item.type.title) .font(.system(size: 14, weight: .semibold)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift index 4a64567..13c3818 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift @@ -8,12 +8,6 @@ import SwiftUI import Charts -struct PredictionDataPoint { - let day: Int - let actual: Int? - let predicted: Int? -} - private struct CumulativeDataPoint { let day: Int let actual: Int? @@ -180,11 +174,11 @@ struct AIPredictionCardView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } - if let todayPoint = cumulativeData.first(where: { $0.day == today }), - let actual = todayPoint.actual { + if let todayPoint = cumulativeData.first(where: { $0.day == today }) { + let actualTotal = cumulativeData.filter { $0.day <= today }.compactMap { $0.actual }.last ?? 0 PointMark( x: .value("날짜", todayPoint.day), - y: .value("금액", actual) + y: .value("금액", actualTotal) ) .foregroundStyle(Color(UIColor.DesignSystem.accent)) } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index 29dd045..d815870 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -13,7 +13,7 @@ final class AIViewModel { // MARK: - Output private(set) var currentModel: AIModelMetadata? = nil private(set) var models: [AIModelMetadata] = [] - private(set) var insights: [AIInsightItemData] = [] + private(set) var insights: [AIInsightItem] = [] private(set) var predictionData: [PredictionDataPoint] = [] private(set) var categoryData: [CategoryPredictionDataPoint] = [] private(set) var today: Int = Calendar.current.component(.day, from: Date()) @@ -85,7 +85,7 @@ final class AIViewModel { .reduce(0) { $0 + $1.amount } return PredictionDataPoint( day: day, - actual: actual > 0 ? actual : nil, + actual: (actual > 0 || day == today) ? actual : nil, predicted: predictions[day] ) } From c4edd1bb1487ae4d2d822c10259deb2c09a152fd Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 15:46:52 +0900 Subject: [PATCH 10/14] =?UTF-8?q?fix=20=EB=88=84=EC=A0=81=EC=9D=84=20?= =?UTF-8?q?=EB=8B=B4=EB=8A=94=20=EB=AA=A8=EB=8D=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 2 +- ...Point.swift => CumulativePrediction.swift} | 4 +- .../View/AI/AIPredictionCardView.swift | 47 +++++-------------- .../Presentation/View/AI/AIViewModel.swift | 27 +++++++---- 4 files changed, 34 insertions(+), 46 deletions(-) rename SpendLearning/SpendLearning/Source/Domain/Entities/AI/{PredictionDataPoint.swift => CumulativePrediction.swift} (67%) diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 063b14a..64bc14d 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -35,7 +35,7 @@ Source/Domain/Entities/AI/AIInsightItem.swift, Source/Domain/Entities/AI/AIModelMetadata.swift, Source/Domain/Entities/AI/CategoryPredictionDataPoint.swift, - Source/Domain/Entities/AI/PredictionDataPoint.swift, + Source/Domain/Entities/AI/CumulativePrediction.swift, Source/Domain/Entities/Expense/CalendarDay.swift, Source/Domain/Entities/Expense/Category.swift, Source/Domain/Entities/Expense/Expense.swift, diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift similarity index 67% rename from SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift rename to SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift index ff9ab4e..d5382a4 100644 --- a/SpendLearning/SpendLearning/Source/Domain/Entities/AI/PredictionDataPoint.swift +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/AI/CumulativePrediction.swift @@ -1,11 +1,11 @@ // -// PredictionDataPoint.swift +// CumulativePrediction.swift // SpendLearning // // Created by 김성훈 on 7/14/26. // -struct PredictionDataPoint { +struct CumulativePrediction { let day: Int let actual: Int? let predicted: Int? diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift index 13c3818..75f339a 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift @@ -8,15 +8,9 @@ import SwiftUI import Charts -private struct CumulativeDataPoint { - let day: Int - let actual: Int? - let predicted: Int? -} - struct AIPredictionCardView: View { - let data: [PredictionDataPoint] + let data: [CumulativePrediction] let today: Int let lastDay: Int @@ -91,31 +85,14 @@ struct AIPredictionCardView: View { } private var chart: some View { - var cumulativeActual = 0 - var cumulativePredicted = 0 - let cumulativeData: [CumulativeDataPoint] = data.map { point in - if let actual = point.actual { - cumulativeActual += actual - } - if let predicted = point.predicted { - cumulativePredicted += predicted - } - return CumulativeDataPoint( - day: point.day, - actual: point.actual != nil ? cumulativeActual : nil, - predicted: point.predicted != nil ? cumulativePredicted : nil - ) - } - - let actualValues = cumulativeData.compactMap { $0.actual } - let predictedValues = cumulativeData.compactMap { $0.predicted } + let actualValues = data.compactMap { $0.actual } + let predictedValues = data.compactMap { $0.predicted } let maxValue = (actualValues + predictedValues).max() ?? 0 - let predictedTotal = cumulativeData.compactMap { $0.predicted }.last ?? 0 + let predictedTotal = data.compactMap { $0.predicted }.last ?? 0 return Chart { - if hasPrediction { - ForEach(cumulativeData, id: \.day) { point in + ForEach(data, id: \.day) { point in if let predicted = point.predicted { LineMark( x: .value("날짜", point.day), @@ -127,7 +104,7 @@ struct AIPredictionCardView: View { } } - if let lastPoint = cumulativeData.last, let predicted = lastPoint.predicted { + if let lastPoint = data.last, let predicted = lastPoint.predicted { PointMark( x: .value("날짜", lastPoint.day), y: .value("금액", predicted) @@ -141,7 +118,7 @@ struct AIPredictionCardView: View { } } - ForEach(cumulativeData, id: \.day) { point in + ForEach(data, id: \.day) { point in if let actual = point.actual { LineMark( x: .value("날짜", point.day), @@ -156,8 +133,8 @@ struct AIPredictionCardView: View { .foregroundStyle(Color(UIColor.DesignSystem.subtitle).opacity(0.5)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) .annotation(position: .top) { - let actualTotal = cumulativeData.filter { $0.day <= today }.compactMap { $0.actual }.last ?? 0 - let predictedToday = cumulativeData.filter { $0.day <= today }.compactMap { $0.predicted }.last ?? 0 + let actualTotal = data.filter { $0.day <= today }.compactMap { $0.actual }.last ?? 0 + let predictedToday = data.filter { $0.day <= today }.compactMap { $0.predicted }.last ?? 0 VStack(alignment: .leading, spacing: 2) { Text("실제: \(formatted(actualTotal))") @@ -174,11 +151,11 @@ struct AIPredictionCardView: View { .clipShape(RoundedRectangle(cornerRadius: 6)) } - if let todayPoint = cumulativeData.first(where: { $0.day == today }) { - let actualTotal = cumulativeData.filter { $0.day <= today }.compactMap { $0.actual }.last ?? 0 + if let todayPoint = data.first(where: { $0.day == today }), + let actual = todayPoint.actual { PointMark( x: .value("날짜", todayPoint.day), - y: .value("금액", actualTotal) + y: .value("금액", actual) ) .foregroundStyle(Color(UIColor.DesignSystem.accent)) } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index d815870..e78c4a7 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -14,7 +14,7 @@ final class AIViewModel { private(set) var currentModel: AIModelMetadata? = nil private(set) var models: [AIModelMetadata] = [] private(set) var insights: [AIInsightItem] = [] - private(set) var predictionData: [PredictionDataPoint] = [] + private(set) var predictionData: [CumulativePrediction] = [] private(set) var categoryData: [CategoryPredictionDataPoint] = [] private(set) var today: Int = Calendar.current.component(.day, from: Date()) private(set) var lastDay: Int = { @@ -74,19 +74,30 @@ final class AIViewModel { self.currentModel = fetchedModel self.models = fetchedModels self.insights = fetchedInsights - self.predictionData = makePredictionData(expenses: fetchedExpenses, predictions: fetchedDaily) + self.predictionData = makeCumulativePrediction(expenses: fetchedExpenses, predictions: fetchedDaily) self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedCategory) } - private func makePredictionData(expenses: [Expense], predictions: [Int: Int]) -> [PredictionDataPoint] { - (1...lastDay).map { day in - let actual = expenses + private func makeCumulativePrediction(expenses: [Expense], predictions: [Int: Int]) -> [CumulativePrediction] { + var cumulativeActual = 0 + var cumulativePredicted = 0 + + return (1...lastDay).map { day in + let daily = expenses .filter { Calendar.current.component(.day, from: $0.date) == day } .reduce(0) { $0 + $1.amount } - return PredictionDataPoint( + + if daily > 0 || day == today { + cumulativeActual += daily + } + if let predicted = predictions[day] { + cumulativePredicted += predicted + } + + return CumulativePrediction( day: day, - actual: (actual > 0 || day == today) ? actual : nil, - predicted: predictions[day] + actual: (daily > 0 || day == today) ? cumulativeActual : nil, + predicted: predictions[day] != nil ? cumulativePredicted : nil ) } } From 0163b8b1a80d1ca0165574a9b0ec3749cbec73b0 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 16:02:50 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20=EB=B7=B0=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= =?UTF-8?q?=ED=95=A0=EA=B1=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/AI/AICategoryPredictionCardView.swift | 7 ++----- .../Presentation/View/AI/AIInsightCardView.swift | 14 +++----------- .../Presentation/View/AI/AIModelSelectView.swift | 3 +-- .../View/AI/AIPredictionCardView.swift | 5 +---- .../Presentation/View/AI/AIStatusCardView.swift | 6 +++--- .../Source/Presentation/View/AI/AIView.swift | 14 +++++++++----- .../Source/Presentation/View/AI/AIViewModel.swift | 11 +++++++++++ 7 files changed, 30 insertions(+), 30 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift index e28f1c7..5d93132 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AICategoryPredictionCardView.swift @@ -10,13 +10,10 @@ import Charts struct AICategoryPredictionCardView: View { - let data: [CategoryPredictionDataPoint] - @State private var isExpanded = false - private var hasPrediction: Bool { - data.compactMap { $0.predicted }.isEmpty == false - } + let data: [CategoryPredictionDataPoint] + let hasPrediction: Bool private var displayData: [CategoryPredictionDataPoint] { isExpanded ? data : Array(data.prefix(5)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift index e446897..12567bc 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIInsightCardView.swift @@ -11,19 +11,11 @@ struct AIInsightCardView: View { let items: [AIInsightItem] - private let orderedTypes: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] - - private var visibleItems: [AIInsightItem] { - orderedTypes.compactMap { type in - items.first { $0.type == type } - } - } - var body: some View { VStack(spacing: 0) { header - if visibleItems.isEmpty { + if items.isEmpty { emptyView } else { bodyView @@ -55,10 +47,10 @@ struct AIInsightCardView: View { private var bodyView: some View { VStack(spacing: 0) { - ForEach(Array(visibleItems.enumerated()), id: \.offset) { index, item in + ForEach(Array(items.enumerated()), id: \.offset) { index, item in rowView(item: item) - if index < visibleItems.count - 1 { + if index < items.count - 1 { Divider() .background(Color(UIColor.DesignSystem.separator)) .padding(.horizontal, 12) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift index 0cbc0d5..d773aa8 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIModelSelectView.swift @@ -10,12 +10,11 @@ import SwiftUI struct AIModelSelectView: View { @Environment(\.dismiss) private var dismiss + @State private var selectedId: String let models: [AIModelMetadata] var onConfirm: (AIModelMetadata) -> Void - @State private var selectedId: String - init( models: [AIModelMetadata], currentModelId: String, diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift index 75f339a..4ff392a 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIPredictionCardView.swift @@ -13,10 +13,7 @@ struct AIPredictionCardView: View { let data: [CumulativePrediction] let today: Int let lastDay: Int - - private var hasPrediction: Bool { - data.compactMap { $0.predicted }.isEmpty == false - } + let hasPrediction: Bool var body: some View { VStack(spacing: 0) { diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift index 304dbff..7e18ce4 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIStatusCardView.swift @@ -9,13 +9,13 @@ import SwiftUI struct AIStatusCardView: View { - let currentModel: AIModelMetadata? - let models: [AIModelMetadata] - @Binding var isShowingToast: Bool @State private var isShowingCreate = false @State private var isShowingSelect = false + let currentModel: AIModelMetadata? + let models: [AIModelMetadata] + var body: some View { VStack(alignment: .leading, spacing: 0) { modelInfo diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 2a7b5a8..5d56751 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -26,23 +26,27 @@ struct AIView: View { .offset(y: -5) AIStatusCardView( + isShowingToast: $isShowingToast, currentModel: viewModel.currentModel, models: viewModel.models, - isShowingToast: $isShowingToast ) } - AIInsightCardView(items: viewModel.insights) + AIInsightCardView(items: viewModel.sortedInsights) AIPredictionCardView( data: viewModel.predictionData, today: viewModel.today, - lastDay: viewModel.lastDay + lastDay: viewModel.lastDay, + hasPrediction: viewModel.hasPrediction ) - AICategoryPredictionCardView(data: viewModel.categoryData) - .padding(.bottom, 20) + AICategoryPredictionCardView( + data: viewModel.categoryData, + hasPrediction: viewModel.hasPrediction + ) } + .padding(.bottom, 20) .padding(.horizontal, 20) } .background(Color(UIColor.DesignSystem.background)) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index e78c4a7..92d226d 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -26,6 +26,17 @@ final class AIViewModel { private let expenseUseCase: ExpenseUseCaseProtocol private let aiUseCase: AIUseCaseProtocol + var hasPrediction: Bool { + predictionData.compactMap { $0.predicted }.isEmpty == false + } + + var sortedInsights: [AIInsightItem] { + let order: [AIInsightItemType] = [.abnormal, .forecast, .unrecorded] + return order.compactMap { type in + insights.first { $0.type == type } + } + } + // MARK: - Init init( expenseUseCase: ExpenseUseCaseProtocol, From b55aa3b319aad635c99cb1248173af6797f21719 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 16:07:48 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20AIUseCaseTests=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCases/AIUseCaseTests.swift | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift diff --git a/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift new file mode 100644 index 0000000..2b12482 --- /dev/null +++ b/SpendLearning/SpendLearningTests/UseCases/AIUseCaseTests.swift @@ -0,0 +1,130 @@ +// +// AIUseCaseTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("AIUseCase") +@MainActor +struct AIUseCaseTests { + + @Test("fetchModels 호출 시 Repository의 fetchModels가 호출된다") + func fetchModelsCallsRepository() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy) + + _ = await sut.fetchModels() + + #expect(spy.fetchModelsCallCount == 1) + } + + @Test("fetchModels 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchModelsReturnsRepositoryResult() async { + let spy = SpyAIRepository() + let model = AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()) + spy.stubbedModels = [model] + let sut = AIUseCase(repository: spy) + + let result = await sut.fetchModels() + + #expect(result.count == 1) + #expect(result.first?.id == model.id) + } + + @Test("fetchCurrentModel 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchCurrentModelReturnsRepositoryResult() async { + let spy = SpyAIRepository() + let model = AIModelMetadata(id: "SP260714", dataCount: 1204, accuracy: 82, createdAt: Date()) + spy.stubbedCurrentModel = model + let sut = AIUseCase(repository: spy) + + let result = await sut.fetchCurrentModel() + + #expect(result?.id == model.id) + } + + @Test("fetchDailyPredictions 호출 시 Repository가 올바른 year/month로 호출된다") + func fetchDailyPredictionsCallsRepositoryWithCorrectYearMonth() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy) + + _ = await sut.fetchDailyPredictions(year: 2026, month: 7) + + #expect(spy.fetchDailyCallCount == 1) + #expect(spy.fetchedYear == 2026) + #expect(spy.fetchedMonth == 7) + } + + @Test("fetchCategoryPredictions 호출 시 Repository가 올바른 year/month로 호출된다") + func fetchCategoryPredictionsCallsRepositoryWithCorrectYearMonth() async { + let spy = SpyAIRepository() + let sut = AIUseCase(repository: spy) + + _ = await sut.fetchCategoryPredictions(year: 2026, month: 7) + + #expect(spy.fetchCategoryCallCount == 1) + #expect(spy.fetchedYear == 2026) + #expect(spy.fetchedMonth == 7) + } + + @Test("fetchInsights 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchInsightsReturnsRepositoryResult() async { + let spy = SpyAIRepository() + let insight = AIInsightItem(type: .abnormal, description: "테스트") + spy.stubbedInsights = [insight] + let sut = AIUseCase(repository: spy) + + let result = await sut.fetchInsights() + + #expect(result.count == 1) + #expect(result.first?.description == insight.description) + } +} + +// MARK: - Spy + +final class SpyAIRepository: AIRepositoryProtocol { + private(set) var fetchModelsCallCount = 0 + private(set) var fetchCurrentModelCallCount = 0 + private(set) var fetchDailyCallCount = 0 + private(set) var fetchCategoryCallCount = 0 + private(set) var fetchInsightsCallCount = 0 + private(set) var fetchedYear: Int? + private(set) var fetchedMonth: Int? + var stubbedModels: [AIModelMetadata] = [] + var stubbedCurrentModel: AIModelMetadata? = nil + var stubbedInsights: [AIInsightItem] = [] + + func fetchModels() async -> [AIModelMetadata] { + fetchModelsCallCount += 1 + return stubbedModels + } + + func fetchCurrentModel() async -> AIModelMetadata? { + fetchCurrentModelCallCount += 1 + return stubbedCurrentModel + } + + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { + fetchDailyCallCount += 1 + fetchedYear = year + fetchedMonth = month + return [:] + } + + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { + fetchCategoryCallCount += 1 + fetchedYear = year + fetchedMonth = month + return [:] + } + + func fetchInsights() async -> [AIInsightItem] { + fetchInsightsCallCount += 1 + return stubbedInsights + } +} From 72f69edb1b8dfedefc52341c9680594f584019fb Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 16:14:47 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20AIViewModel=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpendLearning.xcodeproj/project.pbxproj | 1 + .../Source/Presentation/View/AI/AIView.swift | 4 +- .../Presentation/View/AI/AIViewModel.swift | 6 +- .../ViewModels/AIViewModelTests.swift | 173 ++++++++++++++++++ 4 files changed, 179 insertions(+), 5 deletions(-) create mode 100644 SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj index 64bc14d..22f25e7 100644 --- a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ Source/Domain/UseCases/CategoryUseCaseProtocol.swift, Source/Domain/UseCases/ExpenseUseCase.swift, Source/Domain/UseCases/ExpenseUseCaseProtocol.swift, + Source/Presentation/View/AI/AIViewModel.swift, Source/Presentation/View/Home/HomeViewModel.swift, Source/Presentation/View/NewExpense/NewExpenseViewModel.swift, Source/Presentation/View/Settings/SettingsViewModel.swift, diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift index 5d56751..037fc90 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIView.swift @@ -52,7 +52,9 @@ struct AIView: View { .background(Color(UIColor.DesignSystem.background)) .scrollIndicators(.hidden) .onAppear { - viewModel.onAppear() + Task { + await viewModel.onAppear() + } } .toast(isShowing: $isShowingToast, message: "아직 생성된 예측 모델이 없어요") } diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index 92d226d..13eca9e 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -47,10 +47,8 @@ final class AIViewModel { } // MARK: - Input - func onAppear() { - Task { - await load() - } + func onAppear() async { + await load() } // MARK: - Private diff --git a/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift new file mode 100644 index 0000000..6869a43 --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/AIViewModelTests.swift @@ -0,0 +1,173 @@ +// +// AIViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("AIViewModel") +@MainActor +struct AIViewModelTests { + + // MARK: - onAppear / load + + @Test("onAppear 호출 후 predictionData가 채워진다") + func onAppearFillsPredictionData() async { + let expenseStub = StubExpenseUseCaseForAI() + let aiStub = StubAIUseCase() + aiStub.stubbedDailyPredictions = [1: 10000, 2: 20000] + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.predictionData.isEmpty == false) + } + + @Test("onAppear 호출 후 categoryData가 채워진다") + func onAppearFillsCategoryData() async { + let expenseStub = StubExpenseUseCaseForAI() + let food = Category(name: "식비", emoji: "🍚") + expenseStub.stubbedExpenses = [ + Expense(date: Date(), category: food, memo: nil, amount: 5000) + ] + let aiStub = StubAIUseCase() + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.categoryData.isEmpty == false) + #expect(sut.categoryData.first?.categoryName == "식비") + } + + // MARK: - hasPrediction + + @Test("예측 데이터가 없으면 hasPrediction이 false다") + func hasPredictionIsFalseWhenNoPredictions() async { + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + #expect(sut.hasPrediction == false) + } + + @Test("예측 데이터가 있으면 hasPrediction이 true다") + func hasPredictionIsTrueWhenPredictionsExist() async { + let aiStub = StubAIUseCase() + aiStub.stubbedDailyPredictions = [1: 10000] + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.hasPrediction == true) + } + + // MARK: - makeCumulativePrediction + + @Test("오늘 날짜에 소비가 없어도 actual이 nil이 아니다") + func todayActualIsNotNilEvenWithNoExpenses() async { + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + let todayPoint = sut.predictionData.first(where: { $0.day == sut.today }) + #expect(todayPoint?.actual != nil) + } + + @Test("누적 actual이 올바르게 계산된다") + func cumulativeActualIsCorrect() async { + let expenseStub = StubExpenseUseCaseForAI() + let food = Category(name: "식비", emoji: "🍚") + let calendar = Calendar.current + let day1 = calendar.date(from: DateComponents(year: 2026, month: 7, day: 1))! + let day2 = calendar.date(from: DateComponents(year: 2026, month: 7, day: 2))! + expenseStub.stubbedExpenses = [ + Expense(date: day1, category: food, memo: nil, amount: 1000), + Expense(date: day2, category: food, memo: nil, amount: 2000), + ] + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + let day2Point = sut.predictionData.first(where: { $0.day == 2 }) + #expect(day2Point?.actual == 3000) + } + + // MARK: - makeCategoryData + + @Test("categoryData가 actual 기준 내림차순으로 정렬된다") + func categoryDataIsSortedByActualDescending() async { + let expenseStub = StubExpenseUseCaseForAI() + let food = Category(name: "식비", emoji: "🍚") + let transport = Category(name: "교통", emoji: "🚌") + let day1 = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 1))! + expenseStub.stubbedExpenses = [ + Expense(date: day1, category: transport, memo: nil, amount: 1000), + Expense(date: day1, category: food, memo: nil, amount: 5000), + ] + let sut = AIViewModel(expenseUseCase: expenseStub, aiUseCase: StubAIUseCase()) + + await sut.onAppear() + + #expect(sut.categoryData.first?.categoryName == "식비") + #expect(sut.categoryData.last?.categoryName == "교통") + } + + // MARK: - sortedInsights + + @Test("sortedInsights가 abnormal, forecast, unrecorded 순서로 정렬된다") + func sortedInsightsFollowsDefinedOrder() async { + let aiStub = StubAIUseCase() + aiStub.stubbedInsights = [ + AIInsightItem(type: .unrecorded, description: "미기록"), + AIInsightItem(type: .forecast, description: "예고"), + AIInsightItem(type: .abnormal, description: "이상"), + ] + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.sortedInsights[0].type == .abnormal) + #expect(sut.sortedInsights[1].type == .forecast) + #expect(sut.sortedInsights[2].type == .unrecorded) + } + + @Test("같은 타입의 인사이트가 여러 개여도 하나만 나온다") + func sortedInsightsDeduplicatesByType() async { + let aiStub = StubAIUseCase() + aiStub.stubbedInsights = [ + AIInsightItem(type: .abnormal, description: "이상1"), + AIInsightItem(type: .abnormal, description: "이상2"), + ] + let sut = AIViewModel(expenseUseCase: StubExpenseUseCaseForAI(), aiUseCase: aiStub) + + await sut.onAppear() + + #expect(sut.sortedInsights.filter { $0.type == .abnormal }.count == 1) + } +} + +// MARK: - Stubs + +final class StubExpenseUseCaseForAI: ExpenseUseCaseProtocol { + var stubbedExpenses: [Expense] = [] + func fetch(year: Int, month: Int) async -> [Expense] { stubbedExpenses } + func add(_ expense: Expense) async {} + func delete(_ expense: Expense) async {} +} + +final class StubAIUseCase: AIUseCaseProtocol { + var stubbedModels: [AIModelMetadata] = [] + var stubbedCurrentModel: AIModelMetadata? = nil + var stubbedInsights: [AIInsightItem] = [] + var stubbedDailyPredictions: [Int: Int] = [:] + var stubbedCategoryPredictions: [String: Int] = [:] + + func fetchModels() async -> [AIModelMetadata] { stubbedModels } + func fetchCurrentModel() async -> AIModelMetadata? { stubbedCurrentModel } + func fetchInsights() async -> [AIInsightItem] { stubbedInsights } + func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int] { stubbedDailyPredictions } + func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int] { stubbedCategoryPredictions } +} From afd6eb936b2d125386e1ed360ae684d54e5de283 Mon Sep 17 00:00:00 2001 From: snughnu Date: Tue, 14 Jul 2026 16:20:10 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20chart=20=EC=98=A4=EB=8A=98=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=EA=B9=8C=EC=A7=80=EB=A7=8C=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=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 --- .../Source/Presentation/View/AI/AIViewModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift index 13eca9e..8966db9 100644 --- a/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift +++ b/SpendLearning/SpendLearning/Source/Presentation/View/AI/AIViewModel.swift @@ -96,7 +96,7 @@ final class AIViewModel { .filter { Calendar.current.component(.day, from: $0.date) == day } .reduce(0) { $0 + $1.amount } - if daily > 0 || day == today { + if day <= today { cumulativeActual += daily } if let predicted = predictions[day] { @@ -105,7 +105,7 @@ final class AIViewModel { return CumulativePrediction( day: day, - actual: (daily > 0 || day == today) ? cumulativeActual : nil, + actual: day <= today ? cumulativeActual : nil, predicted: predictions[day] != nil ? cumulativePredicted : nil ) } @@ -113,7 +113,7 @@ final class AIViewModel { private func makeCategoryData(expenses: [Expense], predictions: [String: Int]) -> [CategoryPredictionDataPoint] { var categoryTotals: [String: Int] = [:] - for expense in expenses { + for expense in expenses where Calendar.current.component(.day, from: expense.date) <= today { categoryTotals[expense.category.name, default: 0] += expense.amount }