Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions SpendLearning/SpendLearning.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,23 @@
BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
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/CumulativePrediction.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,
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,
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,
Expand Down
7 changes: 5 additions & 2 deletions SpendLearning/SpendLearning/Source/App/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// 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 fetchInsights() async -> [AIInsightItem] {
[
AIInsightItem(type: .abnormal, description: "이번 주 카페 지출이 평소보다 2.1배 많아요"),
AIInsightItem(type: .forecast, description: "매주 월요일 교통비가 나가는 패턴이에요"),
AIInsightItem(type: .unrecorded, description: "지난주 이맘때 교통비가 있었는데 이번 주엔 없네요"),
]
}

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,
]
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// CategoryPredictionDataPoint.swift
// SpendLearning
//
// Created by 김성훈 on 7/14/26.
//

struct CategoryPredictionDataPoint {
let categoryName: String
let actual: Int
let predicted: Int?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// CumulativePrediction.swift
// SpendLearning
//
// Created by 김성훈 on 7/14/26.
//

struct CumulativePrediction {
let day: Int
let actual: Int?
let predicted: Int?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// AIRepositoryProtocol.swift
// SpendLearning
//
// Created by 김성훈 on 7/14/26.
//

protocol AIRepositoryProtocol {
/// 저장된 모든 AI 모델 메타데이터 목록
func fetchModels() async -> [AIModelMetadata]
/// 현재 사용 중인 AI 모델 메타데이터
func fetchCurrentModel() async -> AIModelMetadata?
/// 이번 달 인사이트 목록
func fetchInsights() async -> [AIInsightItem]
/// 이번 달 일별 예측 금액 [일: 예측금액]
func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int]
/// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액]
func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int]
}
37 changes: 37 additions & 0 deletions SpendLearning/SpendLearning/Source/Domain/UseCases/AIUseCase.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// 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 fetchInsights() async -> [AIInsightItem] {
await repository.fetchInsights()
}

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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// AIUseCaseProtocol.swift
// SpendLearning
//
// Created by 김성훈 on 7/14/26.
//

protocol AIUseCaseProtocol {
/// 저장된 모든 AI 모델 메타데이터 목록
func fetchModels() async -> [AIModelMetadata]
/// 현재 사용 중인 AI 모델 메타데이터
func fetchCurrentModel() async -> AIModelMetadata?
/// 이번 달 인사이트 목록
func fetchInsights() async -> [AIInsightItem]
/// 이번 달 일별 예측 금액 [일: 예측금액]
func fetchDailyPredictions(year: Int, month: Int) async -> [Int: Int]
/// 이번 달 카테고리별 예측 금액 [카테고리명: 예측금액]
func fetchCategoryPredictions(year: Int, month: Int) async -> [String: Int]
}
Original file line number Diff line number Diff line change
@@ -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<Void, Never>? = 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<Bool>, message: String) -> some View {
modifier(ToastModifier(isShowing: isShowing, message: message))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,12 @@
import SwiftUI
import Charts

struct CategoryPredictionDataPoint {
let categoryName: String
let actual: Int
let predicted: Int?
}

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))
Expand All @@ -38,7 +29,9 @@ struct AICategoryPredictionCardView: View {
emptyView
} else {
chart
expandButton
if data.count > 5 {
expandButton
}
}
}
.background(Color(UIColor.DesignSystem.surface))
Expand Down
Loading
Loading