diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4ecb642 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Situation + +## Task + +## Action + +## Result diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml new file mode 100644 index 0000000..09d49fd --- /dev/null +++ b/.github/workflows/code-review.yml @@ -0,0 +1,48 @@ +name: Claude Code Review + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +jobs: + claude-review: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - uses: actions/checkout@v4 + + - uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + model: "claude-sonnet-4-6" + custom_instructions: | + 당신은 iOS/Swift 전문 코드 리뷰어입니다. 한국어로 리뷰를 작성하세요. + + ## 리뷰 기준 + - 잠재적 버그 및 로직 오류 + - 성능 이슈 (불필요한 연산, 메모리 누수, 비효율적인 데이터 처리 등) + - Swift/iOS 안티패턴 + - SwiftData, Vision Framework 등 프레임워크 오용 + + ## Pn 룰 적용 + 모든 리뷰 코멘트에 아래 우선순위를 명시하세요. + - P1: 반드시 수정 필요 (버그, 크래시 가능성) + - P2: 적극적으로 수정 권장 + - P3: 웬만하면 반영 권장 + - P4: 반영해도 좋고 넘어가도 좋음 + - P5: 사소한 의견 + + ## 형식 + 각 코멘트를 `[P1]`, `[P2]` 등으로 시작하고, 이유와 개선 방법을 함께 작성하세요. + 해결 방법이 여러 개인 경우, 각 방법을 간단히 나열하고 장단점을 요약한 뒤 추천 방법을 명시하세요. + 코드를 직접 수정하거나 커밋하지 말고, 제안만 간단하게 작성하세요. + 코드 제안 시 함수나 의미있는 블록 단위로 코드 블록을 제공하세요. diff --git a/.github/workflows/ios-ci.yml b/.github/workflows/ios-ci.yml new file mode 100644 index 0000000..3d42981 --- /dev/null +++ b/.github/workflows/ios-ci.yml @@ -0,0 +1,73 @@ +name: iOS CI + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main, dev ] + +jobs: + build-and-test: + name: Build and Test + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Show Xcode version + run: xcodebuild -version + + - name: Find latest iOS Simulator + id: find-simulator + run: | + ALL_DEVICES=$(xcrun simctl list devices available | grep "iPhone") + + NUMBERED_IPHONES=$(echo "$ALL_DEVICES" | grep -E "iPhone [0-9]+" | sed -E 's/.*iPhone ([0-9]+).*/\1/' | sort -n -u) + LATEST_NUMBER=$(echo "$NUMBERED_IPHONES" | tail -1) + + echo "최신 iPhone 버전: $LATEST_NUMBER" + + SIMULATOR_ID="" + for MODEL in "iPhone $LATEST_NUMBER Pro Max" "iPhone $LATEST_NUMBER Pro" "iPhone $LATEST_NUMBER Plus" "iPhone $LATEST_NUMBER"; do + FOUND=$(echo "$ALL_DEVICES" | grep "$MODEL" | tail -1) + if [ -n "$FOUND" ]; then + SIMULATOR_ID=$(echo "$FOUND" | grep -oE '[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}') + SIMULATOR_NAME=$(echo "$FOUND" | sed -E 's/^[[:space:]]*([^(]+).*/\1/' | xargs) + echo "simulator_id=$SIMULATOR_ID" >> $GITHUB_OUTPUT + echo "simulator_name=$SIMULATOR_NAME" >> $GITHUB_OUTPUT + echo "선택된 시뮬레이터: $SIMULATOR_NAME ($SIMULATOR_ID)" + break + fi + done + + if [ -z "$SIMULATOR_ID" ]; then + echo "사용 가능한 iPhone 시뮬레이터를 찾을 수 없습니다." + xcrun simctl list devices available + exit 1 + fi + + - name: Build SpendLearning + run: | + cd SpendLearning + xcodebuild clean build \ + -project SpendLearning.xcodeproj \ + -scheme SpendLearning \ + -destination "platform=iOS Simulator,id=${{ steps.find-simulator.outputs.simulator_id }}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO + + - name: Test SpendLearning + run: | + cd SpendLearning + xcodebuild test \ + -project SpendLearning.xcodeproj \ + -scheme SpendLearning \ + -destination "platform=iOS Simulator,id=${{ steps.find-simulator.outputs.simulator_id }}" \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ diff --git a/.gitignore b/.gitignore index 52fe2f7..99a492b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,110 @@ +# Created by https://www.toptal.com/developers/gitignore/api/swift,xcode,macos +# Edit at https://www.toptal.com/developers/gitignore?templates=swift,xcode,macos +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride +# Icon must end with two \r +Icon +# Thumbnails +._* +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +### macOS Patch ### +# iCloud generated files +*.icloud +### Swift ### # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore - ## User settings +*.xcuserstate +*.xcuserdatad xcuserdata/ - +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 ## Obj-C/Swift specific *.hmap - ## App packaging *.ipa *.dSYM.zip *.dSYM - ## Playgrounds timeline.xctimeline playground.xcworkspace - # Swift Package Manager -# # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved # *.xcodeproj -# # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm - .build/ - # CocoaPods -# # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# # Pods/ -# # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace - # Carthage -# # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts - Carthage/Build/ - +# Accio dependency management +Dependencies/ +.accio/ # fastlane -# # It is recommended to not store the screenshots in the git repo. # Instead, use fastlane to re-generate the screenshots whenever they are needed. # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/#source-control - fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output +# Code Injection +# After new code Injection tools there’s a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode +iOSInjectionProject/ +### Xcode ### +## Xcode 8 and earlier +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/swift,xcode,macos + diff --git a/README.md b/README.md index 3e5c14d..801cb47 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ # SpendLearning -소비학습 - 돈을 쓸 때마다 똑똑해지는, 나를 위한 지출 전용 머신러닝 가계부 + +소비 전용 가계부 앱. 지출만 기록하고, 소비 패턴을 학습해 피드백을 제공한다. + +--- + +## 주요 기능 + +### 소비 기록 +- 달력 화면에서 날짜 선택 후 소비 기록 +- 금액, 카테고리, 메모(선택) 입력 +- 최근 기록 빠른 입력 지원 + +### 통계 +- 카테고리별 비율 (원그래프) +- 이번 달 / 이번 년도 / 저번 년도 전환 가능 +- 이번 달·년도는 직전 기간 대비 증감 표시 + +### AI 피드백 +- 소비 패턴을 학습해 피드백 제공 + - 카테고리 이상 지출 감지 + - 고정 지출 패턴 알림 + - 절약 성공 칭찬 + - 이번 달 소비 예측 +- 메인 화면 상단 카드 + 푸시 알림으로 노출 + +### 토스 캡쳐 자동 기록 +- 토스 거래내역 캡쳐 후 앱에서 불러오면 OCR로 자동 파싱 +- 내용 확인 및 카테고리 선택 후 기록 완료 + +### 카테고리 +- 기본 제공: 식비, 카페/디저트, 교통, 쇼핑, 구독, 의료/건강, 여가/문화, 기타 +- 사용자 커스텀 추가 가능 + +### 기타 +- iCloud 백업/복원 +- 소비 기록 수정/삭제 +- 첫 실행 온보딩 (카테고리 설정, 토스 캡쳐 기능 안내) diff --git a/SpendLearning/SpendLearning.xcodeproj/project.pbxproj b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4443baa --- /dev/null +++ b/SpendLearning/SpendLearning.xcodeproj/project.pbxproj @@ -0,0 +1,525 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + BCB4150C2FF52D0700C9CABD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = BC0809DA2FF49EDA00AB3767 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BC0809E12FF49EDA00AB3767; + remoteInfo = SpendLearning; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + BC0809E22FF49EDA00AB3767 /* SpendLearning.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SpendLearning.app; sourceTree = BUILT_PRODUCTS_DIR; }; + BCB415082FF52D0700C9CABD /* SpendLearningTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SpendLearningTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + BC0809F42FF49EDB00AB3767 /* Exceptions for "SpendLearning" folder in "SpendLearning" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Resource/Info.plist, + ); + target = BC0809E12FF49EDA00AB3767 /* SpendLearning */; + }; + BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Source/Domain/Entities/Expense/CalendarDay.swift, + Source/Domain/Entities/Expense/Category.swift, + Source/Domain/Entities/Expense/Expense.swift, + Source/Domain/Entities/Prediction/CategoryPrediction.swift, + Source/Domain/Entities/Prediction/CumulativePrediction.swift, + Source/Domain/Entities/Prediction/PredictionModelMetadata.swift, + Source/Domain/Repositories/CategoryRepositoryProtocol.swift, + Source/Domain/Repositories/ExpenseRepositoryProtocol.swift, + Source/Domain/Repositories/PredictionRepositoryProtocol.swift, + Source/Domain/UseCases/CategoryUseCase.swift, + Source/Domain/UseCases/CategoryUseCaseProtocol.swift, + Source/Domain/UseCases/ExpenseUseCase.swift, + Source/Domain/UseCases/ExpenseUseCaseProtocol.swift, + Source/Domain/UseCases/PredictionUseCase.swift, + Source/Domain/UseCases/PredictionUseCaseProtocol.swift, + Source/Presentation/View/Home/HomeViewModel.swift, + Source/Presentation/View/NewExpense/NewExpenseViewModel.swift, + Source/Presentation/View/Prediction/PredictionViewModel.swift, + Source/Presentation/View/Settings/SettingsViewModel.swift, + ); + target = BCB415072FF52D0700C9CABD /* SpendLearningTests */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + BC0809E42FF49EDA00AB3767 /* SpendLearning */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + BC0809F42FF49EDB00AB3767 /* Exceptions for "SpendLearning" folder in "SpendLearning" target */, + BCB415242FF52E7D00C9CABD /* Exceptions for "SpendLearning" folder in "SpendLearningTests" target */, + ); + path = SpendLearning; + sourceTree = ""; + }; + BCB415092FF52D0700C9CABD /* SpendLearningTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = SpendLearningTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + BC0809DF2FF49EDA00AB3767 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BCB415052FF52D0700C9CABD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + BC0809D92FF49EDA00AB3767 = { + isa = PBXGroup; + children = ( + BC0809E42FF49EDA00AB3767 /* SpendLearning */, + BCB415092FF52D0700C9CABD /* SpendLearningTests */, + BC0809E32FF49EDA00AB3767 /* Products */, + ); + sourceTree = ""; + }; + BC0809E32FF49EDA00AB3767 /* Products */ = { + isa = PBXGroup; + children = ( + BC0809E22FF49EDA00AB3767 /* SpendLearning.app */, + BCB415082FF52D0700C9CABD /* SpendLearningTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + BC0809E12FF49EDA00AB3767 /* SpendLearning */ = { + isa = PBXNativeTarget; + buildConfigurationList = BC0809F52FF49EDB00AB3767 /* Build configuration list for PBXNativeTarget "SpendLearning" */; + buildPhases = ( + BC0809DE2FF49EDA00AB3767 /* Sources */, + BC0809DF2FF49EDA00AB3767 /* Frameworks */, + BC0809E02FF49EDA00AB3767 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + BC0809E42FF49EDA00AB3767 /* SpendLearning */, + ); + name = SpendLearning; + packageProductDependencies = ( + ); + productName = SpendLearning; + productReference = BC0809E22FF49EDA00AB3767 /* SpendLearning.app */; + productType = "com.apple.product-type.application"; + }; + BCB415072FF52D0700C9CABD /* SpendLearningTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = BCB4150E2FF52D0700C9CABD /* Build configuration list for PBXNativeTarget "SpendLearningTests" */; + buildPhases = ( + BCB415042FF52D0700C9CABD /* Sources */, + BCB415052FF52D0700C9CABD /* Frameworks */, + BCB415062FF52D0700C9CABD /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BCB4150D2FF52D0700C9CABD /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + BCB415092FF52D0700C9CABD /* SpendLearningTests */, + ); + name = SpendLearningTests; + packageProductDependencies = ( + ); + productName = SpendLearningTests; + productReference = BCB415082FF52D0700C9CABD /* SpendLearningTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + BC0809DA2FF49EDA00AB3767 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + BC0809E12FF49EDA00AB3767 = { + CreatedOnToolsVersion = 26.3; + }; + BCB415072FF52D0700C9CABD = { + CreatedOnToolsVersion = 26.3; + LastSwiftMigration = 2630; + }; + }; + }; + buildConfigurationList = BC0809DD2FF49EDA00AB3767 /* Build configuration list for PBXProject "SpendLearning" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = BC0809D92FF49EDA00AB3767; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = BC0809E32FF49EDA00AB3767 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + BC0809E12FF49EDA00AB3767 /* SpendLearning */, + BCB415072FF52D0700C9CABD /* SpendLearningTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + BC0809E02FF49EDA00AB3767 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BCB415062FF52D0700C9CABD /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + BC0809DE2FF49EDA00AB3767 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BCB415042FF52D0700C9CABD /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + BCB4150D2FF52D0700C9CABD /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BC0809E12FF49EDA00AB3767 /* SpendLearning */; + targetProxy = BCB4150C2FF52D0700C9CABD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + BC0809F62FF49EDB00AB3767 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RS49L5FVHY; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SpendLearning/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "소비학습"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearning; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + BC0809F72FF49EDB00AB3767 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RS49L5FVHY; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = SpendLearning/Resource/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "소비학습"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearning; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + BC0809F82FF49EDB00AB3767 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = RS49L5FVHY; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + BC0809F92FF49EDB00AB3767 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = RS49L5FVHY; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + BCB4150F2FF52D0700C9CABD /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RS49L5FVHY; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearningTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + BCB415102FF52D0700C9CABD /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = RS49L5FVHY; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIUserInterfaceStyle = Light; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sunghun.SpendLearningTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + BC0809DD2FF49EDA00AB3767 /* Build configuration list for PBXProject "SpendLearning" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BC0809F82FF49EDB00AB3767 /* Debug */, + BC0809F92FF49EDB00AB3767 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BC0809F52FF49EDB00AB3767 /* Build configuration list for PBXNativeTarget "SpendLearning" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BC0809F62FF49EDB00AB3767 /* Debug */, + BC0809F72FF49EDB00AB3767 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + BCB4150E2FF52D0700C9CABD /* Build configuration list for PBXNativeTarget "SpendLearningTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BCB4150F2FF52D0700C9CABD /* Debug */, + BCB415102FF52D0700C9CABD /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = BC0809DA2FF49EDA00AB3767 /* Project object */; +} diff --git a/SpendLearning/SpendLearning.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SpendLearning/SpendLearning.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SpendLearning/SpendLearning.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SpendLearning/SpendLearning.xcodeproj/xcshareddata/xcschemes/SpendLearning.xcscheme b/SpendLearning/SpendLearning.xcodeproj/xcshareddata/xcschemes/SpendLearning.xcscheme new file mode 100644 index 0000000..4313628 --- /dev/null +++ b/SpendLearning/SpendLearning.xcodeproj/xcshareddata/xcschemes/SpendLearning.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SpendLearning/SpendLearning.xcodeproj/xcshareddata/xcschemes/SpendLearningTests.xcscheme b/SpendLearning/SpendLearning.xcodeproj/xcshareddata/xcschemes/SpendLearningTests.xcscheme new file mode 100644 index 0000000..88b6bc8 --- /dev/null +++ b/SpendLearning/SpendLearning.xcodeproj/xcshareddata/xcschemes/SpendLearningTests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/SpendLearning/SpendLearning/Resource/Assets.xcassets/AccentColor.colorset/Contents.json b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0191a49 --- /dev/null +++ b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "appicon.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/appicon.png b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/appicon.png new file mode 100644 index 0000000..53b5922 Binary files /dev/null and b/SpendLearning/SpendLearning/Resource/Assets.xcassets/AppIcon.appiconset/appicon.png differ diff --git a/SpendLearning/SpendLearning/Resource/Assets.xcassets/Contents.json b/SpendLearning/SpendLearning/Resource/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/SpendLearning/SpendLearning/Resource/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SpendLearning/SpendLearning/Resource/Info.plist b/SpendLearning/SpendLearning/Resource/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/SpendLearning/SpendLearning/Resource/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/SpendLearning/SpendLearning/Source/App/AppDelegate.swift b/SpendLearning/SpendLearning/Source/App/AppDelegate.swift new file mode 100644 index 0000000..6f70cd1 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/App/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/SpendLearning/SpendLearning/Source/App/Base.lproj/LaunchScreen.storyboard b/SpendLearning/SpendLearning/Source/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift new file mode 100644 index 0000000..4b90f73 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/App/SceneDelegate.swift @@ -0,0 +1,110 @@ +// +// SceneDelegate.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit +import SwiftData +import SwiftUI + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + window = UIWindow(windowScene: windowScene) + window?.rootViewController = makeTabBarController() + window?.makeKeyAndVisible() + } + + private func makeTabBarController() -> UITabBarController { + let container: ModelContainer + do { + container = try ModelContainer(for: ExpenseModel.self, CategoryModel.self, PredictionModel.self) + } catch { + // SwiftData 마이그레이션 실패 시 메모리 전용 스토어로 폴백한다. + // 이 경로를 타면 기존에 저장된 사용자 데이터(지출/카테고리/예측 모델)가 이번 실행에서 보이지 않는다. + // 스키마 변경 후 이 로그가 찍히면 VersionedSchema/SchemaMigrationPlan 도입을 검토할 것. + print("⚠️ ModelContainer 생성 실패, 메모리 전용으로 폴백: \(error)") + container = try! ModelContainer( + for: ExpenseModel.self, CategoryModel.self, PredictionModel.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + } + let modelContext = ModelContext(container) + + let categoryRepository = SwiftDataCategoryRepository(modelContext: modelContext) + let categoryUseCase = CategoryUseCase(repository: categoryRepository) + +// let expenseRepository = MockExpenseRepository() + let expenseRepository = SwiftDataExpenseRepository(modelContext: modelContext, categoryRepository: categoryRepository) + let expenseUseCase = ExpenseUseCase(repository: expenseRepository) + + let homeViewModel = HomeViewModel(expenseUseCase: expenseUseCase) + let homeViewController = HomeViewController(viewModel: homeViewModel, categoryUseCase: categoryUseCase) + homeViewController.tabBarItem = UITabBarItem( + title: "홈", + image: UIImage(systemName: "house"), + selectedImage: UIImage(systemName: "house.fill") + ) + + let predictionUseCase = PredictionUseCase(repository: SwiftDataPredictionRepository(modelContext: modelContext, expenseRepository: expenseRepository), expenseRepository: expenseRepository) + let predictionViewModel = PredictionViewModel(expenseUseCase: expenseUseCase, predictionUseCase: predictionUseCase) + let predictionViewController = UIHostingController(rootView: PredictionView(viewModel: predictionViewModel)) + predictionViewController.tabBarItem = UITabBarItem( + title: "예측", + image: UIImage(systemName: "brain"), + selectedImage: UIImage(systemName: "brain.fill") + ) + + let settingsViewController = SettingsViewController( + categoryUseCase: categoryUseCase, + expenseUseCase: expenseUseCase, + predictionUseCase: predictionUseCase + ) + settingsViewController.tabBarItem = UITabBarItem( + title: "설정", + image: UIImage(systemName: "slider.horizontal.3"), + selectedImage: UIImage(systemName: "slider.horizontal.3") + ) + + let tabBar = UITabBarController() + tabBar.viewControllers = [homeViewController, predictionViewController, settingsViewController] + tabBar.tabBar.tintColor = .DesignSystem.accent + + return tabBar + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } +} + diff --git a/SpendLearning/SpendLearning/Source/Data/Models/CategoryModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/CategoryModel.swift new file mode 100644 index 0000000..f72bad8 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Models/CategoryModel.swift @@ -0,0 +1,36 @@ +// +// CategoryModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import SwiftData + +@Model +final class CategoryModel { + + var id: UUID + var name: String + var emoji: String + var order: Int + var isDefault: Bool + var isDeletable: Bool + + init( + id: UUID = UUID(), + name: String, + emoji: String, + order: Int, + isDefault: Bool, + isDeletable: Bool + ) { + self.id = id + self.name = name + self.emoji = emoji + self.order = order + self.isDefault = isDefault + self.isDeletable = isDeletable + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Models/ExpenseModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/ExpenseModel.swift new file mode 100644 index 0000000..4a61914 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Models/ExpenseModel.swift @@ -0,0 +1,33 @@ +// +// ExpenseModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import SwiftData + +@Model +final class ExpenseModel { + + var id: UUID + var date: Date + var categoryID: UUID + var memo: String? + var amount: Int + + init( + id: UUID = UUID(), + date: Date, + categoryID: UUID, + memo: String?, + amount: Int + ) { + self.id = id + self.date = date + self.categoryID = categoryID + self.memo = memo + self.amount = amount + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Models/PredictionModel.swift b/SpendLearning/SpendLearning/Source/Data/Models/PredictionModel.swift new file mode 100644 index 0000000..e216bc4 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Models/PredictionModel.swift @@ -0,0 +1,48 @@ +// +// PredictionModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation +import SwiftData + +@Model +final class PredictionModel { + + var id: String + var dataCount: Int + var createdAt: Date + private var dailyPredictionsData: Data + private var categoryPredictionsData: Data + + var dailyPredictions: [Int: Int] { + (try? JSONDecoder().decode([Int: Int].self, from: dailyPredictionsData)) ?? [:] + } + + var categoryPredictions: [String: Int] { + (try? JSONDecoder().decode([String: Int].self, from: categoryPredictionsData)) ?? [:] + } + + init( + id: String, + dataCount: Int, + createdAt: Date, + dailyPredictions: [Int: Int], + categoryPredictions: [String: Int] + ) { + self.id = id + self.dataCount = dataCount + self.createdAt = createdAt + self.dailyPredictionsData = (try? JSONEncoder().encode(dailyPredictions)) ?? Data() + self.categoryPredictionsData = (try? JSONEncoder().encode(categoryPredictions)) ?? Data() + } + + func update(dataCount: Int, createdAt: Date, dailyPredictions: [Int: Int], categoryPredictions: [String: Int]) { + self.dataCount = dataCount + self.createdAt = createdAt + self.dailyPredictionsData = (try? JSONEncoder().encode(dailyPredictions)) ?? Data() + self.categoryPredictionsData = (try? JSONEncoder().encode(categoryPredictions)) ?? Data() + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Predictors/StatisticsPredictor.swift b/SpendLearning/SpendLearning/Source/Data/Predictors/StatisticsPredictor.swift new file mode 100644 index 0000000..36ff5c1 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Predictors/StatisticsPredictor.swift @@ -0,0 +1,159 @@ +// +// StatisticsPredictor.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class StatisticsPredictor { + + /// 패턴으로 신뢰하기 위한 최소 표본 수 + private let minimumSampleCount = 2 + /// 패턴으로 신뢰하기 위한 변동계수(표준편차/평균) 임계값. 낮을수록 값이 일관됨을 의미 + private let coefficientOfVariationThreshold = 0.5 + + // MARK: - predictDaily + + /// 이번 달 일별 예측 지출 금액을 반환한다. + /// 폴백 순서 (각 순위는 표본이 2건 이상이고 변동계수가 임계값 이하일 때만 채택한다): + /// 1. 과거 같은 날짜(dayOfMonth) 지출 평균 — 정기결제 등 날짜 기반 패턴 + /// 2. 데이터가 패턴으로 인정되지 않으면 → 과거 같은 요일(weekday) 지출 평균 — 습관성 소비 패턴 + /// 3. 그것도 패턴으로 인정되지 않으면 → 과거 월 평균 총액 / 이번 달 일수 + func predictDaily(expenses: [Expense], year: Int, month: Int) async -> [Int: Int] { + let calendar = Calendar.current + guard let targetDate = calendar.date(from: DateComponents(year: year, month: month, day: 1)), + let dayRange = calendar.range(of: .day, in: .month, for: targetDate) else { + return [:] + } + + // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 + let pastExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == year && m == month) + } + + let monthlyTotal = monthlyAverage(expenses: pastExpenses) + + var result: [Int: Int] = [:] + for day in dayRange { + guard let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) else { continue } + let dayOfMonth = calendar.component(.day, from: date) + let weekday = calendar.component(.weekday, from: date) + + if let avg = reliableDayOfMonthAverage(expenses: pastExpenses, dayOfMonth: dayOfMonth) { + // 1순위: 같은 날짜의 일관된 평균 (정기결제 등) + result[day] = avg + } else if let avg = reliableWeekdayAverage(expenses: pastExpenses, weekday: weekday) { + // 2순위: 같은 요일의 일관된 평균 (습관성 소비) + result[day] = avg + } else { + // 3순위: 월 평균 총액 / 일수 + result[day] = monthlyTotal / dayRange.count + } + } + return result + } + + // MARK: - predictCategory + + /// 이번 달 카테고리별 예측 지출 금액을 반환한다. + /// 과거 카테고리별 월 평균을 계산해 반환한다. + func predictCategory(expenses: [Expense], year: Int, month: Int) async -> [String: Int] { + let calendar = Calendar.current + + // 예측 대상 달(이번 달)은 아직 진행 중이므로 평균 계산에서 제외 + let pastExpenses = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == year && m == month) + } + + // 보유한 달 수 계산 + let months = Set(pastExpenses.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let monthCount = max(months.count, 1) + + // 카테고리별 총합 / 달 수 = 월 평균 + var categoryTotals: [String: Int] = [:] + for expense in pastExpenses { + categoryTotals[expense.category.name, default: 0] += expense.amount + } + + return categoryTotals.mapValues { $0 / monthCount } + } + + // MARK: - Private helpers (predictDaily) + + /// 같은 날짜(dayOfMonth)의 지출을 월별로 합산해, 표본이 충분하고 변동계수가 임계값 이하일 때만 평균을 반환한다. + private func reliableDayOfMonthAverage(expenses: [Expense], dayOfMonth: Int) -> Int? { + let calendar = Calendar.current + let matched = expenses.filter { + calendar.component(.day, from: $0.date) == dayOfMonth + } + let monthlyTotals = monthlyTotals(of: matched, calendar: calendar) + return reliableAverage(of: monthlyTotals) + } + + /// 같은 요일(weekday)의 지출을 주별로 합산해, 표본이 충분하고 변동계수가 임계값 이하일 때만 평균을 반환한다. + private func reliableWeekdayAverage(expenses: [Expense], weekday: Int) -> Int? { + let calendar = Calendar.current + let matched = expenses.filter { + calendar.component(.weekday, from: $0.date) == weekday + } + let weeklyTotals = weeklyTotals(of: matched, calendar: calendar) + return reliableAverage(of: weeklyTotals) + } + + /// 지출을 연-월 단위로 묶어 각 달의 합계 목록을 반환한다. + private func monthlyTotals(of expenses: [Expense], calendar: Calendar) -> [Int] { + Dictionary(grouping: expenses) { + "\(calendar.component(.year, from: $0.date))-\(calendar.component(.month, from: $0.date))" + } + .values + .map { group in group.reduce(0) { $0 + $1.amount } } + } + + /// 지출을 연-주 단위로 묶어 각 주의 합계 목록을 반환한다. + private func weeklyTotals(of expenses: [Expense], calendar: Calendar) -> [Int] { + Dictionary(grouping: expenses) { + "\(calendar.component(.year, from: $0.date))-\(calendar.component(.weekOfYear, from: $0.date))" + } + .values + .map { group in group.reduce(0) { $0 + $1.amount } } + } + + /// 표본이 최소 개수 이상이고, 변동계수가 임계값 이하로 일관될 때만 평균을 반환한다. + /// 표본이 부족하거나(우연 배제) 값이 들쭉날쭉하면(패턴 아님) nil을 반환해 다음 순위로 폴백시킨다. + private func reliableAverage(of samples: [Int]) -> Int? { + guard samples.count >= minimumSampleCount else { return nil } + + let mean = Double(samples.reduce(0, +)) / Double(samples.count) + guard mean > 0 else { return nil } + + let variance = samples.reduce(0.0) { $0 + pow(Double($1) - mean, 2) } / Double(samples.count) + let standardDeviation = variance.squareRoot() + let coefficientOfVariation = standardDeviation / mean + + guard coefficientOfVariation <= coefficientOfVariationThreshold else { return nil } + + return Int(mean) + } + + /// 과거 데이터의 월 평균 총 지출 + private func monthlyAverage(expenses: [Expense]) -> Int { + let calendar = Calendar.current + let months = Set(expenses.map { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return "\(y)-\(m)" + }) + let total = expenses.reduce(0) { $0 + $1.amount } + return total / max(months.count, 1) + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift new file mode 100644 index 0000000..aa38b37 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/MockExpenseRepository.swift @@ -0,0 +1,154 @@ +// +// MockExpenseRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation + +final class MockExpenseRepository: ExpenseRepositoryProtocol { + + private var expenses: [Expense] = { + let calendar = Calendar.current + let now = Date() + let year = calendar.component(.year, from: now) + let month = calendar.component(.month, from: now) + + let food = Category(name: "식비", emoji: "🍚") + let cafe = Category(name: "카페/간식", emoji: "☕️") + let transport = Category(name: "교통", emoji: "🚌") + let shopping = Category(name: "쇼핑", emoji: "🛍️") + let medical = Category(name: "의료/건강", emoji: "💊") + + // 특정 달의 특정 일 날짜 생성 + func date(monthOffset: Int, day: Int) -> Date { + let targetMonth = month + monthOffset + let targetYear = year + (targetMonth - 1) / 12 + var components = DateComponents() + components.year = targetYear + components.month = ((targetMonth - 1 + 12) % 12) + 1 + components.day = day + return calendar.date(from: components)! + } + + // MARK: - 이번 달 일반 지출 + let thisMonth: [Expense] = [ + Expense(date: date(monthOffset: 0, day: 1), category: food, memo: "김밥천국", amount: 8500), + Expense(date: date(monthOffset: 0, day: 3), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: 0, day: 8), category: transport, memo: "택시", amount: 12000), + Expense(date: date(monthOffset: 0, day: 10), category: shopping, memo: "다이소", amount: 15000), + Expense(date: date(monthOffset: 0, day: 10), category: food, memo: "한식당", amount: 12000), + Expense(date: date(monthOffset: 0, day: 10), category: medical, memo: "약국", amount: 8500), + ] + + // MARK: - 과거 일반 지출 + let oneMonthAgo: [Expense] = [ + Expense(date: date(monthOffset: -1, day: 2), category: food, memo: "김밥천국", amount: 7500), + Expense(date: date(monthOffset: -1, day: 5), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: -1, day: 9), category: transport, memo: "택시", amount: 11000), + Expense(date: date(monthOffset: -1, day: 11), category: shopping, memo: "다이소", amount: 13000), + Expense(date: date(monthOffset: -1, day: 11), category: food, memo: "한식당", amount: 11000), + Expense(date: date(monthOffset: -1, day: 14), category: cafe, memo: "투썸플레이스", amount: 5800), + Expense(date: date(monthOffset: -1, day: 16), category: food, memo: "점심", amount: 8500), + Expense(date: date(monthOffset: -1, day: 18), category: medical, memo: "약국", amount: 7500), + Expense(date: date(monthOffset: -1, day: 20), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -1, day: 22), category: food, memo: "저녁", amount: 55000), + Expense(date: date(monthOffset: -1, day: 25), category: cafe, memo: "카페", amount: 14000), + Expense(date: date(monthOffset: -1, day: 27), category: shopping, memo: "쿠팡", amount: 98000), + Expense(date: date(monthOffset: -1, day: 29), category: food, memo: "외식", amount: 28000), + ] + + let twoMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -2, day: 1), category: food, memo: "김밥천국", amount: 8000), + Expense(date: date(monthOffset: -2, day: 4), category: cafe, memo: "스타벅스", amount: 5500), + Expense(date: date(monthOffset: -2, day: 7), category: transport, memo: "택시", amount: 13000), + Expense(date: date(monthOffset: -2, day: 10), category: food, memo: "한식당", amount: 10500), + Expense(date: date(monthOffset: -2, day: 12), category: shopping, memo: "올리브영", amount: 22000), + Expense(date: date(monthOffset: -2, day: 15), category: cafe, memo: "투썸플레이스", amount: 6200), + Expense(date: date(monthOffset: -2, day: 17), category: food, memo: "점심", amount: 9500), + Expense(date: date(monthOffset: -2, day: 19), category: medical, memo: "병원", amount: 15000), + Expense(date: date(monthOffset: -2, day: 21), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -2, day: 23), category: food, memo: "저녁", amount: 48000), + Expense(date: date(monthOffset: -2, day: 26), category: cafe, memo: "카페", amount: 12000), + Expense(date: date(monthOffset: -2, day: 28), category: shopping, memo: "쿠팡", amount: 75000), + ] + + let threeMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -3, day: 2), category: food, memo: "김밥천국", amount: 7000), + Expense(date: date(monthOffset: -3, day: 5), category: cafe, memo: "스타벅스", amount: 6000), + Expense(date: date(monthOffset: -3, day: 8), category: transport, memo: "택시", amount: 10000), + Expense(date: date(monthOffset: -3, day: 11), category: food, memo: "한식당", amount: 12000), + Expense(date: date(monthOffset: -3, day: 13), category: medical, memo: "약국", amount: 8000), + Expense(date: date(monthOffset: -3, day: 16), category: cafe, memo: "투썸플레이스", amount: 5500), + Expense(date: date(monthOffset: -3, day: 18), category: shopping, memo: "다이소", amount: 18000), + Expense(date: date(monthOffset: -3, day: 20), category: food, memo: "점심", amount: 8000), + Expense(date: date(monthOffset: -3, day: 22), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -3, day: 24), category: food, memo: "저녁", amount: 42000), + Expense(date: date(monthOffset: -3, day: 27), category: cafe, memo: "카페", amount: 11000), + Expense(date: date(monthOffset: -3, day: 29), category: shopping, memo: "쿠팡", amount: 65000), + ] + + let fourMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -4, day: 1), category: food, memo: "김밥천국", amount: 8500), + Expense(date: date(monthOffset: -4, day: 4), category: cafe, memo: "스타벅스", amount: 5800), + Expense(date: date(monthOffset: -4, day: 7), category: transport, memo: "택시", amount: 12000), + Expense(date: date(monthOffset: -4, day: 9), category: food, memo: "한식당", amount: 11500), + Expense(date: date(monthOffset: -4, day: 12), category: shopping, memo: "올리브영", amount: 19000), + Expense(date: date(monthOffset: -4, day: 14), category: cafe, memo: "투썸플레이스", amount: 6000), + Expense(date: date(monthOffset: -4, day: 17), category: food, memo: "점심", amount: 9000), + Expense(date: date(monthOffset: -4, day: 19), category: medical, memo: "병원", amount: 12000), + Expense(date: date(monthOffset: -4, day: 21), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -4, day: 23), category: food, memo: "저녁", amount: 51000), + Expense(date: date(monthOffset: -4, day: 26), category: cafe, memo: "카페", amount: 13000), + Expense(date: date(monthOffset: -4, day: 28), category: shopping, memo: "쿠팡", amount: 88000), + ] + + let fiveMonthsAgo: [Expense] = [ + Expense(date: date(monthOffset: -5, day: 2), category: food, memo: "김밥천국", amount: 7500), + Expense(date: date(monthOffset: -5, day: 5), category: cafe, memo: "스타벅스", amount: 6200), + Expense(date: date(monthOffset: -5, day: 8), category: transport, memo: "택시", amount: 11500), + Expense(date: date(monthOffset: -5, day: 10), category: food, memo: "한식당", amount: 10000), + Expense(date: date(monthOffset: -5, day: 13), category: shopping, memo: "다이소", amount: 14000), + Expense(date: date(monthOffset: -5, day: 15), category: cafe, memo: "투썸플레이스", amount: 5500), + Expense(date: date(monthOffset: -5, day: 18), category: food, memo: "점심", amount: 8500), + Expense(date: date(monthOffset: -5, day: 20), category: medical, memo: "약국", amount: 9000), + Expense(date: date(monthOffset: -5, day: 22), category: transport, memo: "지하철", amount: 4500), + Expense(date: date(monthOffset: -5, day: 24), category: food, memo: "저녁", amount: 45000), + Expense(date: date(monthOffset: -5, day: 27), category: cafe, memo: "카페", amount: 12500), + Expense(date: date(monthOffset: -5, day: 29), category: shopping, memo: "쿠팡", amount: 72000), + ] + + return thisMonth +// + oneMonthAgo +// + twoMonthsAgo +// + threeMonthsAgo +// + fourMonthsAgo +// + fiveMonthsAgo + }() + + func fetchExpenses(year: Int, month: Int) async -> [Expense] { + let calendar = Calendar.current + return expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return y == year && m == month + } + } + + func fetchAllExpenses() async -> [Expense] { + expenses + } + + func deleteExpense(_ expense: Expense) async { + expenses.removeAll { $0.id == expense.id } + } + + func deleteAll() async { + expenses.removeAll() + } + + func addExpense(_ expense: Expense) async { + expenses.append(expense) + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataCategoryRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataCategoryRepository.swift new file mode 100644 index 0000000..40fa367 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataCategoryRepository.swift @@ -0,0 +1,145 @@ +// +// SwiftDataCategoryRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import SwiftData + +final class SwiftDataCategoryRepository: CategoryRepositoryProtocol { + + private let modelContext: ModelContext + + init(modelContext: ModelContext) { + self.modelContext = modelContext + } + + func fetchCategories() async throws -> [Category] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.order)] + ) + let models = try modelContext.fetch(descriptor) + if models.isEmpty { + let defaults = defaultCategories() + defaults.forEach { modelContext.insert($0) } + try modelContext.save() + return defaults.map { toCategory($0) } + } + return models.map { toCategory($0) } + } + + func addCategory(name: String, emoji: String) async throws { + let count = try modelContext.fetch(FetchDescriptor()).count + let model = CategoryModel(name: name, emoji: emoji, order: count, isDefault: false, isDeletable: true) + modelContext.insert(model) + try modelContext.save() + } + + func updateCategory(_ category: Category, name: String, emoji: String) async throws { + let targetID = category.id + let predicate = #Predicate { $0.id == targetID } + let descriptor = FetchDescriptor(predicate: predicate) + guard let model = try modelContext.fetch(descriptor).first else { return } + model.name = name + model.emoji = emoji + try modelContext.save() + } + + func deleteCategory(_ category: Category) async throws { + let targetID = category.id + + let expensePredicate = #Predicate { $0.categoryID == targetID } + let expenses = try modelContext.fetch(FetchDescriptor(predicate: expensePredicate)) + let fallbackID = try fetchFallbackCategoryID() + expenses.forEach { $0.categoryID = fallbackID } + + let categoryPredicate = #Predicate { $0.id == targetID } + guard let model = try modelContext.fetch(FetchDescriptor(predicate: categoryPredicate)).first else { return } + modelContext.delete(model) + + try modelContext.save() + } + + func resetToDefault() async throws { + // 기존 DB에서 isDefault인 카테고리 ID 수집 + let existingDefaultPredicate = #Predicate { $0.isDefault } + let existingDefaults = try modelContext.fetch(FetchDescriptor(predicate: existingDefaultPredicate)) + let existingDefaultIDs = Set(existingDefaults.map { $0.id }) + + // 커스텀 카테고리에 속한 지출만 기타로 변경 + let fallbackID = try fetchFallbackCategoryID() + let allExpenses = try modelContext.fetch(FetchDescriptor()) + allExpenses.forEach { + if !existingDefaultIDs.contains($0.categoryID) { + $0.categoryID = fallbackID + } + } + + // 커스텀 카테고리만 삭제 + let customPredicate = #Predicate { !$0.isDefault } + let customCategories = try modelContext.fetch(FetchDescriptor(predicate: customPredicate)) + customCategories.forEach { modelContext.delete($0) } + + // 기본 카테고리 order 복구 + let defaultOrderByName = Dictionary(uniqueKeysWithValues: defaultCategories().map { ($0.name, $0.order) }) + existingDefaults.forEach { + if let order = defaultOrderByName[$0.name] { + $0.order = order + } + } + + try modelContext.save() + } + + func reorderCategories(_ categories: [Category]) async throws { + let allModels = try modelContext.fetch(FetchDescriptor()) + let modelByID = Dictionary(uniqueKeysWithValues: allModels.map { ($0.id, $0) }) + for (index, category) in categories.enumerated() { + modelByID[category.id]?.order = index + } + try modelContext.save() + } +} + +// MARK: - Mapping +private extension SwiftDataCategoryRepository { + + func toCategory(_ model: CategoryModel) -> Category { + Category( + id: model.id, + name: model.name, + emoji: model.emoji, + isDeletable: model.isDeletable + ) + } + + func fetchFallbackCategoryID() throws -> UUID { + let predicate = #Predicate { !$0.isDeletable } + let descriptor = FetchDescriptor(predicate: predicate) + guard let id = try modelContext.fetch(descriptor).first?.id else { + throw CategoryRepositoryError.fallbackCategoryNotFound + } + return id + } + + func defaultCategories() -> [CategoryModel] { + [ + CategoryModel(name: "식비", emoji: "🍚", order: 0, isDefault: true, isDeletable: true), + CategoryModel(name: "카페/간식", emoji: "☕️", order: 1, isDefault: true, isDeletable: true), + CategoryModel(name: "교통", emoji: "🚌", order: 2, isDefault: true, isDeletable: true), + CategoryModel(name: "쇼핑", emoji: "🛍️", order: 3, isDefault: true, isDeletable: true), + CategoryModel(name: "여가", emoji: "🎮", order: 4, isDefault: true, isDeletable: true), + CategoryModel(name: "통신비", emoji: "📞", order: 5, isDefault: true, isDeletable: true), + CategoryModel(name: "의료/건강", emoji: "💊", order: 6, isDefault: true, isDeletable: true), + CategoryModel(name: "구독", emoji: "🧾", order: 7, isDefault: true, isDeletable: true), + CategoryModel(name: "경조사", emoji: "✉️", order: 8, isDefault: true, isDeletable: true), + CategoryModel(name: "기타", emoji: "📦", order: 9, isDefault: true, isDeletable: false), + ] + } +} + +enum CategoryRepositoryError: Error { + case fallbackCategoryNotFound +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift new file mode 100644 index 0000000..73af6db --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataExpenseRepository.swift @@ -0,0 +1,94 @@ +// +// SwiftDataExpenseRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import SwiftData + +final class SwiftDataExpenseRepository: ExpenseRepositoryProtocol { + + private let modelContext: ModelContext + private let categoryRepository: CategoryRepositoryProtocol + + init(modelContext: ModelContext, categoryRepository: CategoryRepositoryProtocol) { + self.modelContext = modelContext + self.categoryRepository = categoryRepository + } + + func fetchExpenses(year: Int, month: Int) async -> [Expense] { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + let calendar = Calendar.current + guard let startDate = calendar.date(from: components), + let endDate = calendar.date(byAdding: .month, value: 1, to: startDate) + else { return [] } + + let predicate = #Predicate { model in + model.date >= startDate && model.date < endDate + } + let descriptor = FetchDescriptor(predicate: predicate) + let models = (try? modelContext.fetch(descriptor)) ?? [] + let categories = (try? await categoryRepository.fetchCategories()) ?? [] + return models.map { toExpense($0, categories: categories) } + } + + func fetchAllExpenses() async -> [Expense] { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + let categories = (try? await categoryRepository.fetchCategories()) ?? [] + return models.map { toExpense($0, categories: categories) } + } + + func addExpense(_ expense: Expense) async { + let model = toModel(expense) + modelContext.insert(model) + try? modelContext.save() + } + + func deleteExpense(_ expense: Expense) async { + let targetID = expense.id + let predicate = #Predicate { $0.id == targetID } + let descriptor = FetchDescriptor(predicate: predicate) + guard let model = try? modelContext.fetch(descriptor).first else { return } + modelContext.delete(model) + try? modelContext.save() + } + + func deleteAll() async { + let descriptor = FetchDescriptor() + let models = (try? modelContext.fetch(descriptor)) ?? [] + models.forEach { modelContext.delete($0) } + try? modelContext.save() + } +} + +// MARK: - Mapping +private extension SwiftDataExpenseRepository { + + func toExpense(_ model: ExpenseModel, categories: [Category]) -> Expense { + let fallback = Category(name: "기타", emoji: "📦", isDeletable: false) + let category = categories.first { $0.id == model.categoryID } ?? fallback + return Expense( + id: model.id, + date: model.date, + category: category, + memo: model.memo, + amount: model.amount + ) + } + + func toModel(_ expense: Expense) -> ExpenseModel { + ExpenseModel( + id: expense.id, + date: expense.date, + categoryID: expense.category.id, + memo: expense.memo, + amount: expense.amount + ) + } +} diff --git a/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataPredictionRepository.swift b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataPredictionRepository.swift new file mode 100644 index 0000000..68221f9 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Data/Repositories/SwiftDataPredictionRepository.swift @@ -0,0 +1,92 @@ +// +// SwiftDataPredictionRepository.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation +import SwiftData + +final class SwiftDataPredictionRepository: PredictionRepositoryProtocol { + + private let modelContext: ModelContext + private let expenseRepository: ExpenseRepositoryProtocol + private let predictor = StatisticsPredictor() + + init( + modelContext: ModelContext, + expenseRepository: ExpenseRepositoryProtocol + ) { + self.modelContext = modelContext + self.expenseRepository = expenseRepository + } + + func fetchCurrentModel() async -> PredictionModelMetadata? { + fetchStoredModel().map { toMetadata($0) } + } + + func deleteModel() async { + guard let existing = fetchStoredModel() else { return } + modelContext.delete(existing) + try? modelContext.save() + } + + func recalculate(expenses: [Expense]) async -> PredictionModelMetadata { + let calendar = Calendar.current + let now = Date() + let currentYear = calendar.component(.year, from: now) + let currentMonth = calendar.component(.month, from: now) + + // 예측 계산에는 이번 달을 제외한 과거 데이터만 사용되므로, 표시용 데이터 개수도 이에 맞춘다. + let pastExpenseCount = expenses.filter { + let y = calendar.component(.year, from: $0.date) + let m = calendar.component(.month, from: $0.date) + return !(y == currentYear && m == currentMonth) + }.count + + let dailyPredictions = await predictor.predictDaily(expenses: expenses, year: currentYear, month: currentMonth) + let categoryPredictions = await predictor.predictCategory(expenses: expenses, year: currentYear, month: currentMonth) + + if let existing = fetchStoredModel() { + existing.update( + dataCount: pastExpenseCount, + createdAt: now, + dailyPredictions: dailyPredictions, + categoryPredictions: categoryPredictions + ) + try? modelContext.save() + return toMetadata(existing) + } + + let id = "SP\(UUID().uuidString.prefix(6).lowercased())" + let model = PredictionModel( + id: id, + dataCount: pastExpenseCount, + createdAt: now, + dailyPredictions: dailyPredictions, + categoryPredictions: categoryPredictions + ) + modelContext.insert(model) + try? modelContext.save() + + return toMetadata(model) + } + + // MARK: - Private + + private func fetchStoredModel() -> PredictionModel? { + let descriptor = FetchDescriptor() + return ((try? modelContext.fetch(descriptor)) ?? []).first + } + + private func toMetadata(_ model: PredictionModel) -> PredictionModelMetadata { + PredictionModelMetadata( + id: model.id, + dataCount: model.dataCount, + createdAt: model.createdAt, + dailyPredictions: model.dailyPredictions, + categoryPredictions: model.categoryPredictions + ) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift new file mode 100644 index 0000000..44abd3b --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/CalendarDay.swift @@ -0,0 +1,14 @@ +// +// CalendarDay.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation + +struct CalendarDay { + let date: Date? + let amount: Int? + let isToday: Bool +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift new file mode 100644 index 0000000..542793e --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Category.swift @@ -0,0 +1,29 @@ +// +// Category.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation + +struct Category: Identifiable { + let id: UUID + var name: String + var emoji: String + var isDeletable: Bool + + var displayName: String { name } + + init( + id: UUID = UUID(), + name: String, + emoji: String, + isDeletable: Bool = true + ) { + self.id = id + self.name = name + self.emoji = emoji + self.isDeletable = isDeletable + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift new file mode 100644 index 0000000..41e1879 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Expense/Expense.swift @@ -0,0 +1,30 @@ +// +// Expense.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation + +struct Expense { + let id: UUID + let date: Date + let category: Category + let memo: String? + let amount: Int + + init( + id: UUID = UUID(), + date: Date, + category: Category, + memo: String?, + amount: Int + ) { + self.id = id + self.date = date + self.category = category + self.memo = memo + self.amount = amount + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CategoryPrediction.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CategoryPrediction.swift new file mode 100644 index 0000000..1064403 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CategoryPrediction.swift @@ -0,0 +1,12 @@ +// +// CategoryPrediction.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +struct CategoryPrediction { + let categoryName: String + let actual: Int + let predicted: Int? +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CumulativePrediction.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CumulativePrediction.swift new file mode 100644 index 0000000..d5382a4 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/CumulativePrediction.swift @@ -0,0 +1,12 @@ +// +// CumulativePrediction.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +struct CumulativePrediction { + let day: Int + let actual: Int? + let predicted: Int? +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/PredictionModelMetadata.swift b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/PredictionModelMetadata.swift new file mode 100644 index 0000000..48eec01 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Entities/Prediction/PredictionModelMetadata.swift @@ -0,0 +1,16 @@ +// +// PredictionModelMetadata.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +struct PredictionModelMetadata { + let id: String + let dataCount: Int + let createdAt: Date + let dailyPredictions: [Int: Int] + let categoryPredictions: [String: Int] +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/CategoryRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/CategoryRepositoryProtocol.swift new file mode 100644 index 0000000..898570c --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/CategoryRepositoryProtocol.swift @@ -0,0 +1,17 @@ +// +// CategoryRepositoryProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation + +protocol CategoryRepositoryProtocol { + func fetchCategories() async throws -> [Category] + func addCategory(name: String, emoji: String) async throws + func updateCategory(_ category: Category, name: String, emoji: String) async throws + func deleteCategory(_ category: Category) async throws + func resetToDefault() async throws + func reorderCategories(_ categories: [Category]) async throws +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift new file mode 100644 index 0000000..80cff10 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/ExpenseRepositoryProtocol.swift @@ -0,0 +1,16 @@ +// +// ExpenseRepositoryProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation + +protocol ExpenseRepositoryProtocol { + func fetchExpenses(year: Int, month: Int) async -> [Expense] + func fetchAllExpenses() async -> [Expense] + func addExpense(_ expense: Expense) async + func deleteExpense(_ expense: Expense) async + func deleteAll() async +} diff --git a/SpendLearning/SpendLearning/Source/Domain/Repositories/PredictionRepositoryProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/Repositories/PredictionRepositoryProtocol.swift new file mode 100644 index 0000000..0e0b0ee --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/Repositories/PredictionRepositoryProtocol.swift @@ -0,0 +1,15 @@ +// +// PredictionRepositoryProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol PredictionRepositoryProtocol { + /// 저장된 예측 모델 메타데이터 (단일) + func fetchCurrentModel() async -> PredictionModelMetadata? + /// 현재 지출 데이터로 예측을 다시 계산해 저장한다 + func recalculate(expenses: [Expense]) async -> PredictionModelMetadata + /// 저장된 예측 모델을 삭제한다 + func deleteModel() async +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/CategoryUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/CategoryUseCase.swift new file mode 100644 index 0000000..69d3a6c --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/CategoryUseCase.swift @@ -0,0 +1,41 @@ +// +// CategoryUseCase.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation + +final class CategoryUseCase: CategoryUseCaseProtocol { + + private let repository: CategoryRepositoryProtocol + + init(repository: CategoryRepositoryProtocol) { + self.repository = repository + } + + func fetchCategories() async throws -> [Category] { + try await repository.fetchCategories() + } + + func addCategory(name: String, emoji: String) async throws { + try await repository.addCategory(name: name, emoji: emoji) + } + + func updateCategory(_ category: Category, name: String, emoji: String) async throws { + try await repository.updateCategory(category, name: name, emoji: emoji) + } + + func deleteCategory(_ category: Category) async throws { + try await repository.deleteCategory(category) + } + + func resetToDefault() async throws { + try await repository.resetToDefault() + } + + func reorderCategories(_ categories: [Category]) async throws { + try await repository.reorderCategories(categories) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/CategoryUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/CategoryUseCaseProtocol.swift new file mode 100644 index 0000000..2477a86 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/CategoryUseCaseProtocol.swift @@ -0,0 +1,17 @@ +// +// CategoryUseCaseProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation + +protocol CategoryUseCaseProtocol { + func fetchCategories() async throws -> [Category] + func addCategory(name: String, emoji: String) async throws + func updateCategory(_ category: Category, name: String, emoji: String) async throws + func deleteCategory(_ category: Category) async throws + func resetToDefault() async throws + func reorderCategories(_ categories: [Category]) async throws +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift new file mode 100644 index 0000000..19bd321 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCase.swift @@ -0,0 +1,33 @@ +// +// ExpenseUseCase.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation + +final class ExpenseUseCase: ExpenseUseCaseProtocol { + + private let repository: ExpenseRepositoryProtocol + + init(repository: ExpenseRepositoryProtocol) { + self.repository = repository + } + + func fetch(year: Int, month: Int) async -> [Expense] { + await repository.fetchExpenses(year: year, month: month) + } + + func add(_ expense: Expense) async { + await repository.addExpense(expense) + } + + func delete(_ expense: Expense) async { + await repository.deleteExpense(expense) + } + + func deleteAll() async { + await repository.deleteAll() + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift new file mode 100644 index 0000000..46b9422 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/ExpenseUseCaseProtocol.swift @@ -0,0 +1,13 @@ +// +// ExpenseUseCaseProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +protocol ExpenseUseCaseProtocol { + func fetch(year: Int, month: Int) async -> [Expense] + func add(_ expense: Expense) async + func delete(_ expense: Expense) async + func deleteAll() async +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCase.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCase.swift new file mode 100644 index 0000000..e2e5eb3 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCase.swift @@ -0,0 +1,35 @@ +// +// PredictionUseCase.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +final class PredictionUseCase: PredictionUseCaseProtocol { + + private let repository: PredictionRepositoryProtocol + private let expenseRepository: ExpenseRepositoryProtocol + + init( + repository: PredictionRepositoryProtocol, + expenseRepository: ExpenseRepositoryProtocol + ) { + self.repository = repository + self.expenseRepository = expenseRepository + } + + func fetchCurrentModel() async -> PredictionModelMetadata? { + await repository.fetchCurrentModel() + } + + func deleteModel() async { + await repository.deleteModel() + } + + func recalculate() async -> PredictionModelMetadata { + let expenses = await expenseRepository.fetchAllExpenses() + return await repository.recalculate(expenses: expenses) + } +} diff --git a/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCaseProtocol.swift b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCaseProtocol.swift new file mode 100644 index 0000000..5243773 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Domain/UseCases/PredictionUseCaseProtocol.swift @@ -0,0 +1,15 @@ +// +// PredictionUseCaseProtocol.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +protocol PredictionUseCaseProtocol { + /// 저장된 예측 모델 메타데이터 (단일) + func fetchCurrentModel() async -> PredictionModelMetadata? + /// 현재 지출 데이터로 예측을 다시 계산해 저장한다 + func recalculate() async -> PredictionModelMetadata + /// 저장된 예측 모델을 삭제한다 + func deleteModel() async +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/Common/CustomNavigationBar.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/CustomNavigationBar.swift new file mode 100644 index 0000000..9c4db5c --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/CustomNavigationBar.swift @@ -0,0 +1,103 @@ +// +// CustomNavigationBar.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit + +final class CustomNavigationBar: UIView { + + // MARK: - Closures + var onLeftAction: (() -> Void)? + var onRightAction: (() -> Void)? + + // MARK: - UI + private let titleLabel = UILabel() + private let leftButton = UIButton() + private let rightButton = UIButton() + private let bottomSeparator = UIView() + + // MARK: - Init + init(title: String, leftButtonTitle: String, rightButtonTitle: String? = nil) { + super.init(frame: .zero) + titleLabel.text = title + leftButton.setTitle(leftButtonTitle, for: .normal) + rightButton.setTitle(rightButtonTitle, for: .normal) + rightButton.isHidden = rightButtonTitle == nil + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateRightButton(title: String) { + rightButton.setTitle(title, for: .normal) + } +} + +// MARK: - Actions +private extension CustomNavigationBar { + + @objc func didTapLeft() { + onLeftAction?() + } + + @objc func didTapRight() { + onRightAction?() + } +} + +// MARK: - Helper +private extension CustomNavigationBar { + + func setup() { + setupSubviews() + setupConstraints() + } + + func setupSubviews() { + backgroundColor = .DesignSystem.background + + titleLabel.font = .systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .DesignSystem.primary + titleLabel.textAlignment = .center + + leftButton.setTitleColor(.DesignSystem.primary, for: .normal) + leftButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .regular) + leftButton.addTarget(self, action: #selector(didTapLeft), for: .touchUpInside) + + rightButton.setTitleColor(.DesignSystem.accent, for: .normal) + rightButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold) + rightButton.addTarget(self, action: #selector(didTapRight), for: .touchUpInside) + + bottomSeparator.backgroundColor = .DesignSystem.separator + + [titleLabel, leftButton, rightButton, bottomSeparator].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + heightAnchor.constraint(equalToConstant: 44), + + leftButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + leftButton.centerYAnchor.constraint(equalTo: centerYAnchor), + + rightButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + rightButton.centerYAnchor.constraint(equalTo: centerYAnchor), + + titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + + bottomSeparator.leadingAnchor.constraint(equalTo: leadingAnchor), + bottomSeparator.trailingAnchor.constraint(equalTo: trailingAnchor), + bottomSeparator.bottomAnchor.constraint(equalTo: bottomAnchor), + bottomSeparator.heightAnchor.constraint(equalToConstant: 1), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/Common/EmojiCircleView.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/EmojiCircleView.swift new file mode 100644 index 0000000..f75f653 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/EmojiCircleView.swift @@ -0,0 +1,48 @@ +// +// EmojiCircleView.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit + +final class EmojiCircleView: UIView { + + private let emojiLabel = UILabel() + + init(emoji: String, size: CGFloat) { + super.init(frame: .zero) + setup(emoji: emoji, size: size) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(emoji: String) { + emojiLabel.text = emoji + } +} + +// MARK: - Helper +private extension EmojiCircleView { + + func setup(emoji: String, size: CGFloat) { + backgroundColor = .DesignSystem.secondary + layer.cornerRadius = size / 2 + + emojiLabel.text = emoji + emojiLabel.font = .systemFont(ofSize: size * 0.43) + emojiLabel.textAlignment = .center + emojiLabel.translatesAutoresizingMaskIntoConstraints = false + + addSubview(emojiLabel) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size), + heightAnchor.constraint(equalToConstant: size), + emojiLabel.centerXAnchor.constraint(equalTo: centerXAnchor), + emojiLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/Common/IconCircleView.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/IconCircleView.swift new file mode 100644 index 0000000..44b12a6 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/IconCircleView.swift @@ -0,0 +1,53 @@ +// +// IconCircleView.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +final class IconCircleView: UIView { + private let imageView = UIImageView() + private var iconSize: CGFloat = 0 + + init(symbolName: String, size: CGFloat) { + super.init(frame: .zero) + setup(symbolName: symbolName, size: size) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + func update(symbolName: String) { + let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium) + imageView.image = UIImage(systemName: symbolName, withConfiguration: config) + } +} + +// MARK: - Helper +private extension IconCircleView { + + func setup(symbolName: String, size: CGFloat) { + iconSize = size * 0.43 + backgroundColor = .DesignSystem.secondary + layer.cornerRadius = size / 2 + + let config = UIImage.SymbolConfiguration(pointSize: iconSize, weight: .medium) + imageView.image = UIImage(systemName: symbolName, withConfiguration: config) + imageView.tintColor = .DesignSystem.primary + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(imageView) + NSLayoutConstraint.activate([ + widthAnchor.constraint(equalToConstant: size), + heightAnchor.constraint(equalToConstant: size), + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: centerYAnchor), + imageView.widthAnchor.constraint(equalToConstant: iconSize), + imageView.heightAnchor.constraint(equalToConstant: iconSize), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/Common/MonthSelectorView.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/MonthSelectorView.swift new file mode 100644 index 0000000..b405b01 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/MonthSelectorView.swift @@ -0,0 +1,110 @@ +// +// MonthSelectorView.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +final class MonthSelectorView: UIView { + + // MARK: - Closures + var onPrevious: (() -> Void)? + var onNext: (() -> Void)? + var onTitleTapped: (() -> Void)? + + // MARK: - UI + private let previousButton = IconCircleView(symbolName: "chevron.left", size: 34) + private let nextButton = IconCircleView(symbolName: "chevron.right", size: 34) + private let titleStack = UIStackView() + private let titleLabel = UILabel() + private let chevronIcon = UIImageView() + + // MARK: - Init + init() { + super.init(frame: .zero) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Public + func configure(year: Int, month: Int) { + titleLabel.text = "\(year)년 \(month)월" + } +} + +// MARK: - Actions +private extension MonthSelectorView { + + @objc func didTapPrevious() { + onPrevious?() + } + + @objc func didTapNext() { + onNext?() + } + + @objc func didTapTitle() { + onTitleTapped?() + } +} + +// MARK: - Helper +private extension MonthSelectorView { + + func setup() { + [previousButton, titleStack, nextButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + setupTitle() + setupGestures() + setupConstraints() + } + + func setupTitle() { + titleLabel.font = .systemFont(ofSize: 19, weight: .bold) + titleLabel.textColor = .DesignSystem.primary + titleLabel.textAlignment = .center + + let config = UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold) + chevronIcon.image = UIImage(systemName: "chevron.down", withConfiguration: config) + chevronIcon.tintColor = .DesignSystem.subtitle + chevronIcon.contentMode = .scaleAspectFit + + titleStack.axis = .horizontal + titleStack.alignment = .center + titleStack.spacing = 4 + titleStack.addArrangedSubview(titleLabel) + titleStack.addArrangedSubview(chevronIcon) + } + + func setupGestures() { + previousButton.isUserInteractionEnabled = true + nextButton.isUserInteractionEnabled = true + titleStack.isUserInteractionEnabled = true + + previousButton.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapPrevious))) + nextButton.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNext))) + titleStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapTitle))) + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + previousButton.leadingAnchor.constraint(equalTo: leadingAnchor), + previousButton.centerYAnchor.constraint(equalTo: centerYAnchor), + + nextButton.trailingAnchor.constraint(equalTo: trailingAnchor), + nextButton.centerYAnchor.constraint(equalTo: centerYAnchor), + + titleStack.centerXAnchor.constraint(equalTo: centerXAnchor), + titleStack.centerYAnchor.constraint(equalTo: centerYAnchor), + + heightAnchor.constraint(equalToConstant: 34), + ]) + } +} 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/Common/UIColor+DesignSystem.swift b/SpendLearning/SpendLearning/Source/Presentation/Common/UIColor+DesignSystem.swift new file mode 100644 index 0000000..e2b251c --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/Common/UIColor+DesignSystem.swift @@ -0,0 +1,71 @@ +// +// UIColor+DesignSystem.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +extension UIColor { + enum DesignSystem { + /// Primary - 딥 올리브 다크 + static let primary = UIColor(red: 0.10, green: 0.12, blue: 0.08, alpha: 1.0) // #1A1F14 + + /// Background - 크림 베이지 (올리브 톤) + static let background = UIColor(red: 0.95, green: 0.95, blue: 0.92, alpha: 1.0) // #F2F2EB + + /// Surface - 연한 크림 화이트 + static let surface = UIColor(red: 0.98, green: 0.98, blue: 0.96, alpha: 1.0) // #FAFAF5 + + /// Secondary - 연한 올리브 베이지 (아이콘 배경) + static let secondary = UIColor(red: 0.85, green: 0.87, blue: 0.82, alpha: 1.0) // #D9DED1 + + /// Accent - 국방색 포인트 + static let accent = UIColor(red: 0.325, green: 0.388, blue: 0.286, alpha: 1.0) // #536349 + + /// Subtitle - 올리브 그레이 + static let subtitle = UIColor(red: 0.45, green: 0.48, blue: 0.42, alpha: 1.0) // #737A6B + + /// Separator - 연한 올리브 구분선 + static let separator = UIColor(red: 0.87, green: 0.88, blue: 0.85, alpha: 1.0) // #DEE1D9 + + /// Chart Predicted - 버건디 브라운 + static let chartPredicted = UIColor(red: 0.478, green: 0.247, blue: 0.247, alpha: 1.0) // #7A3F3F + } +} + +// MARK: - Preview + +import SwiftUI + +private struct ColorRow: View { + let name: String + let color: UIColor + + var body: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 8) + .fill(Color(color)) + .frame(width: 48, height: 48) + Text(name) + .font(.system(size: 14, weight: .medium)) + } + } +} + +#Preview { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + ColorRow(name: "primary", color: .DesignSystem.primary) + ColorRow(name: "background", color: .DesignSystem.background) + ColorRow(name: "surface", color: .DesignSystem.surface) + ColorRow(name: "secondary", color: .DesignSystem.secondary) + ColorRow(name: "accent", color: .DesignSystem.accent) + ColorRow(name: "subtitle", color: .DesignSystem.subtitle) + ColorRow(name: "separator", color: .DesignSystem.separator) + ColorRow(name: "chartPredicted", color: .DesignSystem.chartPredicted) + } + .padding(20) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/Calendar/CalendarDayCell.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/Calendar/CalendarDayCell.swift new file mode 100644 index 0000000..956cc63 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/Calendar/CalendarDayCell.swift @@ -0,0 +1,106 @@ +// +// CalendarDayCell.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +final class CalendarDayCell: UICollectionViewCell { + + private let circleView = UIView() + private let dayLabel = UILabel() + private let amountLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(day: Int?, amount: Int?, isToday: Bool, isSelected: Bool) { + guard let day else { + dayLabel.text = nil + amountLabel.text = nil + circleView.backgroundColor = .clear + circleView.layer.borderWidth = 0 + return + } + + dayLabel.text = "\(day)" + amountLabel.text = amount.map { $0.formatted() } ?? " " + + if isToday && isSelected { + circleView.backgroundColor = .DesignSystem.accent + circleView.layer.borderWidth = 1.5 + circleView.layer.borderColor = UIColor.DesignSystem.primary.cgColor + dayLabel.textColor = .DesignSystem.surface + } else if isToday { + circleView.backgroundColor = .DesignSystem.accent + circleView.layer.borderWidth = 0 + dayLabel.textColor = .DesignSystem.surface + } else if isSelected { + circleView.backgroundColor = .clear + circleView.layer.borderWidth = 1.5 + circleView.layer.borderColor = UIColor.DesignSystem.primary.cgColor + dayLabel.textColor = .DesignSystem.primary + } else { + circleView.backgroundColor = .clear + circleView.layer.borderWidth = 0 + dayLabel.textColor = .DesignSystem.primary + } + + amountLabel.textColor = isSelected ? .DesignSystem.accent : .DesignSystem.subtitle + } + + override func prepareForReuse() { + super.prepareForReuse() + dayLabel.text = nil + amountLabel.text = " " + circleView.backgroundColor = .clear + circleView.layer.borderWidth = 0 + dayLabel.textColor = .DesignSystem.primary + } +} + +// MARK: - Helper +private extension CalendarDayCell { + + func setup() { + let circleViewSize: CGFloat = 27 + circleView.layer.cornerRadius = circleViewSize / 2 + circleView.translatesAutoresizingMaskIntoConstraints = false + + dayLabel.font = .systemFont(ofSize: 14, weight: .bold) + dayLabel.textAlignment = .center + dayLabel.translatesAutoresizingMaskIntoConstraints = false + + amountLabel.font = .systemFont(ofSize: 9, weight: .bold) + amountLabel.adjustsFontSizeToFitWidth = true + amountLabel.minimumScaleFactor = 0.5 + amountLabel.textAlignment = .center + amountLabel.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(circleView) + contentView.addSubview(dayLabel) + contentView.addSubview(amountLabel) + + NSLayoutConstraint.activate([ + circleView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + circleView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 4), + circleView.widthAnchor.constraint(equalToConstant: circleViewSize), + circleView.heightAnchor.constraint(equalToConstant: circleViewSize), + + dayLabel.centerXAnchor.constraint(equalTo: circleView.centerXAnchor), + dayLabel.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), + + amountLabel.topAnchor.constraint(equalTo: circleView.bottomAnchor, constant: 2), + amountLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + amountLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/Calendar/CalendarWeekdayHeader.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/Calendar/CalendarWeekdayHeader.swift new file mode 100644 index 0000000..4197382 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/Calendar/CalendarWeekdayHeader.swift @@ -0,0 +1,48 @@ +// +// CalendarWeekdayHeader.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +final class CalendarWeekdayHeader: UICollectionReusableView { + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Helper +private extension CalendarWeekdayHeader { + + func setup() { + let stack = UIStackView() + stack.axis = .horizontal + stack.distribution = .fillEqually + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + + ["일", "월", "화", "수", "목", "금", "토"].enumerated().forEach { index, day in + let label = UILabel() + label.text = day + label.font = .systemFont(ofSize: 12, weight: .semibold) + label.textColor = index == 0 ? .DesignSystem.accent : .DesignSystem.subtitle + label.textAlignment = .center + stack.addArrangedSubview(label) + } + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/ExpenseCell.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/ExpenseCell.swift new file mode 100644 index 0000000..99ee736 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/ExpenseCell.swift @@ -0,0 +1,87 @@ +// +// ExpenseCell.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +final class ExpenseCell: UICollectionViewCell { + + private let iconView = EmojiCircleView(emoji: "📦", size: 38) + private let categoryLabel = UILabel() + private let memoLabel = UILabel() + private let amountLabel = UILabel() + private let separator = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + categoryLabel.text = nil + memoLabel.text = nil + amountLabel.text = nil + memoLabel.isHidden = false + separator.isHidden = false + } + + func configure(category: Category, memo: String?, amount: Int) { + iconView.update(emoji: category.emoji) + categoryLabel.text = category.displayName + memoLabel.text = memo ?? " " + amountLabel.text = "\(amount.formatted())원" + } +} + +// MARK: - Helper +private extension ExpenseCell { + + func setup() { + backgroundColor = .clear + + let textStack = UIStackView(arrangedSubviews: [categoryLabel, memoLabel]) + textStack.axis = .vertical + textStack.spacing = 2 + + separator.backgroundColor = .DesignSystem.separator + + [iconView, textStack, amountLabel, separator].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + categoryLabel.font = .systemFont(ofSize: 14, weight: .bold) + categoryLabel.textColor = .DesignSystem.primary + + memoLabel.font = .systemFont(ofSize: 12, weight: .regular) + memoLabel.textColor = .DesignSystem.subtitle + + amountLabel.font = .systemFont(ofSize: 14.5, weight: .bold) + amountLabel.textColor = .DesignSystem.primary + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + textStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 12), + textStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + amountLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + amountLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + amountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: textStack.trailingAnchor, constant: 8), + + separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separator.heightAnchor.constraint(equalToConstant: 1), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift new file mode 100644 index 0000000..4a332ad --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewController.swift @@ -0,0 +1,468 @@ +// +// HomeViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit +import Combine + +final class HomeViewController: UIViewController { + + // MARK: - UI + private let monthSelectorView = MonthSelectorView() + private let totalTitleLabel = UILabel() + private let totalAmountLabel = UILabel() + private let todayButton = UIButton() + private let scrollView = UIScrollView() + private let scrollContentView = UIView() + private let calendarCollectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewLayout() + ) + private let expenseDateLabel = UILabel() + private let expenseTotalLabel = UILabel() + private let expenseCollectionView = UICollectionView( + frame: .zero, + collectionViewLayout: UICollectionViewLayout() + ) + private let fabButton = UIButton() + + // MARK: - Constraints + private var calendarHeightConstraint: NSLayoutConstraint! + private var expenseCollectionHeightConstraint: NSLayoutConstraint! + + // MARK: - Cell Registration + private let calendarCellRegistration = UICollectionView.CellRegistration { + cell, indexPath, item in + let (day, isSelected) = item + cell.configure(day: day.date.map { Calendar.current.component(.day, from: $0) }, + amount: day.amount, + isToday: day.isToday, + isSelected: isSelected) + } + private let expenseCellRegistration = UICollectionView.CellRegistration { + cell, indexPath, expense in + cell.configure(category: expense.category, memo: expense.memo, amount: expense.amount) + } + private let weekdayHeaderRegistration = UICollectionView.SupplementaryRegistration( + elementKind: UICollectionView.elementKindSectionHeader + ) { _, _, _ in } + + // MARK: - Properties + private let viewModel: HomeViewModel + private let categoryUseCase: CategoryUseCaseProtocol + private var cancellables = Set() + private let expenseDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "ko_KR") + formatter.dateFormat = "M월 d일 지출내역" + return formatter + }() + + // MARK: - Init + init( + viewModel: HomeViewModel, + categoryUseCase: CategoryUseCaseProtocol + ) { + self.viewModel = viewModel + self.categoryUseCase = categoryUseCase + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setup() + bind() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.updateCalendar() + } +} + +// MARK: - Bind +private extension HomeViewController { + + func bind() { + viewModel.$currentYear + .combineLatest(viewModel.$currentMonth) + .sink { [weak self] year, month in + self?.monthSelectorView.configure(year: year, month: month) + } + .store(in: &cancellables) + + viewModel.$totalAmount + .sink { [weak self] amount in + self?.totalAmountLabel.text = "\(amount.formatted())원" + } + .store(in: &cancellables) + + viewModel.$calendarDays + .sink { [weak self] _ in + self?.calendarCollectionView.reloadData() + } + .store(in: &cancellables) + + viewModel.$calendarWeekCount + .sink { [weak self] weeks in + guard let self else { return } + // 36: 요일 헤더, 47: row 높이, 22: 상하 contentInset + self.calendarHeightConstraint.constant = 36 + 47 * CGFloat(weeks) + 22 + UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() } + } + .store(in: &cancellables) + + viewModel.$selectedDate + .receive(on: DispatchQueue.main) + .sink { [weak self] date in + guard let self else { return } + self.expenseDateLabel.text = self.expenseDateFormatter.string(from: date) + self.calendarCollectionView.reloadData() + } + .store(in: &cancellables) + + viewModel.$selectedDayExpenses + .sink { [weak self] expenses in + guard let self else { return } + self.expenseCollectionHeightConstraint.constant = CGFloat(expenses.count) * 58 + self.expenseCollectionView.reloadData() + } + .store(in: &cancellables) + + viewModel.$selectedDayTotal + .sink { [weak self] total in + self?.expenseTotalLabel.text = "총 \(total.formatted())원" + } + .store(in: &cancellables) + } +} + +// MARK: - UICollectionViewDataSource +extension HomeViewController: UICollectionViewDataSource { + + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + if collectionView === calendarCollectionView { + return viewModel.calendarDays.count + } + if collectionView === expenseCollectionView { + return viewModel.selectedDayExpenses.count + } + return 0 + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + if collectionView === calendarCollectionView { + let day = viewModel.calendarDays[indexPath.item] + let isSelected = day.date.map { + Calendar.current.isDate($0, inSameDayAs: viewModel.selectedDate) + } ?? false + return collectionView.dequeueConfiguredReusableCell( + using: calendarCellRegistration, for: indexPath, item: (day, isSelected) + ) + } + if collectionView === expenseCollectionView { + let expense = viewModel.selectedDayExpenses[indexPath.item] + return collectionView.dequeueConfiguredReusableCell( + using: expenseCellRegistration, for: indexPath, item: expense + ) + } + return UICollectionViewCell() + } + + func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + guard collectionView === calendarCollectionView else { + return UICollectionReusableView() + } + return collectionView.dequeueConfiguredReusableSupplementary( + using: weekdayHeaderRegistration, for: indexPath + ) + } +} + +// MARK: - UICollectionViewDelegate +extension HomeViewController: UICollectionViewDelegate { + + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + if collectionView === calendarCollectionView { + let day = viewModel.calendarDays[indexPath.item] + guard let date = day.date else { return } + viewModel.didSelectDate(date) + } + if collectionView === expenseCollectionView { + let expense = viewModel.selectedDayExpenses[indexPath.item] + presentAddExpense(expense: expense) + } + } + + func collectionView( + _ collectionView: UICollectionView, + contextMenuConfigurationForItemAt indexPath: IndexPath, + point: CGPoint + ) -> UIContextMenuConfiguration? { + guard collectionView === expenseCollectionView else { return nil } + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in + let delete = UIAction( + title: "삭제", + image: UIImage(systemName: "trash"), + attributes: .destructive + ) { [weak self] _ in + self?.viewModel.didDeleteExpense(at: indexPath.item) + } + return UIMenu(children: [delete]) + } + } +} + +// MARK: - Helper +private extension HomeViewController { + + func setup() { + view.backgroundColor = .DesignSystem.background + setupSubviews() + setupLabels() + setupTodayButton() + setupConstraints() + setupCalendarCollectionView() + setupExpenseCollectionView() + setupFAB() + setupMonthSelector() + } + + func setupSubviews() { + [monthSelectorView, totalTitleLabel, + totalAmountLabel, todayButton, scrollView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + + scrollContentView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(scrollContentView) + scrollView.showsVerticalScrollIndicator = false + + [calendarCollectionView, expenseDateLabel, + expenseTotalLabel, expenseCollectionView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + scrollContentView.addSubview($0) + } + + fabButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(fabButton) + } + + func setupLabels() { + totalTitleLabel.text = "총 지출" + totalTitleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + totalTitleLabel.textColor = .DesignSystem.subtitle + + totalAmountLabel.font = .systemFont(ofSize: 32, weight: .heavy) + totalAmountLabel.textColor = .DesignSystem.primary + + expenseDateLabel.font = .systemFont(ofSize: 16, weight: .bold) + expenseDateLabel.textColor = .DesignSystem.primary + + expenseTotalLabel.font = .systemFont(ofSize: 12.5, weight: .semibold) + expenseTotalLabel.textColor = .DesignSystem.subtitle + } + + func setupTodayButton() { + var config = UIButton.Configuration.bordered() + config.title = "오늘" + config.baseForegroundColor = .DesignSystem.subtitle + config.baseBackgroundColor = .DesignSystem.secondary + config.cornerStyle = .capsule + config.contentInsets = NSDirectionalEdgeInsets(top: 5, leading: 10, bottom: 5, trailing: 10) + config.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in + var a = attrs + a.font = UIFont.systemFont(ofSize: 12, weight: .semibold) + return a + } + todayButton.configuration = config + todayButton.addAction(UIAction { [weak self] _ in + self?.viewModel.didSelectToday() + }, for: .touchUpInside) + } + + func setupConstraints() { + calendarHeightConstraint = calendarCollectionView.heightAnchor.constraint(equalToConstant: 36 + 47 * 6 + 22) + expenseCollectionHeightConstraint = expenseCollectionView.heightAnchor.constraint(equalToConstant: 0) + + NSLayoutConstraint.activate([ + monthSelectorView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 4), + monthSelectorView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + monthSelectorView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + totalTitleLabel.topAnchor.constraint(equalTo: monthSelectorView.bottomAnchor, constant: 16), + totalTitleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + totalAmountLabel.topAnchor.constraint(equalTo: totalTitleLabel.bottomAnchor, constant: 2), + totalAmountLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + totalAmountLabel.trailingAnchor.constraint(lessThanOrEqualTo: todayButton.leadingAnchor, constant: -8), + + todayButton.centerYAnchor.constraint(equalTo: totalAmountLabel.centerYAnchor), + todayButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + scrollView.topAnchor.constraint(equalTo: totalAmountLabel.bottomAnchor, constant: 18), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + scrollContentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + scrollContentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + scrollContentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + scrollContentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + scrollContentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + + calendarCollectionView.topAnchor.constraint(equalTo: scrollContentView.topAnchor), + calendarCollectionView.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor, constant: 20), + calendarCollectionView.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor, constant: -20), + calendarHeightConstraint, + + expenseDateLabel.topAnchor.constraint(equalTo: calendarCollectionView.bottomAnchor, constant: 14), + expenseDateLabel.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor, constant: 20), + + expenseTotalLabel.centerYAnchor.constraint(equalTo: expenseDateLabel.centerYAnchor), + expenseTotalLabel.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor, constant: -20), + + expenseCollectionView.topAnchor.constraint(equalTo: expenseDateLabel.bottomAnchor, constant: 10), + expenseCollectionView.leadingAnchor.constraint(equalTo: scrollContentView.leadingAnchor, constant: 20), + expenseCollectionView.trailingAnchor.constraint(equalTo: scrollContentView.trailingAnchor, constant: -20), + expenseCollectionView.bottomAnchor.constraint(equalTo: scrollContentView.bottomAnchor, constant: -20), + expenseCollectionHeightConstraint, + + fabButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + fabButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + fabButton.widthAnchor.constraint(equalToConstant: 56), + fabButton.heightAnchor.constraint(equalToConstant: 56), + ]) + } + + func setupCalendarCollectionView() { + calendarCollectionView.backgroundColor = .DesignSystem.surface + calendarCollectionView.layer.cornerRadius = 20 + calendarCollectionView.isScrollEnabled = false + calendarCollectionView.setCollectionViewLayout(makeCalendarLayout(), animated: false) + calendarCollectionView.dataSource = self + calendarCollectionView.delegate = self + } + + func setupExpenseCollectionView() { + expenseCollectionView.backgroundColor = .clear + expenseCollectionView.isScrollEnabled = false + expenseCollectionView.setCollectionViewLayout(makeExpenseLayout(), animated: false) + expenseCollectionView.dataSource = self + expenseCollectionView.delegate = self + } + + func setupFAB() { + fabButton.backgroundColor = .DesignSystem.primary + fabButton.layer.cornerRadius = 28 + fabButton.layer.shadowColor = UIColor.DesignSystem.primary.cgColor + fabButton.layer.shadowOpacity = 0.4 + fabButton.layer.shadowOffset = CGSize(width: 0, height: 6) + fabButton.layer.shadowRadius = 12 + + let config = UIImage.SymbolConfiguration(pointSize: 20, weight: .medium) + fabButton.setImage(UIImage(systemName: "plus", withConfiguration: config), for: .normal) + fabButton.tintColor = .white + fabButton.addAction(UIAction { [weak self] _ in + self?.presentAddExpense() + }, for: .touchUpInside) + } + + func setupMonthSelector() { + monthSelectorView.onPrevious = { [weak self] in + self?.viewModel.didTapPreviousMonth() + } + monthSelectorView.onNext = { [weak self] in + self?.viewModel.didTapNextMonth() + } + monthSelectorView.onTitleTapped = { [weak self] in + self?.presentMonthYearPicker() + } + } + + func makeCalendarLayout() -> UICollectionViewCompositionalLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/7), heightDimension: .absolute(47)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(47)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 7) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets(top: 14, leading: 10, bottom: 8, trailing: 10) + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(36)) + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [header] + + return UICollectionViewCompositionalLayout(section: section) + } + + func makeExpenseLayout() -> UICollectionViewCompositionalLayout { + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(58)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(58)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, repeatingSubitem: item, count: 1) + + let section = NSCollectionLayoutSection(group: group) + return UICollectionViewCompositionalLayout(section: section) + } +} + +// MARK: - Presentation +private extension HomeViewController { + + func presentMonthYearPicker() { + let pickerVC = MonthYearPickerViewController(year: viewModel.currentYear, month: viewModel.currentMonth) + pickerVC.onConfirm = { [weak self] year, month, selectedDay in + self?.viewModel.didSelectYearMonth(year: year, month: month) + self?.viewModel.didSelectDate(selectedDay) + } + + if let sheet = pickerVC.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 + } + + present(pickerVC, animated: true) + } + + func presentAddExpense(expense: Expense? = nil) { + let newExpenseViewModel = NewExpenseViewModel( + expenseUseCase: viewModel.expenseUseCase, + categoryUseCase: categoryUseCase, + date: viewModel.selectedDate, + editingExpense: expense + ) + let categorySelectVC = CategorySelectViewController(viewModel: newExpenseViewModel) + categorySelectVC.modalPresentationStyle = .fullScreen + present(categorySelectVC, animated: true) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewModel.swift new file mode 100644 index 0000000..515d2b7 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/HomeViewModel.swift @@ -0,0 +1,151 @@ +// +// HomeViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Foundation +import Combine + +@MainActor +final class HomeViewModel { + + // MARK: - Output + @Published private(set) var currentYear: Int + @Published private(set) var currentMonth: Int + @Published private(set) var selectedDate: Date + @Published private(set) var calendarDays: [CalendarDay] = [] + @Published private(set) var selectedDayExpenses: [Expense] = [] + @Published private(set) var selectedDayTotal: Int = 0 + @Published private(set) var totalAmount: Int = 0 + @Published private(set) var calendarWeekCount: Int = 6 + + let expenseUseCase: ExpenseUseCaseProtocol + + // MARK: - Init + init(expenseUseCase: ExpenseUseCaseProtocol) { + let now = Date() + let calendar = Calendar.current + self.currentYear = calendar.component(.year, from: now) + self.currentMonth = calendar.component(.month, from: now) + self.selectedDate = calendar.startOfDay(for: now) + self.expenseUseCase = expenseUseCase + updateCalendar() + } + + // MARK: - Input + func didTapPreviousMonth() { + if currentMonth == 1 { + currentMonth = 12 + currentYear -= 1 + } else { + currentMonth -= 1 + } + updateCalendar() + } + + func didTapNextMonth() { + if currentMonth == 12 { + currentMonth = 1 + currentYear += 1 + } else { + currentMonth += 1 + } + updateCalendar() + } + + func didSelectYearMonth(year: Int, month: Int) { + currentYear = year + currentMonth = month + updateCalendar() + } + + func didSelectToday() { + let today = Calendar.current.startOfDay(for: Date()) + let todayYear = Calendar.current.component(.year, from: today) + let todayMonth = Calendar.current.component(.month, from: today) + if todayYear != currentYear || todayMonth != currentMonth { + currentYear = todayYear + currentMonth = todayMonth + updateCalendar() + } else { + selectedDate = today + Task { + let expenses = await expenseUseCase.fetch(year: currentYear, month: currentMonth) + updateSelectedDay(from: expenses) + } + } + } + + func didSelectDate(_ date: Date) { + selectedDate = date + Task { + let expenses = await expenseUseCase.fetch(year: currentYear, month: currentMonth) + updateSelectedDay(from: expenses) + } + } + + func didDeleteExpense(at index: Int) { + let expense = selectedDayExpenses[index] + Task { + await expenseUseCase.delete(expense) + updateCalendar() + } + } + + func updateCalendar() { + Task { + let expenses = await expenseUseCase.fetch(year: currentYear, month: currentMonth) + calendarDays = makeCalendarDays(year: currentYear, month: currentMonth, expenses: expenses) + totalAmount = expenses.reduce(0) { $0 + $1.amount } + updateSelectedDay(from: expenses) + } + } +} + +// MARK: - Helper +private extension HomeViewModel { + + func updateSelectedDay(from expenses: [Expense]) { + let calendar = Calendar.current + let filtered = expenses.filter { + calendar.isDate($0.date, inSameDayAs: selectedDate) + } + selectedDayExpenses = filtered + selectedDayTotal = filtered.reduce(0) { $0 + $1.amount } + } + + func makeCalendarDays(year: Int, month: Int, expenses: [Expense]) -> [CalendarDay] { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + + let calendar = Calendar.current + guard let firstDay = calendar.date(from: components), + let range = calendar.range(of: .day, in: .month, for: firstDay) else { return [] } + + let weekday = calendar.component(.weekday, from: firstDay) + let leadingBlanks = weekday - 1 + let today = calendar.startOfDay(for: Date()) + + var days: [CalendarDay] = Array(repeating: CalendarDay(date: nil, amount: nil, isToday: false), count: leadingBlanks) + + for day in range { + components.day = day + guard let date = calendar.date(from: components) else { continue } + let isToday = calendar.isDate(date, inSameDayAs: today) + let dayExpenses = expenses.filter { + calendar.isDate($0.date, inSameDayAs: date) + } + let amount = dayExpenses.isEmpty ? nil : dayExpenses.reduce(0) { $0 + $1.amount } + days.append(CalendarDay(date: date, amount: amount, isToday: isToday)) + } + + let totalCells = leadingBlanks + range.count + calendarWeekCount = Int(ceil(Double(totalCells) / 7.0)) + + return days + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Home/MonthYearPickerViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Home/MonthYearPickerViewController.swift new file mode 100644 index 0000000..420c221 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Home/MonthYearPickerViewController.swift @@ -0,0 +1,98 @@ +// +// MonthYearPickerViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import UIKit + +final class MonthYearPickerViewController: UIViewController { + + var onConfirm: ((Int, Int, Date) -> Void)? + + private let picker = UIDatePicker() + private let confirmButton = UIButton() + + init(year: Int, month: Int) { + super.init(nibName: nil, bundle: nil) + + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + picker.date = Calendar.current.date(from: components) ?? Date() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setup() + } +} + +// MARK: - Actions +private extension MonthYearPickerViewController { + + @objc func didTapConfirm() { + let calendar = Calendar.current + let year = calendar.component(.year, from: picker.date) + let month = calendar.component(.month, from: picker.date) + let selectedDay = calendar.startOfDay(for: picker.date) + onConfirm?(year, month, selectedDay) + dismiss(animated: true) + } +} + +// MARK: - Helper +private extension MonthYearPickerViewController { + + func setup() { + view.backgroundColor = .DesignSystem.background + setupSubviews() + setupPicker() + setupConfirmButton() + setupConstraints() + } + + func setupSubviews() { + [picker, confirmButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupPicker() { + picker.datePickerMode = .date + picker.preferredDatePickerStyle = .wheels + picker.locale = Locale(identifier: "ko_KR") + picker.minimumDate = Calendar.current.date(from: DateComponents(year: 2000, month: 1)) + picker.maximumDate = Calendar.current.date(from: DateComponents(year: 2099, month: 12)) + } + + func setupConfirmButton() { + confirmButton.backgroundColor = .DesignSystem.primary + confirmButton.setTitle("확인", for: .normal) + confirmButton.setTitleColor(.white, for: .normal) + confirmButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold) + confirmButton.layer.cornerRadius = 14 + confirmButton.addTarget(self, action: #selector(didTapConfirm), for: .touchUpInside) + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + picker.topAnchor.constraint(equalTo: view.topAnchor, constant: 20), + picker.leadingAnchor.constraint(equalTo: view.leadingAnchor), + picker.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + confirmButton.topAnchor.constraint(equalTo: picker.bottomAnchor, constant: 16), + confirmButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + confirmButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + confirmButton.heightAnchor.constraint(equalToConstant: 52), + confirmButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift new file mode 100644 index 0000000..44f53a6 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/CategorySelectViewController.swift @@ -0,0 +1,149 @@ +// +// CategorySelectViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit +import Combine + +final class CategorySelectViewController: UIViewController { + + // MARK: - UI + private let navigationBar = CustomNavigationBar(title: "카테고리", leftButtonTitle: "취소") + private let categoryTableView = UITableView() + + // MARK: - Properties + private let viewModel: NewExpenseViewModel + private var cancellables = Set() + + // MARK: - Init + init(viewModel: NewExpenseViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .DesignSystem.background + setup() + loadCategories() + } +} + +// MARK: - UITableView +extension CategorySelectViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.categories.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: SettingsCategoryCell.reuseIdentifier, + for: indexPath + ) as! SettingsCategoryCell + cell.configure(category: viewModel.categories[indexPath.row], isEditing: false) + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 64 + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let category = viewModel.categories[indexPath.row] + viewModel.didSelectCategory(category) + let inputVC = ExpenseInputViewController(viewModel: viewModel) + inputVC.modalPresentationStyle = .fullScreen + present(inputVC, animated: true) + } +} + +// MARK: - Helper +private extension CategorySelectViewController { + + func setup() { + setupNavigationBar() + setupSubviews() + setupConstraints() + setupCategoryTableView() + bindError() + } + + func setupNavigationBar() { + navigationBar.onLeftAction = { [weak self] in + self?.dismiss(animated: true) + } + } + + func setupSubviews() { + [navigationBar, categoryTableView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + navigationBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + categoryTableView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 24), + categoryTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + categoryTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + categoryTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + func setupCategoryTableView() { + categoryTableView.backgroundColor = .DesignSystem.surface + categoryTableView.layer.cornerRadius = 16 + categoryTableView.isScrollEnabled = true + categoryTableView.showsVerticalScrollIndicator = false + categoryTableView.separatorStyle = .none + categoryTableView.dataSource = self + categoryTableView.delegate = self + categoryTableView.register( + SettingsCategoryCell.self, + forCellReuseIdentifier: SettingsCategoryCell.reuseIdentifier + ) + } + + func loadCategories() { + Task { + await viewModel.loadCategories() + categoryTableView.reloadData() + } + } + + func bindError() { + viewModel.$fetchError + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] _ in + self?.showFetchErrorAlert() + } + .store(in: &cancellables) + } + + func showFetchErrorAlert() { + let alert = UIAlertController( + title: "불러오기 실패", + message: "카테고리를 불러오지 못했습니다.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "다시 시도", style: .default) { [weak self] _ in + self?.loadCategories() + }) + present(alert, animated: true) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift new file mode 100644 index 0000000..7bb80e1 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/ExpenseInputViewController.swift @@ -0,0 +1,156 @@ +// +// ExpenseInputViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit +import Combine + +final class ExpenseInputViewController: UIViewController { + + // MARK: - UI + private lazy var navigationBar = CustomNavigationBar( + title: viewModel.selectedCategory?.displayName ?? "", + leftButtonTitle: "뒤로", + rightButtonTitle: "저장" + ) + private let amountLabel = UILabel() + private let memoTextField = UITextField() + private let amountTextField: UITextField = { + let tf = UITextField() + tf.keyboardType = .numberPad + tf.isHidden = true + return tf + }() + + // MARK: - Properties + private let viewModel: NewExpenseViewModel + + // MARK: - Init + init(viewModel: NewExpenseViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .DesignSystem.background + setup() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + amountTextField.becomeFirstResponder() + } +} + +// MARK: - Actions +private extension ExpenseInputViewController { + + @objc func didTapSave() { + Task { + await viewModel.didSaveExpense() + presentingViewController?.presentingViewController?.dismiss(animated: true) + } + } + + @objc func didTapBack() { + dismiss(animated: true) + } + + @objc func didTapAmountLabel() { + amountTextField.becomeFirstResponder() + } + + @objc func amountDidChange() { + let amount = Int(amountTextField.text ?? "") ?? 0 + viewModel.didInputAmount(amount) + amountLabel.text = amount == 0 ? "0원" : "\(amount.formatted())원" + } +} + +// MARK: - UITextFieldDelegate +extension ExpenseInputViewController: UITextFieldDelegate { + + func textFieldDidChangeSelection(_ textField: UITextField) { + guard textField == memoTextField else { return } + viewModel.didInputMemo(textField.text ?? "") + } +} + +// MARK: - Helper +private extension ExpenseInputViewController { + + func setup() { + setupNavigationBar() + setupSubviews() + setupConstraints() + } + + func setupNavigationBar() { + navigationBar.onLeftAction = { [weak self] in + self?.didTapBack() + } + navigationBar.onRightAction = { [weak self] in + self?.didTapSave() + } + } + + func setupSubviews() { + let initialAmount = viewModel.initialAmount + amountLabel.text = initialAmount == 0 ? "0원" : "\(initialAmount.formatted())원" + amountLabel.font = .systemFont(ofSize: 40, weight: .bold) + amountLabel.textColor = .DesignSystem.primary + amountLabel.textAlignment = .center + amountLabel.isUserInteractionEnabled = true + amountLabel.adjustsFontSizeToFitWidth = true + amountLabel.minimumScaleFactor = 0.5 + amountLabel.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(didTapAmountLabel)) + ) + + memoTextField.placeholder = "메모를 남겨보세요" + memoTextField.text = viewModel.initialMemo + memoTextField.font = .systemFont(ofSize: 15) + memoTextField.backgroundColor = .white + memoTextField.layer.cornerRadius = 12 + memoTextField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 0)) + memoTextField.leftViewMode = .always + memoTextField.keyboardType = .default + memoTextField.autocapitalizationType = .none + memoTextField.autocorrectionType = .no + memoTextField.delegate = self + + amountTextField.text = initialAmount == 0 ? "" : "\(initialAmount)" + amountTextField.addTarget(self, action: #selector(amountDidChange), for: .editingChanged) + + [navigationBar, amountLabel, memoTextField, amountTextField].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + navigationBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + amountLabel.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 70), + amountLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + amountLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + + memoTextField.topAnchor.constraint(equalTo: amountLabel.bottomAnchor, constant: 60), + memoTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + memoTextField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + memoTextField.heightAnchor.constraint(equalToConstant: 48), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift new file mode 100644 index 0000000..956bce9 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/NewExpense/NewExpenseViewModel.swift @@ -0,0 +1,84 @@ +// +// NewExpenseViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import Combine + +@MainActor +final class NewExpenseViewModel { + + // MARK: - Output + @Published private(set) var selectedCategory: Category? + @Published private(set) var categories: [Category] = [] + @Published private(set) var fetchError: Error? = nil + + var initialAmount: Int { editingExpense?.amount ?? 0 } + var initialMemo: String { editingExpense?.memo ?? "" } + + // MARK: - Private + private let expenseUseCase: ExpenseUseCaseProtocol + private let categoryUseCase: CategoryUseCaseProtocol + private let date: Date + private var amount: Int = 0 + private var memo: String = "" + private let editingExpense: Expense? + + // MARK: - Init + init( + expenseUseCase: ExpenseUseCaseProtocol, + categoryUseCase: CategoryUseCaseProtocol, + date: Date, + editingExpense: Expense? = nil + ) { + self.expenseUseCase = expenseUseCase + self.categoryUseCase = categoryUseCase + self.date = date + self.editingExpense = editingExpense + + if let expense = editingExpense { + self.amount = expense.amount + self.memo = expense.memo ?? "" + self.selectedCategory = expense.category + } + } + + // MARK: - Input + func loadCategories() async { + do { + categories = try await categoryUseCase.fetchCategories() + fetchError = nil + } catch { + fetchError = error + } + } + + func didSelectCategory(_ category: Category) { + selectedCategory = category + } + + func didInputAmount(_ amount: Int) { + self.amount = amount + } + + func didInputMemo(_ memo: String) { + self.memo = memo + } + + func didSaveExpense() async { + guard let category = selectedCategory else { return } + if let editing = editingExpense { + await expenseUseCase.delete(editing) + } + let expense = Expense( + date: date, + category: category, + memo: memo.isEmpty ? nil : memo, + amount: amount + ) + await expenseUseCase.add(expense) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/CategoryPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/CategoryPredictionCardView.swift new file mode 100644 index 0000000..88e2bed --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/CategoryPredictionCardView.swift @@ -0,0 +1,187 @@ +// +// CategoryPredictionCardView.swift +// SpendLearning +// +// Created by 김성훈 on 7/8/26. +// + +import SwiftUI +import Charts + +struct CategoryPredictionCardView: View { + + @State private var isExpanded = false + + let data: [CategoryPrediction] + let hasPrediction: Bool + + private var displayData: [CategoryPrediction] { + isExpanded ? data : Array(data.prefix(5)) + } + + var body: some View { + VStack(spacing: 0) { + header + if !hasPrediction { + noPredictionBanner + } + if data.isEmpty { + emptyView + } else { + chart + if data.count > 5 { + expandButton + } + } + } + .background(Color(UIColor.DesignSystem.surface)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .animation(.easeInOut, value: isExpanded) + } + + private var header: some View { + HStack { + Text("카테고리별 이번 달 실제 vs 예측") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + + Spacer() + + HStack(spacing: 10) { + legendItem(title: "실제", color: Color(UIColor.DesignSystem.accent)) + legendItem(title: "예측", color: Color(UIColor.DesignSystem.chartPredicted)) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(UIColor.DesignSystem.surface)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color(UIColor.DesignSystem.accent)) + } + + private var noPredictionBanner: some View { + Text("아직 예측이 없어요") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color(UIColor.DesignSystem.secondary).opacity(0.4)) + } + + private var emptyView: some View { + Text("아직 데이터가 없어요") + .font(.system(size: 14)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + + private var expandButton: some View { + Button { + isExpanded.toggle() + } label: { + Text(isExpanded ? "접기" : "전체보기 (\(data.count)개)") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + + private func legendItem(title: String, color: Color) -> some View { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: 12, height: 12) + Text(title) + .font(.system(size: 11)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + } + } + + private var chart: some View { + let maxActual = displayData.map { $0.actual }.max() ?? 0 + let maxPredicted = displayData.compactMap { $0.predicted }.max() ?? 0 + let maxValue = max(maxActual, maxPredicted) + + return Chart { + ForEach(displayData, id: \.categoryName) { item in + BarMark( + x: .value("금액", item.actual), + y: .value("카테고리", item.categoryName), + height: .fixed(11) + ) + .foregroundStyle(Color(UIColor.DesignSystem.accent)) + .position(by: .value("타입", "실제")) + .annotation(position: .trailing, alignment: .leading, spacing: 6) { + if item.actual > 0 { + Text(formatted(item.actual)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.accent)) + } + } + } + + ForEach(displayData, id: \.categoryName) { item in + BarMark( + x: .value("금액", item.predicted ?? 0), + y: .value("카테고리", item.categoryName), + height: .fixed(11) + ) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + .position(by: .value("타입", "예측")) + .annotation(position: .trailing, alignment: .leading, spacing: 6) { + if let predicted = item.predicted, predicted > 0 { + Text(formatted(predicted)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + } + } + } + } + .chartXScale(domain: 0...Int(Double(maxValue) * 1.5)) + .chartXAxis { + AxisMarks(position: .top) { value in + AxisValueLabel { + if let amount = value.as(Int.self) { + Text(yAxisLabel(amount)) + .font(.system(size: 10)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + } + } + AxisGridLine() + } + } + .chartYAxis { + AxisMarks { value in + AxisValueLabel { + if let category = value.as(String.self) { + Text(category) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + .lineLimit(1) + } + } + } + } + .frame(height: CGFloat(displayData.count) * 50) + .padding(.vertical, 12) + .padding(.horizontal, 8) + } + + private func formatted(_ amount: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return (formatter.string(from: NSNumber(value: amount)) ?? "\(amount)") + "원" + } + + private func yAxisLabel(_ amount: Int) -> String { + if amount >= 10000 { + return "\(amount / 10000)만" + } else { + return formatted(amount) + } + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/DailyPredictionCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/DailyPredictionCardView.swift new file mode 100644 index 0000000..0a7cacc --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/DailyPredictionCardView.swift @@ -0,0 +1,213 @@ +// +// DailyPredictionCardView.swift +// SpendLearning +// +// Created by 김성훈 on 7/8/26. +// + +import SwiftUI +import Charts + +struct DailyPredictionCardView: View { + + let data: [CumulativePrediction] + let today: Int + let lastDay: Int + let hasPrediction: Bool + + var body: some View { + VStack(spacing: 0) { + header + if !hasPrediction { + noPredictionBanner + } + if data.compactMap({ $0.actual }).isEmpty { + emptyView + } else { + chart + } + } + .background(Color(UIColor.DesignSystem.surface)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private var header: some View { + HStack { + Text("이번 달 실제 vs 예측 (누적)") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + + Spacer() + + HStack(spacing: 10) { + legendItem(title: "실제", color: Color(UIColor.DesignSystem.accent)) + legendItem(title: "예측", color: Color(UIColor.DesignSystem.chartPredicted)) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(UIColor.DesignSystem.surface)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(Color(UIColor.DesignSystem.accent)) + } + + private var noPredictionBanner: some View { + Text("아직 예측이 없어요") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color(UIColor.DesignSystem.secondary).opacity(0.4)) + } + + private var emptyView: some View { + Text("아직 데이터가 없어요") + .font(.system(size: 14)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + + private func legendItem(title: String, color: Color) -> some View { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: 12, height: 3) + Text(title) + .font(.system(size: 11)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + } + } + + private var chart: some View { + let actualValues = data.compactMap { $0.actual } + let predictedValues = data.compactMap { $0.predicted } + let maxValue = (actualValues + predictedValues).max() ?? 0 + let predictedTotal = data.compactMap { $0.predicted }.last ?? 0 + + return Chart { + if hasPrediction { + ForEach(data, id: \.day) { point in + if let predicted = point.predicted { + LineMark( + x: .value("날짜", point.day), + y: .value("금액", predicted), + series: .value("타입", "예측") + ) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + .lineStyle(StrokeStyle(lineWidth: 2, dash: [4, 4])) + } + } + + if let lastPoint = data.last, let predicted = lastPoint.predicted { + PointMark( + x: .value("날짜", lastPoint.day), + y: .value("금액", predicted) + ) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + .annotation(position: .top) { + Text("예측 총 \(formatted(predictedTotal))") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + } + } + } + + ForEach(data, id: \.day) { point in + if let actual = point.actual { + LineMark( + x: .value("날짜", point.day), + y: .value("금액", actual), + series: .value("타입", "실제") + ) + .foregroundStyle(Color(UIColor.DesignSystem.accent)) + } + } + + RuleMark(x: .value("오늘", today)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle).opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + .annotation(position: .top) { + 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))") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.accent)) + if hasPrediction { + Text("예측: \(formatted(predictedToday))") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Color(UIColor.DesignSystem.chartPredicted)) + } + } + .padding(6) + .background(Color(UIColor.DesignSystem.background)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + if let todayPoint = data.first(where: { $0.day == today }), + let actual = todayPoint.actual { + PointMark( + x: .value("날짜", todayPoint.day), + y: .value("금액", actual) + ) + .foregroundStyle(Color(UIColor.DesignSystem.accent)) + } + } + .chartXScale(domain: 1...40) + .chartXAxis { + let xAxisValues: [Int] = { + var values = [1, lastDay] + if today > 3 && today < lastDay - 3 { + values.insert(today, at: 1) + } + return values + }() + + AxisMarks(values: xAxisValues) { value in + AxisValueLabel { + if let day = value.as(Int.self) { + Text("\(day)일") + .font(.system(size: 10)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + } + } + AxisGridLine() + } + } + .chartYScale(domain: 0...Int(Double(maxValue) * 1.1)) + .chartYAxis { + AxisMarks(position: .leading) { value in + AxisValueLabel { + if let amount = value.as(Int.self) { + Text(yAxisLabel(amount)) + .font(.system(size: 10)) + .foregroundStyle(Color(UIColor.DesignSystem.subtitle)) + } + } + AxisGridLine() + } + } + .frame(height: 200) + .padding(.top, 50) + .padding(.bottom, 8) + .padding(.horizontal, 8) + } + + private func formatted(_ amount: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return (formatter.string(from: NSNumber(value: amount)) ?? "\(amount)") + "원" + } + + private func yAxisLabel(_ amount: Int) -> String { + if amount >= 10000 { + return "\(amount / 10000)만" + } else { + return formatted(amount) + } + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift new file mode 100644 index 0000000..10a7709 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionStatusCardView.swift @@ -0,0 +1,106 @@ +// +// PredictionStatusCardView.swift +// SpendLearning +// +// Created by 김성훈 on 7/8/26. +// + +import SwiftUI + +struct PredictionStatusCardView: View { + + @State private var isShowingConfirm = false + + let currentModel: PredictionModelMetadata? + let accuracy: Float? + let isRecalculating: Bool + let onRecalculate: () async -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + modelInfo + buttons + } + .background(Color(UIColor.DesignSystem.surface)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .alert("예측 다시 계산", isPresented: $isShowingConfirm) { + Button("계산하기") { + Task { await onRecalculate() } + } + Button("취소", role: .cancel) {} + } message: { + Text("지난달까지의 소비로 예측을 계산할까요?") + } + .tint(Color(UIColor.DesignSystem.accent)) + } + + private var modelInfo: some View { + 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("예측 정확도" + (accuracy.map { " \(Int($0))%" } ?? "")) + .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)) + + if let accuracy { + ProgressView(value: 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)) + + 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) + .padding(.top, 20) + .padding(.bottom, 20) + } + + private var buttons: some View { + Button { + isShowingConfirm = true + } label: { + Group { + if isRecalculating { + ProgressView() + .tint(.white) + } else { + Text("계산하기") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(.white) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color(UIColor.DesignSystem.accent)) + .clipShape(RoundedRectangle(cornerRadius: 14)) + } + .disabled(isRecalculating) + .padding(.horizontal, 20) + .padding(.bottom, 20) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionView.swift new file mode 100644 index 0000000..db6dfd8 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionView.swift @@ -0,0 +1,63 @@ +// +// PredictionView.swift +// SpendLearning +// +// Created by 김성훈 on 7/8/26. +// + +import SwiftUI + +struct PredictionView: View { + + @State var viewModel: PredictionViewModel + @State private var isShowingSuccessToast = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("소비 예측") + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(Color(UIColor.DesignSystem.primary)) + .padding(.top, 16) + + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 20) + .fill(Color(UIColor.DesignSystem.accent)) + .offset(y: -5) + + PredictionStatusCardView( + currentModel: viewModel.currentModel, + accuracy: viewModel.accuracy, + isRecalculating: viewModel.isRecalculating, + onRecalculate: { + await viewModel.recalculate() + isShowingSuccessToast = true + } + ) + } + + DailyPredictionCardView( + data: viewModel.predictionData, + today: viewModel.today, + lastDay: viewModel.lastDay, + hasPrediction: viewModel.hasPrediction + ) + + CategoryPredictionCardView( + data: viewModel.categoryData, + hasPrediction: viewModel.hasPrediction + ) + } + .padding(.bottom, 20) + .padding(.horizontal, 20) + } + .background(Color(UIColor.DesignSystem.background)) + .scrollIndicators(.hidden) + .onAppear { + Task { + await viewModel.onAppear() + } + } + .toast(isShowing: $isShowingSuccessToast, message: "예측이 계산되었어요") + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift new file mode 100644 index 0000000..4e52041 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Prediction/PredictionViewModel.swift @@ -0,0 +1,126 @@ +// +// PredictionViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Foundation + +@Observable +final class PredictionViewModel { + + // MARK: - Output + private(set) var currentModel: PredictionModelMetadata? = nil + private(set) var predictionData: [CumulativePrediction] = [] + private(set) var categoryData: [CategoryPrediction] = [] + 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(set) var isRecalculating: Bool = false + + private let expenseUseCase: ExpenseUseCaseProtocol + private let predictionUseCase: PredictionUseCaseProtocol + + var hasPrediction: Bool { + currentModel != nil + } + + /// 이번 달 1일~오늘까지의 누적 실제/예측 오차를 바탕으로 산출한 정확도(0~100) + /// 오차율이 클수록 감소 폭이 완만해지는 지수 감쇠 방식을 사용해, 오차가 100%를 넘어도 + /// 곧바로 0%가 되지 않고 오차 크기에 따라 점진적으로 낮아지도록 한다. + var accuracy: Float? { + guard let todayPoint = predictionData.first(where: { $0.day == today }), + let actual = todayPoint.actual, + let predicted = todayPoint.predicted, + actual > 0 else { return nil } + + let errorRatio = abs(Double(predicted) - Double(actual)) / Double(actual) + return Float(100 * exp(-errorRatio)) + } + + // MARK: - Init + init( + expenseUseCase: ExpenseUseCaseProtocol, + predictionUseCase: PredictionUseCaseProtocol + ) { + self.expenseUseCase = expenseUseCase + self.predictionUseCase = predictionUseCase + } + + // MARK: - Input + func onAppear() async { + await load() + } + + func recalculate() async { + isRecalculating = true + _ = await predictionUseCase.recalculate() + await load() + isRecalculating = false + } + + // 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 = predictionUseCase.fetchCurrentModel() + + let (fetchedExpenses, fetchedModel) = await (expenses, currentModel) + + self.currentModel = fetchedModel + self.predictionData = makeCumulativePrediction(expenses: fetchedExpenses, predictions: fetchedModel?.dailyPredictions ?? [:]) + self.categoryData = makeCategoryData(expenses: fetchedExpenses, predictions: fetchedModel?.categoryPredictions ?? [:], hasModel: fetchedModel != nil) + } + + 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 } + + if day <= today { + cumulativeActual += daily + } + if let predicted = predictions[day] { + cumulativePredicted += predicted + } + + return CumulativePrediction( + day: day, + actual: day <= today ? cumulativeActual : nil, + predicted: predictions[day] != nil ? cumulativePredicted : nil + ) + } + } + + private func makeCategoryData(expenses: [Expense], predictions: [String: Int], hasModel: Bool) -> [CategoryPrediction] { + var categoryTotals: [String: Int] = [:] + for expense in expenses where Calendar.current.component(.day, from: expense.date) <= today { + categoryTotals[expense.category.name, default: 0] += expense.amount + } + + let allCategories = Set(categoryTotals.keys).union(Set(predictions.keys)) + return allCategories.map { name in + // 예측 모델이 있을 때만, 실제 지출이 있는 카테고리는 과거 예측 데이터가 없어도 0원으로 표시한다. + let predicted = predictions[name] ?? (hasModel && categoryTotals[name] != nil ? 0 : nil) + return CategoryPrediction( + categoryName: name, + actual: categoryTotals[name] ?? 0, + predicted: predicted + ) + } + // 실제 지출도 없고 예측도 0(또는 없음)인 카테고리는 보여줄 정보가 없으므로 제외한다. + .filter { $0.actual > 0 || ($0.predicted ?? 0) > 0 } + .sorted { $0.actual > $1.actual } + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/CategoryEditViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/CategoryEditViewController.swift new file mode 100644 index 0000000..5c0c4e2 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/CategoryEditViewController.swift @@ -0,0 +1,216 @@ +// +// CategoryEditViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit + +private final class EmojiTextField: UITextField { + override var textInputContextIdentifier: String? { "" } + override var textInputMode: UITextInputMode? { + UITextInputMode.activeInputModes.first { $0.primaryLanguage == "emoji" } + } +} + +final class CategoryEditViewController: UIViewController { + + // MARK: - UI + private let titleLabel = UILabel() + private let emojiField = EmojiTextField() + private let emojiGuideLabel = UILabel() + private let nameField = UITextField() + private let nameGuideLabel = UILabel() + private let saveButton = UIButton() + + // MARK: - Properties + private let category: Category? + private let existingNames: [String] + private let onSave: (String, String) -> Void + + // MARK: - Init + init( + category: Category?, + existingNames: [String], + onSave: @escaping (String, String) -> Void + ) { + self.category = category + self.existingNames = existingNames + self.onSave = onSave + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .DesignSystem.background + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))) + setup() + prefill() + updateSaveButton() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + emojiField.becomeFirstResponder() + } +} + +// MARK: - Helper +private extension CategoryEditViewController { + + func setup() { + setupSubviews() + setupLabels() + setupFields() + setupSaveButton() + setupConstraints() + } + + func setupSubviews() { + [titleLabel, emojiField, emojiGuideLabel, nameField, nameGuideLabel, saveButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupLabels() { + titleLabel.text = category == nil ? "카테고리 추가" : "카테고리 편집" + titleLabel.font = .systemFont(ofSize: 17, weight: .bold) + titleLabel.textColor = .DesignSystem.primary + titleLabel.textAlignment = .center + + emojiGuideLabel.text = "이모지 1개를 입력해주세요" + emojiGuideLabel.font = .systemFont(ofSize: 12, weight: .regular) + emojiGuideLabel.textColor = .DesignSystem.subtitle + emojiGuideLabel.textAlignment = .center + + nameGuideLabel.font = .systemFont(ofSize: 12, weight: .regular) + nameGuideLabel.textColor = .DesignSystem.accent + nameGuideLabel.textAlignment = .left + } + + func setupFields() { + emojiField.font = .systemFont(ofSize: 36) + emojiField.textAlignment = .center + emojiField.backgroundColor = .DesignSystem.surface + emojiField.layer.cornerRadius = 12 + emojiField.autocorrectionType = .no + emojiField.addTarget(self, action: #selector(emojiFieldDidChange), for: .editingChanged) + + nameField.placeholder = "카테고리 이름" + nameField.font = .systemFont(ofSize: 16, weight: .semibold) + nameField.textColor = .DesignSystem.primary + nameField.backgroundColor = .DesignSystem.surface + nameField.layer.cornerRadius = 12 + nameField.autocorrectionType = .no + nameField.autocapitalizationType = .none + nameField.addTarget(self, action: #selector(nameFieldDidChange), for: .editingChanged) + + let namePadding = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 0)) + nameField.leftView = namePadding + nameField.leftViewMode = .always + } + + func setupSaveButton() { + saveButton.backgroundColor = .DesignSystem.primary + saveButton.setTitle("저장", for: .normal) + saveButton.setTitleColor(.white, for: .normal) + saveButton.setTitleColor(.white.withAlphaComponent(0.4), for: .disabled) + saveButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold) + saveButton.layer.cornerRadius = 14 + saveButton.addAction(UIAction { [weak self] _ in + self?.didTapSave() + }, for: .touchUpInside) + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 24), + titleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + emojiField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 32), + emojiField.centerXAnchor.constraint(equalTo: view.centerXAnchor), + emojiField.widthAnchor.constraint(equalToConstant: 72), + emojiField.heightAnchor.constraint(equalToConstant: 72), + + emojiGuideLabel.topAnchor.constraint(equalTo: emojiField.bottomAnchor, constant: 8), + emojiGuideLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + + nameField.topAnchor.constraint(equalTo: emojiGuideLabel.bottomAnchor, constant: 20), + nameField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + nameField.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + nameField.heightAnchor.constraint(equalToConstant: 52), + + nameGuideLabel.topAnchor.constraint(equalTo: nameField.bottomAnchor, constant: 8), + nameGuideLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + nameGuideLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + saveButton.heightAnchor.constraint(equalToConstant: 52), + saveButton.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -20), + ]) + } + + func prefill() { + guard let category else { return } + emojiField.text = category.emoji + nameField.text = category.name + } + + func isValidEmoji(_ text: String) -> Bool { + guard text.count == 1 else { return false } + guard let scalar = text.unicodeScalars.first else { return false } + return scalar.properties.isEmoji + } + + func updateSaveButton() { + let emoji = emojiField.text ?? "" + let name = nameField.text?.trimmingCharacters(in: .whitespaces) ?? "" + let isDuplicate = existingNames.contains(name) + let valid = isValidEmoji(emoji) && !name.isEmpty && !isDuplicate + saveButton.isEnabled = valid + saveButton.alpha = valid ? 1.0 : 0.4 + } + + @objc func emojiFieldDidChange() { + let text = emojiField.text ?? "" + if text.count > 1 { + emojiField.text = String(text.prefix(1)) + } + let emoji = emojiField.text ?? "" + if !emoji.isEmpty && !isValidEmoji(emoji) { + emojiGuideLabel.text = "이모지만 입력 가능해요" + emojiGuideLabel.textColor = .systemRed + emojiField.text = "" + } else { + emojiGuideLabel.text = "이모지 1개를 입력해주세요" + emojiGuideLabel.textColor = .DesignSystem.subtitle + } + updateSaveButton() + } + + @objc func nameFieldDidChange() { + let name = nameField.text?.trimmingCharacters(in: .whitespaces) ?? "" + nameGuideLabel.text = existingNames.contains(name) ? "이미 존재하는 카테고리 이름이에요" : nil + updateSaveButton() + } + + @objc func dismissKeyboard() { + view.endEditing(true) + } + + func didTapSave() { + let emoji = emojiField.text ?? "" + let name = nameField.text?.trimmingCharacters(in: .whitespaces) ?? "" + guard isValidEmoji(emoji), !name.isEmpty, !existingNames.contains(name) else { return } + onSave(name, emoji) + dismiss(animated: true) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/CategoryManageViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/CategoryManageViewController.swift new file mode 100644 index 0000000..65d5cfc --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/CategoryManageViewController.swift @@ -0,0 +1,385 @@ +// +// CategoryManageViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit +import Combine + +final class CategoryManageViewController: UIViewController { + + // MARK: - UI + private let navigationBar = CustomNavigationBar( + title: "카테고리 관리", leftButtonTitle: "뒤로", rightButtonTitle: "편집" + ) + private let categoryTableView = UITableView() + private let addButton = UIButton() + private let resetButton = UIButton() + private let tableFooterView = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: 0)) + + // MARK: - Properties + private let viewModel: SettingsViewModel + private var cancellables = Set() + private var isEditingMode = false + + // MARK: - Init + init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .DesignSystem.background + setup() + bind() + loadCategories() + } +} + +// MARK: - Bind +private extension CategoryManageViewController { + + func bind() { + viewModel.$categories + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.categoryTableView.reloadData() + } + .store(in: &cancellables) + + viewModel.$fetchError + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] _ in + self?.showFetchErrorAlert() + } + .store(in: &cancellables) + + viewModel.$saveError + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] _ in + self?.showSaveErrorAlert() + } + .store(in: &cancellables) + } +} + +// MARK: - UITableViewDataSource +extension CategoryManageViewController: UITableViewDataSource { + + func tableView( + _ tableView: UITableView, + numberOfRowsInSection section: Int + ) -> Int { + viewModel.categories.count + } + + func tableView( + _ tableView: UITableView, + cellForRowAt indexPath: IndexPath + ) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: SettingsCategoryCell.reuseIdentifier, + for: indexPath + ) as! SettingsCategoryCell + let category = viewModel.categories[indexPath.row] + cell.configure(category: category, isEditing: isEditingMode) + cell.onEdit = { [weak self] in + self?.presentEditSheet(category: category) + } + return cell + } + + func tableView( + _ tableView: UITableView, + canMoveRowAt indexPath: IndexPath + ) -> Bool { + true + } + + func tableView( + _ tableView: UITableView, + moveRowAt sourceIndexPath: IndexPath, + to destinationIndexPath: IndexPath + ) { + var reordered = viewModel.categories + let moved = reordered.remove(at: sourceIndexPath.row) + reordered.insert(moved, at: destinationIndexPath.row) + Task { + await viewModel.reorderCategories(reordered) + } + } + + func tableView( + _ tableView: UITableView, + commit editingStyle: UITableViewCell.EditingStyle, + forRowAt indexPath: IndexPath + ) { + guard editingStyle == .delete else { return } + didTapDelete(at: indexPath.row) + } +} + +// MARK: - UITableViewDelegate +extension CategoryManageViewController: UITableViewDelegate { + + func tableView( + _ tableView: UITableView, + heightForRowAt indexPath: IndexPath + ) -> CGFloat { + 64 + } + +// func tableView( +// _ tableView: UITableView, +// didSelectRowAt indexPath: IndexPath +// ) { +// guard isEditingMode else { return } +// presentEditSheet(category: viewModel.categories[indexPath.row]) +// } + + func tableView( + _ tableView: UITableView, + targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, + toProposedIndexPath proposedDestinationIndexPath: IndexPath + ) -> IndexPath { + proposedDestinationIndexPath + } + + func tableView( + _ tableView: UITableView, + editingStyleForRowAt indexPath: IndexPath + ) -> UITableViewCell.EditingStyle { + viewModel.categories[indexPath.row].isDeletable ? .delete : .none + } + + func tableView( + _ tableView: UITableView, + shouldIndentWhileEditingRowAt indexPath: IndexPath + ) -> Bool { + false + } + + func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + guard viewModel.categories[indexPath.row].isDeletable else { + return UISwipeActionsConfiguration(actions: []) + } + let action = UIContextualAction(style: .destructive, title: nil) { + [weak self] _, _, completion in + self?.didTapDelete(at: indexPath.row) + completion(true) + } + action.image = UIImage(systemName: "trash") + return UISwipeActionsConfiguration(actions: [action]) + } +} + +// MARK: - Helper +private extension CategoryManageViewController { + + func setup() { + setupNavigationBar() + setupFooterButtons() + setupSubviews() + setupConstraints() + setupCategoryTableView() + } + + func setupNavigationBar() { + navigationBar.onLeftAction = { [weak self] in + self?.dismiss(animated: true) + } + navigationBar.onRightAction = { [weak self] in + self?.toggleEditingMode() + } + } + + func setupFooterButtons() { + var addConfig = UIButton.Configuration.plain() + addConfig.title = "+ 카테고리 추가" + addConfig.baseForegroundColor = .DesignSystem.accent + addConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in + var a = attrs + a.font = UIFont.systemFont(ofSize: 15, weight: .semibold) + return a + } + addButton.configuration = addConfig + addButton.addAction(UIAction { [weak self] _ in + self?.presentEditSheet(category: nil) + }, for: .touchUpInside) + + var resetConfig = UIButton.Configuration.plain() + resetConfig.title = "카테고리 초기화" + resetConfig.baseForegroundColor = .systemRed + resetConfig.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attrs in + var a = attrs + a.font = UIFont.systemFont(ofSize: 15, weight: .semibold) + return a + } + resetButton.configuration = resetConfig + resetButton.addAction(UIAction { [weak self] _ in + self?.didTapReset() + }, for: .touchUpInside) + + addButton.isHidden = true + resetButton.isHidden = true + + [addButton, resetButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + tableFooterView.addSubview($0) + } + + NSLayoutConstraint.activate([ + addButton.leadingAnchor.constraint(equalTo: tableFooterView.leadingAnchor, constant: 4), + addButton.centerYAnchor.constraint(equalTo: tableFooterView.centerYAnchor), + + resetButton.trailingAnchor.constraint(equalTo: tableFooterView.trailingAnchor, constant: -4), + resetButton.centerYAnchor.constraint(equalTo: tableFooterView.centerYAnchor), + ]) + + categoryTableView.tableFooterView = tableFooterView + } + + func setupSubviews() { + [navigationBar, categoryTableView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + navigationBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + categoryTableView.topAnchor.constraint(equalTo: navigationBar.bottomAnchor, constant: 24), + categoryTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + categoryTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + categoryTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + func setupCategoryTableView() { + categoryTableView.backgroundColor = .DesignSystem.surface + categoryTableView.layer.cornerRadius = 16 + categoryTableView.isScrollEnabled = true + categoryTableView.showsVerticalScrollIndicator = false + categoryTableView.separatorStyle = .none + categoryTableView.dataSource = self + categoryTableView.delegate = self + categoryTableView.register( + SettingsCategoryCell.self, + forCellReuseIdentifier: SettingsCategoryCell.reuseIdentifier + ) + } + + func loadCategories() { + Task { + await viewModel.loadCategories() + } + } + + func toggleEditingMode() { + isEditingMode.toggle() + navigationBar.updateRightButton(title: isEditingMode ? "완료" : "편집") + categoryTableView.setEditing(isEditingMode, animated: true) + categoryTableView.reloadData() + addButton.isHidden = !isEditingMode + resetButton.isHidden = !isEditingMode + tableFooterView.frame.size.height = isEditingMode ? 52 : 0 + categoryTableView.tableFooterView = tableFooterView + } + + func didTapDelete(at index: Int) { + let category = viewModel.categories[index] + let alert = UIAlertController( + title: "\"\(category.name)\" 삭제", + message: "이 카테고리 지출은 '기타'로 변경됩니다.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "삭제", style: .destructive) { [weak self] _ in + Task { + await self?.viewModel.deleteCategory(at: index) + } + }) + present(alert, animated: true) + } + + func didTapReset() { + let alert = UIAlertController( + title: "카테고리 초기화", + message: "기본 카테고리 10개로 되돌립니다.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "초기화", style: .destructive) { [weak self] _ in + Task { + await self?.viewModel.resetCategories() + } + }) + present(alert, animated: true) + } + + func presentEditSheet(category: Category?) { + let existingNames = viewModel.categories + .filter { $0.id != category?.id } + .map { $0.name } + + let editVC = CategoryEditViewController( + category: category, + existingNames: existingNames, + onSave: { [weak self] name, emoji in + Task { + if let category { + await self?.viewModel.updateCategory(category, name: name, emoji: emoji) + } else { + await self?.viewModel.addCategory(name: name, emoji: emoji) + } + } + } + ) + if let sheet = editVC.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 + } + present(editVC, animated: true) + } + + func showFetchErrorAlert() { + let alert = UIAlertController( + title: "불러오기 실패", + message: "카테고리를 불러오지 못했습니다.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "다시 시도", style: .default) { [weak self] _ in + Task { await self?.viewModel.loadCategories() } + }) + present(alert, animated: true) + } + + func showSaveErrorAlert() { + let alert = UIAlertController( + title: "저장 실패", + message: "변경사항을 저장하지 못했습니다.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default)) + present(alert, animated: true) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsCategoryCell.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsCategoryCell.swift new file mode 100644 index 0000000..d68809e --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsCategoryCell.swift @@ -0,0 +1,92 @@ +// +// SettingsCategoryCell.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit + +final class SettingsCategoryCell: UITableViewCell { + + static let reuseIdentifier = "SettingsCategoryCell" + + // MARK: - UI + private let iconView = EmojiCircleView(emoji: "📦", size: 46) + private let titleLabel = UILabel() + private let editButton = UIButton() + private let separator = UIView() + + // MARK: - Action + var onEdit: (() -> Void)? + + // MARK: - Init + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + func configure(category: Category, isEditing: Bool) { + iconView.update(emoji: category.emoji) + titleLabel.text = category.displayName + editButton.isHidden = !isEditing || !category.isDeletable + } + + override func prepareForReuse() { + super.prepareForReuse() + iconView.update(emoji: "") // 추가 + titleLabel.text = nil + editButton.isHidden = true + onEdit = nil + } +} + +// MARK: - Helper +private extension SettingsCategoryCell { + + func setup() { + backgroundColor = .clear + selectionStyle = .none + + titleLabel.font = .systemFont(ofSize: 14, weight: .semibold) + titleLabel.textColor = .DesignSystem.primary + + editButton.setImage(UIImage(systemName: "pencil.line"), for: .normal) + editButton.tintColor = .DesignSystem.subtitle + editButton.isHidden = true + editButton.addAction(UIAction { [weak self] _ in + self?.onEdit?() + }, for: .touchUpInside) + + separator.backgroundColor = .DesignSystem.separator + + [iconView, titleLabel, editButton, separator].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + + editButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + editButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + editButton.widthAnchor.constraint(equalToConstant: 24), + editButton.heightAnchor.constraint(equalToConstant: 24), + + titleLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + titleLabel.trailingAnchor.constraint(equalTo: editButton.leadingAnchor, constant: -8), + + separator.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), + separator.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + separator.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separator.heightAnchor.constraint(equalToConstant: 0.5), + ]) + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift new file mode 100644 index 0000000..e1fd592 --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsRowView.swift @@ -0,0 +1,84 @@ +// +// SettingsRowView.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit + +final class SettingsRowView: UIView { + + // MARK: - UI + private let iconView = IconCircleView(symbolName: "list.bullet", size: 46) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let chevronImageView = UIImageView() + + // MARK: - Action + var onTap: (() -> Void)? + + // MARK: - Init + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Configure + func configure(iconName: String, title: String, subtitle: String) { + iconView.update(symbolName: iconName) + titleLabel.text = title + subtitleLabel.text = subtitle + } +} + +// MARK: - Helper +private extension SettingsRowView { + + func setup() { + backgroundColor = .DesignSystem.surface + layer.cornerRadius = 16 + + titleLabel.font = .systemFont(ofSize: 16, weight: .semibold) + titleLabel.textColor = .DesignSystem.primary + + subtitleLabel.font = .systemFont(ofSize: 13, weight: .regular) + subtitleLabel.textColor = .DesignSystem.subtitle + + chevronImageView.image = UIImage(systemName: "chevron.right") + chevronImageView.tintColor = .DesignSystem.subtitle + chevronImageView.contentMode = .scaleAspectFit + + let labelStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + labelStack.axis = .vertical + labelStack.spacing = 3 + + [iconView, labelStack, chevronImageView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + addSubview($0) + } + + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + iconView.centerYAnchor.constraint(equalTo: centerYAnchor), + + labelStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 14), + labelStack.centerYAnchor.constraint(equalTo: centerYAnchor), + + chevronImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + chevronImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + chevronImageView.widthAnchor.constraint(equalToConstant: 14), + chevronImageView.heightAnchor.constraint(equalToConstant: 20), + ]) + + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTap))) + } + + @objc func didTap() { + onTap?() + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift new file mode 100644 index 0000000..50be54b --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewController.swift @@ -0,0 +1,167 @@ +// +// SettingsViewController.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import UIKit +import Combine + +final class SettingsViewController: UIViewController { + + // MARK: - UI + private let titleLabel = UILabel() + private let categorySectionLabel = UILabel() + private let categoryRowView = SettingsRowView() + private let dataSectionLabel = UILabel() + private let resetRowView = SettingsRowView() + + // MARK: - Properties + private let viewModel: SettingsViewModel + private var cancellables = Set() + + // MARK: - Init + init( + categoryUseCase: CategoryUseCaseProtocol, + expenseUseCase: ExpenseUseCaseProtocol, + predictionUseCase: PredictionUseCaseProtocol + ) { + self.viewModel = SettingsViewModel( + categoryUseCase: categoryUseCase, + expenseUseCase: expenseUseCase, + predictionUseCase: predictionUseCase + ) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .DesignSystem.background + setup() + bind() + loadCategories() + } +} + +// MARK: - Bind +private extension SettingsViewController { + + func bind() { + viewModel.$categories + .receive(on: DispatchQueue.main) + .sink { [weak self] categories in + self?.categoryRowView.configure( + iconName: "list.bullet", + title: "카테고리 관리", + subtitle: "\(categories.count)개 카테고리 사용 중" + ) + } + .store(in: &cancellables) + } +} + +// MARK: - Helper +private extension SettingsViewController { + + func setup() { + setupSubviews() + setupLabels() + setupConstraints() + setupCategoryRow() + setupResetRow() + } + + func setupSubviews() { + [titleLabel, categorySectionLabel, categoryRowView, dataSectionLabel, resetRowView].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + view.addSubview($0) + } + } + + func setupLabels() { + titleLabel.text = "설정" + titleLabel.font = .systemFont(ofSize: 24, weight: .heavy) + titleLabel.textColor = .DesignSystem.primary + + categorySectionLabel.text = "카테고리" + categorySectionLabel.font = .systemFont(ofSize: 13, weight: .semibold) + categorySectionLabel.textColor = .DesignSystem.subtitle + + dataSectionLabel.text = "데이터" + dataSectionLabel.font = .systemFont(ofSize: 13, weight: .semibold) + dataSectionLabel.textColor = .DesignSystem.subtitle + } + + func setupConstraints() { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + categorySectionLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 28), + categorySectionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + categoryRowView.topAnchor.constraint(equalTo: categorySectionLabel.bottomAnchor, constant: 8), + categoryRowView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + categoryRowView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + categoryRowView.heightAnchor.constraint(equalToConstant: 72), + + dataSectionLabel.topAnchor.constraint(equalTo: categoryRowView.bottomAnchor, constant: 28), + dataSectionLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + + resetRowView.topAnchor.constraint(equalTo: dataSectionLabel.bottomAnchor, constant: 8), + resetRowView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + resetRowView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + resetRowView.heightAnchor.constraint(equalToConstant: 72), + ]) + } + + func setupCategoryRow() { + categoryRowView.onTap = { [weak self] in + self?.pushCategoryManage() + } + } + + func setupResetRow() { + resetRowView.configure(iconName: "trash", title: "전체 초기화", subtitle: "모든 소비 기록과 예측이 삭제돼요") + resetRowView.onTap = { [weak self] in + self?.showResetConfirmAlert() + } + } + + func loadCategories() { + Task { + await viewModel.loadCategories() + } + } + + func pushCategoryManage() { + let manageVC = CategoryManageViewController(viewModel: viewModel) + manageVC.modalPresentationStyle = .fullScreen + present(manageVC, animated: true) + } + + func showResetConfirmAlert() { + let alert = UIAlertController( + title: "전체 초기화", + message: "모든 소비 기록, 카테고리 설정,\n예측이 초기화돼요.\n이 작업은 되돌릴 수 없어요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "초기화", style: .destructive) { [weak self] _ in + self?.resetAllData() + }) + present(alert, animated: true) + } + + func resetAllData() { + Task { + await viewModel.resetAllData() + } + } +} diff --git a/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..391325e --- /dev/null +++ b/SpendLearning/SpendLearning/Source/Presentation/View/Settings/SettingsViewModel.swift @@ -0,0 +1,107 @@ +// +// SettingsViewModel.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import Combine + +@MainActor +final class SettingsViewModel { + + // MARK: - Output + @Published private(set) var categories: [Category] = [] + @Published private(set) var fetchError: Error? = nil + @Published private(set) var saveError: Error? = nil + + // MARK: - Private + private let categoryUseCase: CategoryUseCaseProtocol + private let expenseUseCase: ExpenseUseCaseProtocol + private let predictionUseCase: PredictionUseCaseProtocol + + // MARK: - Init + init( + categoryUseCase: CategoryUseCaseProtocol, + expenseUseCase: ExpenseUseCaseProtocol, + predictionUseCase: PredictionUseCaseProtocol + ) { + self.categoryUseCase = categoryUseCase + self.expenseUseCase = expenseUseCase + self.predictionUseCase = predictionUseCase + } + + // MARK: - Input + func loadCategories() async { + do { + categories = try await categoryUseCase.fetchCategories() + fetchError = nil + } catch { + fetchError = error + } + } + + func addCategory(name: String, emoji: String) async { + do { + try await categoryUseCase.addCategory(name: name, emoji: emoji) + categories = (try? await categoryUseCase.fetchCategories()) ?? categories + } catch { + saveError = error + await loadCategories() + } + } + + func updateCategory(_ category: Category, name: String, emoji: String) async { + do { + try await categoryUseCase.updateCategory(category, name: name, emoji: emoji) + categories = (try? await categoryUseCase.fetchCategories()) ?? categories + } catch { + saveError = error + await loadCategories() + } + } + + func deleteCategory(at index: Int) async { + let category = categories[index] + do { + try await categoryUseCase.deleteCategory(category) + categories = (try? await categoryUseCase.fetchCategories()) ?? categories + } catch { + saveError = error + await loadCategories() + } + } + + func resetCategories() async { + do { + try await categoryUseCase.resetToDefault() + categories = (try? await categoryUseCase.fetchCategories()) ?? categories + } catch { + saveError = error + await loadCategories() + } + } + + func reorderCategories(_ categories: [Category]) async { + do { + try await categoryUseCase.reorderCategories(categories) + self.categories = (try? await categoryUseCase.fetchCategories()) ?? self.categories + } catch { + saveError = error + await loadCategories() + } + } + + func resetAllData() async { + await expenseUseCase.deleteAll() + await predictionUseCase.deleteModel() + do { + try await categoryUseCase.resetToDefault() + categories = (try? await categoryUseCase.fetchCategories()) ?? categories + } catch { + saveError = error + await loadCategories() + } + } +} diff --git a/SpendLearning/SpendLearningTests/UseCases/CategoryUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/CategoryUseCaseTests.swift new file mode 100644 index 0000000..ca81cf4 --- /dev/null +++ b/SpendLearning/SpendLearningTests/UseCases/CategoryUseCaseTests.swift @@ -0,0 +1,149 @@ +// +// CategoryUseCaseTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Testing + +@Suite("CategoryUseCase") +@MainActor +struct CategoryUseCaseTests { + + @Test("fetchCategories 호출 시 Repository의 fetchCategories가 호출된다") + func fetchCategoriesCallsRepository() async throws { + let spy = SpyCategoryRepository() + let sut = CategoryUseCase(repository: spy) + + _ = try await sut.fetchCategories() + + #expect(spy.fetchCallCount == 1) + } + + @Test("fetchCategories 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchCategoriesReturnsRepositoryResult() async throws { + let spy = SpyCategoryRepository() + let category = Category(name: "식비", emoji: "🍚") + spy.stubbedCategories = [category] + let sut = CategoryUseCase(repository: spy) + + let result = try await sut.fetchCategories() + + #expect(result.count == 1) + #expect(result.first?.id == category.id) + } + + @Test("addCategory 호출 시 Repository의 addCategory가 올바른 name/emoji로 호출된다") + func addCategoryCallsRepositoryWithCorrectArguments() async throws { + let spy = SpyCategoryRepository() + let sut = CategoryUseCase(repository: spy) + + try await sut.addCategory(name: "카페", emoji: "☕️") + + #expect(spy.addCallCount == 1) + #expect(spy.addedName == "카페") + #expect(spy.addedEmoji == "☕️") + } + + @Test("updateCategory 호출 시 Repository의 updateCategory가 올바른 인자로 호출된다") + func updateCategoryCallsRepositoryWithCorrectArguments() async throws { + let spy = SpyCategoryRepository() + let category = Category(name: "식비", emoji: "🍚") + let sut = CategoryUseCase(repository: spy) + + try await sut.updateCategory(category, name: "외식", emoji: "🍖") + + #expect(spy.updateCallCount == 1) + #expect(spy.updatedCategory?.id == category.id) + #expect(spy.updatedName == "외식") + #expect(spy.updatedEmoji == "🍖") + } + + @Test("deleteCategory 호출 시 Repository의 deleteCategory가 올바른 category로 호출된다") + func deleteCategoryCallsRepositoryWithCorrectCategory() async throws { + let spy = SpyCategoryRepository() + let category = Category(name: "쇼핑", emoji: "🛍️") + let sut = CategoryUseCase(repository: spy) + + try await sut.deleteCategory(category) + + #expect(spy.deleteCallCount == 1) + #expect(spy.deletedCategory?.id == category.id) + } + + @Test("resetToDefault 호출 시 Repository의 resetToDefault가 호출된다") + func resetToDefaultCallsRepository() async throws { + let spy = SpyCategoryRepository() + let sut = CategoryUseCase(repository: spy) + + try await sut.resetToDefault() + + #expect(spy.resetCallCount == 1) + } + + @Test("reorderCategories 호출 시 Repository의 reorderCategories가 올바른 순서로 호출된다") + func reorderCategoriesCallsRepositoryWithCorrectOrder() async throws { + let spy = SpyCategoryRepository() + let first = Category(name: "식비", emoji: "🍚") + let second = Category(name: "교통", emoji: "🚌") + let sut = CategoryUseCase(repository: spy) + + try await sut.reorderCategories([first, second]) + + #expect(spy.reorderCallCount == 1) + #expect(spy.reorderedCategories?.first?.id == first.id) + #expect(spy.reorderedCategories?.last?.id == second.id) + } +} + +// MARK: - Spy + +final class SpyCategoryRepository: CategoryRepositoryProtocol { + private(set) var fetchCallCount = 0 + private(set) var addCallCount = 0 + private(set) var addedName: String? + private(set) var addedEmoji: String? + private(set) var updateCallCount = 0 + private(set) var updatedCategory: Category? + private(set) var updatedName: String? + private(set) var updatedEmoji: String? + private(set) var deleteCallCount = 0 + private(set) var deletedCategory: Category? + private(set) var resetCallCount = 0 + private(set) var reorderCallCount = 0 + private(set) var reorderedCategories: [Category]? + var stubbedCategories: [Category] = [] + + func fetchCategories() async throws -> [Category] { + fetchCallCount += 1 + return stubbedCategories + } + + func addCategory(name: String, emoji: String) async throws { + addCallCount += 1 + addedName = name + addedEmoji = emoji + } + + func updateCategory(_ category: Category, name: String, emoji: String) async throws { + updateCallCount += 1 + updatedCategory = category + updatedName = name + updatedEmoji = emoji + } + + func deleteCategory(_ category: Category) async throws { + deleteCallCount += 1 + deletedCategory = category + } + + func resetToDefault() async throws { + resetCallCount += 1 + } + + func reorderCategories(_ categories: [Category]) async throws { + reorderCallCount += 1 + reorderedCategories = categories + } +} diff --git a/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift new file mode 100644 index 0000000..671cb18 --- /dev/null +++ b/SpendLearning/SpendLearningTests/UseCases/ExpenseUseCaseTests.swift @@ -0,0 +1,82 @@ +// +// ExpenseUseCaseTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Foundation +import Testing + +@Suite("ExpenseUseCase") +@MainActor +struct ExpenseUseCaseTests { + + @Test("fetch 호출 시 Repository의 fetchExpenses가 올바른 year/month로 호출된다") + func fetchCallsRepositoryWithCorrectYearMonth() async { + let spy = SpyExpenseRepository() + let sut = ExpenseUseCase(repository: spy) + + _ = await sut.fetch(year: 2026, month: 7) + + #expect(spy.fetchCallCount == 1) + #expect(spy.fetchedYear == 2026) + #expect(spy.fetchedMonth == 7) + } + + @Test("fetch 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchReturnsRepositoryResult() async { + let spy = SpyExpenseRepository() + let expense = Expense(date: Date(), category: Category(name: "식비", emoji: "🍚"), memo: nil, amount: 5000) + spy.stubbedExpenses = [expense] + let sut = ExpenseUseCase(repository: spy) + + let result = await sut.fetch(year: 2026, month: 7) + + #expect(result.count == 1) + #expect(result.first?.id == expense.id) + } + + @Test("delete 호출 시 Repository의 deleteExpense가 올바른 Expense로 호출된다") + func deleteCallsRepositoryWithCorrectExpense() async { + let spy = SpyExpenseRepository() + let expense = Expense(date: Date(), category: Category(name: "식비", emoji: "🍚"), memo: nil, amount: 5000) + let sut = ExpenseUseCase(repository: spy) + + await sut.delete(expense) + + #expect(spy.deleteCallCount == 1) + #expect(spy.deletedExpense?.id == expense.id) + } +} + +// MARK: - Spy + +final class SpyExpenseRepository: ExpenseRepositoryProtocol { + private(set) var fetchCallCount = 0 + private(set) var fetchedYear: Int? + private(set) var fetchedMonth: Int? + private(set) var deleteCallCount = 0 + private(set) var deletedExpense: Expense? + var stubbedExpenses: [Expense] = [] + + func fetchExpenses(year: Int, month: Int) async -> [Expense] { + fetchCallCount += 1 + fetchedYear = year + fetchedMonth = month + return stubbedExpenses + } + + func fetchAllExpenses() async -> [Expense] { + return stubbedExpenses + } + + func addExpense(_ expense: Expense) async {} + + func deleteExpense(_ expense: Expense) async { + deleteCallCount += 1 + deletedExpense = expense + } + + func deleteAll() async {} +} diff --git a/SpendLearning/SpendLearningTests/UseCases/PredictionUseCaseTests.swift b/SpendLearning/SpendLearningTests/UseCases/PredictionUseCaseTests.swift new file mode 100644 index 0000000..6d401a9 --- /dev/null +++ b/SpendLearning/SpendLearningTests/UseCases/PredictionUseCaseTests.swift @@ -0,0 +1,80 @@ +// +// PredictionUseCaseTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("PredictionUseCase") +@MainActor +struct PredictionUseCaseTests { + + @Test("fetchCurrentModel 호출 시 Repository에서 반환한 결과를 그대로 반환한다") + func fetchCurrentModelReturnsRepositoryResult() async { + let spy = SpyPredictionRepository() + let model = PredictionModelMetadata(id: "SP260714", dataCount: 1204, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + spy.stubbedCurrentModel = model + let sut = PredictionUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + let result = await sut.fetchCurrentModel() + + #expect(result?.id == model.id) + } + + @Test("recalculate 호출 시 Repository의 recalculate가 호출된다") + func recalculateCallsRepository() async { + let spy = SpyPredictionRepository() + let sut = PredictionUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + _ = await sut.recalculate() + + #expect(spy.recalculateCallCount == 1) + } + + @Test("recalculate 호출 시 계산된 모델을 반환한다") + func recalculateReturnsModel() async { + let spy = SpyPredictionRepository() + let model = PredictionModelMetadata(id: "SP260714", dataCount: 10, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + spy.stubbedRecalculateResult = model + let sut = PredictionUseCase(repository: spy, expenseRepository: StubExpenseRepository()) + + let result = await sut.recalculate() + + #expect(result.id == "SP260714") + } +} + +// MARK: - Spy + +final class SpyPredictionRepository: PredictionRepositoryProtocol { + private(set) var fetchCurrentModelCallCount = 0 + private(set) var recalculateCallCount = 0 + + var stubbedCurrentModel: PredictionModelMetadata? = nil + var stubbedRecalculateResult = PredictionModelMetadata(id: "SP000000", dataCount: 0, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + + func fetchCurrentModel() async -> PredictionModelMetadata? { + fetchCurrentModelCallCount += 1 + return stubbedCurrentModel + } + + func recalculate(expenses: [Expense]) async -> PredictionModelMetadata { + recalculateCallCount += 1 + return stubbedRecalculateResult + } + + func deleteModel() async {} +} + +// MARK: - Stub + +final class StubExpenseRepository: ExpenseRepositoryProtocol { + func fetchExpenses(year: Int, month: Int) async -> [Expense] { [] } + func fetchAllExpenses() async -> [Expense] { [] } + func addExpense(_ expense: Expense) async {} + func deleteExpense(_ expense: Expense) async {} + func deleteAll() async {} +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift new file mode 100644 index 0000000..46c3b9b --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/HomeViewModelTests.swift @@ -0,0 +1,42 @@ +// +// HomeViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/1/26. +// + +import Testing + +final class StubExpenseUseCase: ExpenseUseCaseProtocol { + func add(_ expense: Expense) async {} + func fetch(year: Int, month: Int) async -> [Expense] { [] } + func delete(_ expense: Expense) async {} + func deleteAll() async {} +} + +@Suite("HomeViewModel") +@MainActor +struct HomeViewModelTests { + + @Test("1월에서 이전 달로 가면 전년도 12월이 된다") + func previousMonthFromJanuary() async { + let sut = HomeViewModel(expenseUseCase: StubExpenseUseCase()) + sut.didSelectYearMonth(year: 2026, month: 1) + + sut.didTapPreviousMonth() + + #expect(sut.currentYear == 2025) + #expect(sut.currentMonth == 12) + } + + @Test("12월에서 다음 달로 가면 다음년도 1월이 된다") + func nextMonthFromDecember() async { + let sut = HomeViewModel(expenseUseCase: StubExpenseUseCase()) + sut.didSelectYearMonth(year: 2026, month: 12) + + sut.didTapNextMonth() + + #expect(sut.currentYear == 2027) + #expect(sut.currentMonth == 1) + } +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift new file mode 100644 index 0000000..e9e2b71 --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/NewExpenseViewModelTests.swift @@ -0,0 +1,108 @@ +// +// NewExpenseViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Testing +import Foundation + +final class SpyExpenseUseCase: ExpenseUseCaseProtocol { + private(set) var addCallCount = 0 + private(set) var addedExpense: Expense? + private(set) var deleteCallCount = 0 + private(set) var deletedExpense: Expense? + + func fetch(year: Int, month: Int) async -> [Expense] { [] } + func add(_ expense: Expense) async { addCallCount += 1; addedExpense = expense } + func delete(_ expense: Expense) async { deleteCallCount += 1; deletedExpense = expense } + func deleteAll() async {} +} + +final class StubCategoryUseCaseForNewExpense: CategoryUseCaseProtocol { + var stubbedCategories: [Category] = [] + var shouldThrow = false + + func fetchCategories() async throws -> [Category] { + if shouldThrow { throw StubError.generic } + return stubbedCategories + } + + func addCategory(name: String, emoji: String) async throws {} + func updateCategory(_ category: Category, name: String, emoji: String) async throws {} + func deleteCategory(_ category: Category) async throws {} + func resetToDefault() async throws {} + func reorderCategories(_ categories: [Category]) async throws {} +} + +@Suite("NewExpenseViewModel") +@MainActor +struct NewExpenseViewModelTests { + + @Test("추가 모드에서 저장 시 add만 호출된다") + func saveInAddMode() async { + let spy = SpyExpenseUseCase() + let sut = NewExpenseViewModel( + expenseUseCase: spy, + categoryUseCase: StubCategoryUseCaseForNewExpense(), + date: Date() + ) + sut.didSelectCategory(Category(name: "식비", emoji: "🍚")) + + await sut.didSaveExpense() + + #expect(spy.addCallCount == 1) + #expect(spy.deleteCallCount == 0) + } + + @Test("수정 모드에서 저장 시 delete 후 add가 호출된다") + func saveInEditMode() async { + let spy = SpyExpenseUseCase() + let food = Category(name: "식비", emoji: "🍚") + let editing = Expense(date: Date(), category: food, memo: nil, amount: 5000) + let sut = NewExpenseViewModel( + expenseUseCase: spy, + categoryUseCase: StubCategoryUseCaseForNewExpense(), + date: Date(), + editingExpense: editing + ) + sut.didSelectCategory(Category(name: "카페", emoji: "☕️")) + + await sut.didSaveExpense() + + #expect(spy.deleteCallCount == 1) + #expect(spy.deletedExpense?.id == editing.id) + #expect(spy.addCallCount == 1) + } + + @Test("수정 모드에서 initialAmount와 initialMemo가 기존 값으로 반환된다") + func initialValuesInEditMode() { + let food = Category(name: "식비", emoji: "🍚") + let editing = Expense(date: Date(), category: food, memo: "점심", amount: 12000) + let sut = NewExpenseViewModel( + expenseUseCase: SpyExpenseUseCase(), + categoryUseCase: StubCategoryUseCaseForNewExpense(), + date: Date(), + editingExpense: editing + ) + + #expect(sut.initialAmount == 12000) + #expect(sut.initialMemo == "점심") + } + + @Test("fetchCategories 실패 시 fetchError가 설정된다") + func loadCategoriesSetsErrorOnFailure() async { + let stub = StubCategoryUseCaseForNewExpense() + stub.shouldThrow = true + let sut = NewExpenseViewModel( + expenseUseCase: SpyExpenseUseCase(), + categoryUseCase: stub, + date: Date() + ) + + await sut.loadCategories() + + #expect(sut.fetchError != nil) + } +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift new file mode 100644 index 0000000..2d6126d --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/PredictionViewModelTests.swift @@ -0,0 +1,160 @@ +// +// PredictionViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/14/26. +// + +import Testing +import Foundation + +@Suite("PredictionViewModel") +@MainActor +struct PredictionViewModelTests { + + // MARK: - onAppear / load + + @Test("onAppear 호출 후 predictionData가 채워진다") + func onAppearFillsPredictionData() async { + let expenseStub = StubExpenseUseCaseForPrediction() + let predictionStub = StubPredictionUseCase() + predictionStub.stubbedCurrentModel = PredictionModelMetadata( + id: "SPa1b2c3", dataCount: 10, createdAt: Date(), + dailyPredictions: [1: 10000, 2: 20000], categoryPredictions: [:] + ) + let sut = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: predictionStub) + + await sut.onAppear() + + #expect(sut.predictionData.isEmpty == false) + } + + @Test("onAppear 호출 후 categoryData가 채워진다") + func onAppearFillsCategoryData() async { + let expenseStub = StubExpenseUseCaseForPrediction() + let food = Category(name: "식비", emoji: "🍚") + expenseStub.stubbedExpenses = [ + Expense(date: Date(), category: food, memo: nil, amount: 5000) + ] + let predictionStub = StubPredictionUseCase() + let sut = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: predictionStub) + + await sut.onAppear() + + #expect(sut.categoryData.isEmpty == false) + #expect(sut.categoryData.first?.categoryName == "식비") + } + + // MARK: - hasPrediction + + @Test("예측 데이터가 없으면 hasPrediction이 false다") + func hasPredictionIsFalseWhenNoPredictions() async { + let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: StubPredictionUseCase()) + + await sut.onAppear() + + #expect(sut.hasPrediction == false) + } + + @Test("예측 데이터가 있으면 hasPrediction이 true다") + func hasPredictionIsTrueWhenPredictionsExist() async { + let predictionStub = StubPredictionUseCase() + predictionStub.stubbedCurrentModel = PredictionModelMetadata(id: "SPa1b2c3", dataCount: 10, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: predictionStub) + + await sut.onAppear() + + #expect(sut.hasPrediction == true) + } + + // MARK: - makeCumulativePrediction + + @Test("오늘 날짜에 소비가 없어도 actual이 nil이 아니다") + func todayActualIsNotNilEvenWithNoExpenses() async { + let sut = PredictionViewModel(expenseUseCase: StubExpenseUseCaseForPrediction(), predictionUseCase: StubPredictionUseCase()) + + await sut.onAppear() + + let todayPoint = sut.predictionData.first(where: { $0.day == sut.today }) + #expect(todayPoint?.actual != nil) + } + + @Test("누적 actual이 올바르게 계산된다") + func cumulativeActualIsCorrect() async { + let expenseStub = StubExpenseUseCaseForPrediction() + 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 = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: StubPredictionUseCase()) + + 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 = StubExpenseUseCaseForPrediction() + 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 = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: StubPredictionUseCase()) + + await sut.onAppear() + + #expect(sut.categoryData.first?.categoryName == "식비") + #expect(sut.categoryData.last?.categoryName == "교통") + } + + @Test("실제 지출이 있는 카테고리는 과거 예측 데이터가 없어도 0원으로 예측된다") + func categoryWithActualButNoPredictionShowsZeroPredicted() async { + let expenseStub = StubExpenseUseCaseForPrediction() + let event = Category(name: "경조사", emoji: "🎉") + let day1 = Calendar.current.date(from: DateComponents(year: 2026, month: 7, day: 1))! + expenseStub.stubbedExpenses = [ + Expense(date: day1, category: event, memo: nil, amount: 3) + ] + let predictionStub = StubPredictionUseCase() + predictionStub.stubbedCurrentModel = PredictionModelMetadata( + id: "SPa1b2c3", dataCount: 10, createdAt: Date(), + dailyPredictions: [:], categoryPredictions: [:] + ) + let sut = PredictionViewModel(expenseUseCase: expenseStub, predictionUseCase: predictionStub) + + await sut.onAppear() + + let eventPoint = sut.categoryData.first(where: { $0.categoryName == "경조사" }) + #expect(eventPoint?.predicted == 0) + } +} + +// MARK: - Stubs + +final class StubExpenseUseCaseForPrediction: ExpenseUseCaseProtocol { + var stubbedExpenses: [Expense] = [] + func fetch(year: Int, month: Int) async -> [Expense] { stubbedExpenses } + func add(_ expense: Expense) async {} + func delete(_ expense: Expense) async {} + func deleteAll() async {} +} + +final class StubPredictionUseCase: PredictionUseCaseProtocol { + var stubbedCurrentModel: PredictionModelMetadata? = nil + var stubbedRecalculateResult = PredictionModelMetadata(id: "SP000000", dataCount: 0, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + + func fetchCurrentModel() async -> PredictionModelMetadata? { stubbedCurrentModel } + func recalculate() async -> PredictionModelMetadata { stubbedRecalculateResult } + func deleteModel() async {} +} diff --git a/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift b/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift new file mode 100644 index 0000000..ddcd243 --- /dev/null +++ b/SpendLearning/SpendLearningTests/ViewModels/SettingsViewModelTests.swift @@ -0,0 +1,203 @@ +// +// SettingsViewModelTests.swift +// SpendLearning +// +// Created by 김성훈 on 7/2/26. +// + +import Testing +import Foundation + +@Suite("SettingsViewModel") +@MainActor +struct SettingsViewModelTests { + + @Test("loadCategories 호출 후 categories가 채워진다") + func loadCategoriesFillsCategories() async { + let stub = StubCategoryUseCase() + let category = Category(name: "식비", emoji: "🍚") + stub.stubbedCategories = [category] + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + + await sut.loadCategories() + + #expect(sut.categories.count == 1) + #expect(sut.categories.first?.id == category.id) + } + + @Test("addCategory 호출 후 categories가 갱신된다") + func addCategoryRefreshesCategories() async { + let stub = StubCategoryUseCase() + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + stub.stubbedCategories = [Category(name: "카페", emoji: "☕️")] + + await sut.addCategory(name: "카페", emoji: "☕️") + + #expect(sut.categories.count == 1) + #expect(sut.categories.first?.name == "카페") + } + + @Test("updateCategory 호출 후 categories가 갱신된다") + func updateCategoryRefreshesCategories() async { + let stub = StubCategoryUseCase() + let original = Category(name: "식비", emoji: "🍚") + stub.stubbedCategories = [Category(name: "외식", emoji: "🍖")] + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + + await sut.updateCategory(original, name: "외식", emoji: "🍖") + + #expect(sut.categories.first?.name == "외식") + } + + @Test("deleteCategory(at:) 호출 후 categories가 갱신된다") + func deleteCategoryRefreshesCategories() async { + let stub = StubCategoryUseCase() + let category = Category(name: "쇼핑", emoji: "🛍️") + stub.stubbedCategories = [category] + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + await sut.loadCategories() + + stub.stubbedCategories = [] + await sut.deleteCategory(at: 0) + + #expect(sut.categories.isEmpty) + } + + @Test("resetCategories 호출 후 categories가 갱신된다") + func resetCategoriesRefreshesCategories() async { + let stub = StubCategoryUseCase() + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + stub.stubbedCategories = [ + Category(name: "식비", emoji: "🍚"), + Category(name: "교통", emoji: "🚌"), + ] + + await sut.resetCategories() + + #expect(sut.categories.count == 2) + } + + @Test("reorderCategories 호출 후 categories가 갱신된 순서로 반영된다") + func reorderCategoriesRefreshesCategories() async { + let stub = StubCategoryUseCase() + let first = Category(name: "교통", emoji: "🚌") + let second = Category(name: "식비", emoji: "🍚") + stub.stubbedCategories = [first, second] + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + + await sut.reorderCategories([first, second]) + + #expect(sut.categories.first?.id == first.id) + #expect(sut.categories.last?.id == second.id) + } + + @Test("fetchCategories 실패 시 fetchError가 설정된다") + func loadCategoriesSetsErrorOnFailure() async { + let stub = StubCategoryUseCase() + stub.shouldThrow = true + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + + await sut.loadCategories() + + #expect(sut.fetchError != nil) + } + + @Test("save 실패 시 saveError가 설정되고 categories가 원복된다") + func addCategorySetsErrorAndRestoresOnFailure() async { + let stub = StubCategoryUseCase() + let existing = Category(name: "식비", emoji: "🍚") + stub.stubbedCategories = [existing] + let sut = SettingsViewModel( + categoryUseCase: stub, + expenseUseCase: StubExpenseUseCaseForSettings(), + predictionUseCase: StubPredictionUseCaseForSettings() + ) + await sut.loadCategories() + + stub.shouldThrow = true + await sut.addCategory(name: "카페", emoji: "☕️") + + #expect(sut.saveError != nil) + #expect(sut.categories.count == 1) + #expect(sut.categories.first?.id == existing.id) + } +} + +// MARK: - Stub + +final class StubCategoryUseCase: CategoryUseCaseProtocol { + var stubbedCategories: [Category] = [] + var shouldThrow = false + + func fetchCategories() async throws -> [Category] { + if shouldThrow { throw StubError.generic } + return stubbedCategories + } + + func addCategory(name: String, emoji: String) async throws { + if shouldThrow { throw StubError.generic } + } + + func updateCategory(_ category: Category, name: String, emoji: String) async throws { + if shouldThrow { throw StubError.generic } + } + + func deleteCategory(_ category: Category) async throws { + if shouldThrow { throw StubError.generic } + } + + func resetToDefault() async throws { + if shouldThrow { throw StubError.generic } + } + + func reorderCategories(_ categories: [Category]) async throws { + if shouldThrow { throw StubError.generic } + } +} + +enum StubError: Error { + case generic +} + +final class StubExpenseUseCaseForSettings: ExpenseUseCaseProtocol { + func fetch(year: Int, month: Int) async -> [Expense] { [] } + func add(_ expense: Expense) async {} + func delete(_ expense: Expense) async {} + func deleteAll() async {} +} + +final class StubPredictionUseCaseForSettings: PredictionUseCaseProtocol { + func fetchCurrentModel() async -> PredictionModelMetadata? { nil } + func recalculate() async -> PredictionModelMetadata { + PredictionModelMetadata(id: "SP000000", dataCount: 0, createdAt: Date(), dailyPredictions: [:], categoryPredictions: [:]) + } + func deleteModel() async {} +}