diff --git a/.github/workflows/deploy-testflight.yml b/.github/workflows/deploy-testflight.yml index e09687ef..3661cb46 100644 --- a/.github/workflows/deploy-testflight.yml +++ b/.github/workflows/deploy-testflight.yml @@ -113,7 +113,9 @@ jobs: RELEASE_XCCONFIG: ${{ secrets.RELEASE_XCCONFIG }} run: | mkdir -p Configs - echo "$RELEASE_XCCONFIG" | grep -v -E 'CURRENT_PROJECT_VERSION|MARKETING_VERSION' > Configs/release.xcconfig + echo "$RELEASE_XCCONFIG" | grep -v -E 'CURRENT_PROJECT_VERSION|MARKETING_VERSION|PROVISIONING_PROFILE_SPECIFIER|CODE_SIGN_IDENTITY' > Configs/release.xcconfig + echo "PROVISIONING_PROFILE_SPECIFIER = FoodDiary-Distribution" >> Configs/release.xcconfig + echo "CODE_SIGN_IDENTITY = iPhone Distribution" >> Configs/release.xcconfig echo "// Debug Configuration (CI)" > Configs/debug.xcconfig - name: Create GoogleService-Info.plist @@ -151,10 +153,17 @@ jobs: - name: Archive run: | set -o pipefail + BUILD_NUMBER=$(date -u +'%y%m%d')${{ github.run_number }} + MARKETING_VERSION=$(grep 'MARKETING_VERSION' FoodDiary/App/Project.swift | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + if [ -z "$MARKETING_VERSION" ]; then + echo "::error::MARKETING_VERSION 추출 실패" + exit 1 + fi + ARCHIVE_PATH=$RUNNER_TEMP/App.xcarchive - + xcodebuild archive \ -workspace Workspace.xcworkspace \ -scheme release \ @@ -169,8 +178,11 @@ jobs: OTHER_CODE_SIGN_FLAGS="--keychain ${{ env.KEYCHAIN_PATH }}" \ 2>&1 | tee "$RUNNER_TEMP/archive.log" | xcpretty --color - # 아카이브에서 버전 추출 VERSION=$(/usr/libexec/PlistBuddy -c "Print :ApplicationProperties:CFBundleShortVersionString" "$ARCHIVE_PATH/Info.plist") + if [ -z "$VERSION" ]; then + echo "::error::VERSION 추출 실패" + exit 1 + fi echo "ARCHIVE_PATH=$ARCHIVE_PATH" >> $GITHUB_ENV echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV diff --git a/.gitignore b/.gitignore index a4b940de..3d0546db 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ buildServer.json .cursor/rules/ .claude/ .codex/ +.mcp.json diff --git a/Assets/appicon.png b/Assets/appicon.png new file mode 100644 index 00000000..be18e1fc Binary files /dev/null and b/Assets/appicon.png differ diff --git a/Assets/logo.png b/Assets/logo.png new file mode 100644 index 00000000..2cb6c3cc Binary files /dev/null and b/Assets/logo.png differ diff --git a/FoodDiary/App/Project.swift b/FoodDiary/App/Project.swift index a34be8ce..1ebcd16e 100644 --- a/FoodDiary/App/Project.swift +++ b/FoodDiary/App/Project.swift @@ -17,11 +17,32 @@ let project = Project( destinations: [.iPhone], product: .app, bundleId: "com.fooddiary.ios.app", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), infoPlist: .sceneDelegateApp(), sources: ["Sources/**"], resources: ["Resources/**"], entitlements: "App.entitlements", + scripts: [ + .post( + script: """ + if [[ "$(uname -m)" == arm64 ]]; then + export PATH="/opt/homebrew/bin:$PATH" + fi + if which sentry-cli >/dev/null; then + export SENTRY_ORG=mumuk-cs + export SENTRY_PROJECT=mumuk-ios + export SENTRY_AUTH_TOKEN="${SENTRY_AUTH_TOKEN}" + ERROR=$(sentry-cli debug-files upload "$DWARF_DSYM_FOLDER_PATH" 2>&1 >/dev/null) + if [ ! $? -eq 0 ]; then + echo "warning: sentry-cli - $ERROR" + fi + else + echo "warning: sentry-cli not installed, download from https://github.com/getsentry/sentry-cli/releases" + fi + """, + name: "Upload dSYM to Sentry" + ) + ], dependencies: [ .project(target: "Data", path: "../Data"), .project(target: "DesignSystem", path: "../DesignSystem"), @@ -30,11 +51,13 @@ let project = Project( .project(target: "Presentation", path: "../Presentation"), .external(name: "FirebaseCore"), .external(name: "FirebaseMessaging"), + .external(name: "Sentry"), ], settings: .settings( base: [ - "MARKETING_VERSION": "1.0.0", + "MARKETING_VERSION": "1.1.1", "BASE_URL": "$(BASE_URL)", + "SENTRY_DSN": "$(SENTRY_DSN)", "TARGETED_DEVICE_FAMILY": "1" ], configurations: [] diff --git a/FoodDiary/App/Sources/AppCoordinator.swift b/FoodDiary/App/Sources/AppCoordinator.swift new file mode 100644 index 00000000..2c4d2055 --- /dev/null +++ b/FoodDiary/App/Sources/AppCoordinator.swift @@ -0,0 +1,97 @@ +// +// AppCoordinator.swift +// App +// +// Created by 강대훈 on 4/8/26. +// + +import Domain +import Presentation +import UIKit + +final class AppCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + + weak var sceneTransitioner: SceneTransitioning? + private let factories: Factories + + init(factories: Factories) { + self.factories = factories + } +} + +// MARK: - Screen Routing + +extension AppCoordinator { + func pushLoginVC() { + childCoordinators.removeAll() + + let loginCoordinator = LoginCoordinator(factories: factories) + loginCoordinator.sceneTransitioner = sceneTransitioner + loginCoordinator.parentCoordinator = self + addChild(loginCoordinator) + + loginCoordinator.start() + } + + func pushMainVC() { + childCoordinators.removeAll() + + let mainCoordinator = MainCoordinator(factories: factories) + mainCoordinator.sceneTransitioner = sceneTransitioner + mainCoordinator.parentCoordinator = self + addChild(mainCoordinator) + + mainCoordinator.start() + } + + func navigateToDetail(diaryDateString: String) { + performDeepLinkNavigation(diaryDateString: diaryDateString) + } +} + +// MARK: - Permission + +extension AppCoordinator { + func presentPermissionVC(from presenter: UIViewController) { + let coord = PermissionCoordinator(factories: factories, presentingViewController: presenter) + coord.parentCoordinator = self + addChild(coord) + coord.start() + } +} + +// MARK: - Deep Link Navigation + +extension AppCoordinator { + private static let deepLinkDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() + + private func performDeepLinkNavigation(diaryDateString: String) { + guard let date = Self.deepLinkDateFormatter.date(from: diaryDateString) else { + print("[DeepLink] 날짜 파싱 실패: \(diaryDateString)") + return + } + + let navController = childCoordinators + .compactMap({ $0 as? MainCoordinator }) + .last?.navigationController + + let input = DetailSceneInput(date: date, records: []) + let coord = DetailCoordinator(factories: factories, navigationController: navController) + coord.parentCoordinator = self + addChild(coord) + + if let presented = navController?.presentedViewController { + presented.dismiss(animated: false) { + coord.start(input: input) + } + } else { + coord.start(input: input) + } + } +} diff --git a/FoodDiary/App/Sources/AppDelegate.swift b/FoodDiary/App/Sources/AppDelegate.swift index 1d373598..79a9e7f4 100644 --- a/FoodDiary/App/Sources/AppDelegate.swift +++ b/FoodDiary/App/Sources/AppDelegate.swift @@ -9,6 +9,7 @@ import UIKit import UserNotifications import FirebaseCore import FirebaseMessaging +import Sentry import Domain import Data import DI @@ -18,6 +19,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { logBuildConfiguration() + configureSentry() FirebaseApp.configure() Messaging.messaging().delegate = self setupAppearance() @@ -25,6 +27,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } + private func configureSentry() { + guard let dsn = Bundle.main.infoDictionary?["SENTRY_DSN"] as? String, !dsn.isEmpty else { return } + SentrySDK.start { options in + options.dsn = dsn + options.debug = false + options.tracesSampleRate = 0.2 + } + } + private func logBuildConfiguration() { #if DEBUG print("[App] 빌드 설정: DEBUG") @@ -130,8 +141,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { let userInfo = response.notification.request.content.userInfo - if userInfo["is_local_notification"] as? Bool == true { - // 로컬 알림 탭 → 딥링크로 상세 화면 이동 + if userInfo["notification_type"] as? String == "daily_reminder" { + // 매일 리마인더 탭 → 앱이 열리는 기본 동작만 수행 + } else if userInfo["is_local_notification"] as? Bool == true { + // 분석 완료 로컬 알림 탭 → 딥링크로 상세 화면 이동 if let diaryDate = userInfo["diary_date"] as? String { NotificationCenter.default.post( name: AppNotification.Push.deepLinkToDetail, @@ -153,8 +166,11 @@ extension AppDelegate: UNUserNotificationCenterDelegate { ) { let userInfo = notification.request.content.userInfo - if userInfo["is_local_notification"] as? Bool == true { - // 로컬 알림이 포그라운드에서 도착 → 배너 표시 안 함 (토스트로 이미 처리됨) + if userInfo["notification_type"] as? String == "daily_reminder" { + // 매일 리마인더가 포그라운드에 도착 → 배너 표시 안 함 (이미 앱 사용 중) + completionHandler([]) + } else if userInfo["is_local_notification"] as? Bool == true { + // 분석 완료 로컬 알림이 포그라운드에서 도착 → 배너 표시 안 함 (토스트로 이미 처리됨) completionHandler([]) } else { handlePushNotification(userInfo) diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index d6b77a95..f0f24c65 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -14,20 +14,28 @@ import SnapKit import UIKit import UserNotifications -final class AppFlowController: UIViewController { - private typealias AssetFetcher = FoodImageAssetFetcher - private typealias FetchUseCase = FetchFoodImageAssetUseCase - private typealias AuthUseCase = RequestPhotoAuthorizationUseCase - - private var currentChild: UIViewController? +final class AppFlowController: UIViewController, SceneTransitioning { private var networkCancellable: AnyCancellable? private var cancellables = Set() private let container: DIContainer - private var pendingDeepLinkDate: String? + private let loginSession: LoginSession private var splashView: SplashView? - - public init(container: DIContainer) { + private let coordinator: AppCoordinator + private let transitionHandler: ViewTransitionHandling + private let photoAuthFetcher: PhotoAuthorizationFetcher + + public init( + appCoordinator: AppCoordinator, + loginSession: LoginSession, + container: DIContainer, + transitionHandler: ViewTransitionHandling, + photoAuthFetcher: PhotoAuthorizationFetcher + ) { + self.coordinator = appCoordinator + self.loginSession = loginSession self.container = container + self.transitionHandler = transitionHandler + self.photoAuthFetcher = photoAuthFetcher super.init(nibName: nil, bundle: nil) } @@ -42,6 +50,33 @@ final class AppFlowController: UIViewController { setupNetworkMonitoring() setupAnalysisCompletionToast() setupDeepLinkHandling() + setupLoginSessionObservation() + setupForegroundReminderScheduling() + } + + private func setupForegroundReminderScheduling() { + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.tryScheduleDailyReminder() + } + .store(in: &cancellables) + } + + private func setupLoginSessionObservation() { + loginSession.loginResultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] loginResult in + self?.handleLoginResult(loginResult) + } + .store(in: &cancellables) + + loginSession.logoutPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.coordinator.pushLoginVC() + } + .store(in: &cancellables) } private func setupSplashView() { @@ -69,6 +104,49 @@ final class AppFlowController: UIViewController { } .store(in: &cancellables) } + + fileprivate func handleLoginResult(_ loginResult: LoginResult) { + Task { [weak self] in + guard let self else { return } + try? await fetchUserProfile() + if !loginResult.isFirst { + registerForRemoteNotificationsAfterLogin() + } + await MainActor.run { [weak self] in + guard let self else { return } + loginResult.isFirst ? showOnboarding() : pushMain() + } + } + } + + private func showOnboarding() { + let onboardingVC = OnboardingViewController() + var cancellable: AnyCancellable? + cancellable = onboardingVC.didCompletePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + registerForRemoteNotificationsAfterLogin() + pushMain() + _ = cancellable + } + transition(to: UINavigationController(rootViewController: onboardingVC)) + } + + private func pushMain() { + coordinator.pushMainVC() + checkPhotoPermission() + } + + private func checkPhotoPermission() { + let status = photoAuthFetcher.authorizationStatus() + guard status == .denied || status == .restricted else { return } + coordinator.presentPermissionVC(from: self) + } + + private func navigateToDetailFromDeepLink(diaryDateString: String) { + coordinator.navigateToDetail(diaryDateString: diaryDateString) + } } extension AppFlowController { @@ -116,7 +194,7 @@ extension AppFlowController { } private func prefetchFoodImageAssets() { - guard let useCase = try? container.resolve(FetchUseCase.self) else { return } + guard let useCase = try? container.resolve(FetchFoodImageAssetUseCase.self) else { return } useCase.prefetch(forPreviousWeeks: 2, of: Date()) } @@ -127,218 +205,13 @@ extension AppFlowController { } fileprivate func routeToAppropriateScreen(isLogin: Bool) { - let destinationVC = isLogin ? createMainView() : createLoginView() - transition(to: destinationVC) - removeSplashView() if isLogin { + pushMain() registerForRemoteNotificationsAfterLogin() + } else { + coordinator.pushLoginVC() } - - if isLogin, let deepLink = pendingDeepLinkDate { - pendingDeepLinkDate = nil - Task { [weak self] in - try? await Task.sleep(for: .seconds(0.5)) - self?.performDeepLinkNavigation(diaryDateString: deepLink) - } - } - } - - fileprivate func validateToken() async -> Bool { - guard - let validateAccessTokenUseCase = try? container.resolve( - ValidateAccessTokenUseCase< - TokenRepositoryImpl, InitialLaunchStorage> - >.self - ) - else { - fatalError("ValidateAccessTokenUseCase Failed Resolve") - } - - return await validateAccessTokenUseCase.execute() - } - - fileprivate func fetchUserProfile() async throws { - guard - let fetchUserProfileUseCase = try? container.resolve( - FetchUserProfileUseCase< - UserRepositoryImpl>, - NicknameStorage - >.self - ) - else { - fatalError("FetchUserProfileUseCase Failed Resolve") - } - - try await fetchUserProfileUseCase.execute() - } - - fileprivate func createMainView() -> UIViewController { - let weeklyCalendarVC = makeWeeklyCalendarVC() - let monthlyCalendarVC = makeMonthlyCalendarVC() - - let myPageVCFactory: (@escaping () -> Void) -> UIViewController = { [container] onLogout in - guard let vm = try? container.resolve(MyPageViewModel.self) else { - fatalError("MyPageViewModel not registered") - } - let vc = MyPageViewController(viewModel: vm) - var cancellable: AnyCancellable? - cancellable = vc.didLogoutPublisher - .sink { - onLogout() - _ = cancellable - } - return vc - } - - let tabBarVC = RootTabBarController( - weeklyVC: weeklyCalendarVC, - monthlyVC: monthlyCalendarVC, - insightVC: InsightViewController(), - myPageViewControllerFactory: myPageVCFactory - ) - - tabBarVC.didLogoutPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] in - guard let self else { return } - transition(to: createLoginView()) - } - .store(in: &cancellables) - - let navController = UINavigationController(rootViewController: tabBarVC) - let navAppearance = UINavigationBarAppearance() - navAppearance.configureWithTransparentBackground() - navAppearance.backgroundColor = .clear - navAppearance.titleTextAttributes = [.foregroundColor: UIColor(white: 1, alpha: 1)] - navAppearance.shadowColor = .clear - - navController.navigationBar.standardAppearance = navAppearance - navController.navigationBar.scrollEdgeAppearance = navAppearance - navController.navigationBar.tintColor = UIColor(white: 1, alpha: 1) - - return navController - } - - fileprivate func makeWeeklyCalendarVC() -> UIViewController { - guard let imageProvider = try? container.resolve(UIImageLoader.self) else { - fatalError("WeeklyCalendarViewController dependencies not registered") - } - - typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - - guard let weeklyViewModel = try? container.resolve(WeeklyVM.self) else { - fatalError("WeeklyCalendarViewModel not registered") - } - - return WeeklyCalendarViewController( - viewModel: weeklyViewModel, - imageProvider: imageProvider, - detailViewControllerFactory: { [weak self] date, records, scrollToMealType, shouldPopToRoot, onDismiss in - self?.makeDetailViewController( - date: date, - records: records, - scrollToMealType: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismiss - ) ?? UIViewController() - } - ) - } - - fileprivate func makeMonthlyCalendarVC() -> UIViewController { - typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > - - guard let monthlyViewModel = try? container.resolve(MonthlyVM.self) else { - fatalError("MonthlyCalendarViewModel not registered") - } - - return MonthlyCalendarViewController( - viewModel: monthlyViewModel, - detailViewControllerFactory: { [weak self] date, records, scrollToMealType, shouldPopToRoot, onDismiss in - self?.makeDetailViewController( - date: date, - records: records, - scrollToMealType: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismiss - ) ?? UIViewController() - } - ) - } - - fileprivate func createOnboardingView() -> UIViewController { - let onboardingVC = OnboardingViewController() - - onboardingVC.didCompletePublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - registerForRemoteNotificationsAfterLogin() - let mainVC = createMainView() - transition(to: mainVC) - showCoachmarkOverlay(on: mainVC) - } - .store(in: &cancellables) - - return UINavigationController(rootViewController: onboardingVC) - } - - fileprivate func showCoachmarkOverlay(on viewController: UIViewController) { - let overlay = CoachmarkOverlayView() - viewController.view.addSubview(overlay) - overlay.snp.makeConstraints { $0.edges.equalToSuperview() } - overlay.alpha = 0 - UIView.animate(withDuration: 0.3) { overlay.alpha = 1 } - - overlay.didDismissPublisher - .receive(on: DispatchQueue.main) - .sink { _ in - UIView.animate( - withDuration: 0.3, - animations: { - overlay.alpha = 0 - }, - completion: { _ in - overlay.removeFromSuperview() - }) - } - .store(in: &cancellables) - } - - fileprivate func createLoginView() -> UIViewController { - guard let viewModel = try? container.resolve(LoginViewModel.self) else { - fatalError("LoginViewModel Failed Resolve") - } - - let loginVC = LoginViewController(viewModel: viewModel) - - loginVC.didLoginPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] loginResult in - guard let self else { return } - handleLoginResult(loginResult) - } - .store(in: &cancellables) - - return loginVC - } - - fileprivate func handleLoginResult(_ loginResult: LoginResult) { - Task { - try? await fetchUserProfile() - if !loginResult.isFirst { - registerForRemoteNotificationsAfterLogin() - } - transition(to: loginResult.isFirst ? createOnboardingView() : createMainView()) - } + removeSplashView() } fileprivate func registerForRemoteNotificationsAfterLogin() { @@ -347,6 +220,7 @@ extension AppFlowController { switch settings.authorizationStatus { case .authorized, .provisional, .ephemeral: UIApplication.shared.registerForRemoteNotifications() + tryScheduleDailyReminder() case .notDetermined: await requestSystemNotificationAuthorization() case .denied: @@ -358,291 +232,39 @@ extension AppFlowController { } @MainActor - fileprivate func requestSystemNotificationAuthorization() async { + private func requestSystemNotificationAuthorization() async { do { let granted = try await UNUserNotificationCenter.current().requestAuthorization( options: [.alert, .badge, .sound] ) guard granted else { return } UIApplication.shared.registerForRemoteNotifications() + tryScheduleDailyReminder() } catch { print("알림 권한 요청 실패: \(error)") } } - fileprivate func transition(to viewController: UIViewController) { - let previousChild = currentChild - - addChild(viewController) - view.addSubview(viewController.view) - viewController.view.snp.makeConstraints { - $0.edges.equalToSuperview() - } - viewController.view.alpha = 0 - - UIView.animate( - withDuration: 0.3, - animations: { - previousChild?.view.alpha = 0 - viewController.view.alpha = 1 - }, - completion: { _ in - previousChild?.willMove(toParent: nil) - previousChild?.view.removeFromSuperview() - previousChild?.removeFromParent() - - viewController.didMove(toParent: self) - self.currentChild = viewController - } - ) - } -} - -// MARK: - Deep Link Navigation - -extension AppFlowController { - private static let deepLinkDateFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.locale = Locale(identifier: "en_US_POSIX") - return formatter - }() - - fileprivate func navigateToDetailFromDeepLink(diaryDateString: String) { - guard findNavigationController() != nil else { - pendingDeepLinkDate = diaryDateString - return - } - performDeepLinkNavigation(diaryDateString: diaryDateString) + fileprivate func tryScheduleDailyReminder() { + guard let useCase = try? container.resolve(ScheduleDailyReminderUseCase.self) else { return } + Task { await useCase.execute() } } - fileprivate func performDeepLinkNavigation(diaryDateString: String) { - guard let date = Self.deepLinkDateFormatter.date(from: diaryDateString) else { - print("[DeepLink] 날짜 파싱 실패: \(diaryDateString)") - return - } - - guard let navController = findNavigationController() else { - print("[DeepLink] NavigationController를 찾을 수 없음") - return - } - - let detailVC = makeDetailViewController( - date: date, - records: [], - onDismissWithDate: nil - ) - - if let presented = navController.presentedViewController { - presented.dismiss(animated: false) { - navController.pushViewController(detailVC, animated: true) - } - } else { - navController.pushViewController(detailVC, animated: true) - } - } - - private func findNavigationController() -> UINavigationController? { - guard let child = currentChild else { return nil } - - if let nav = child as? UINavigationController { - return nav - } - - for child in child.children { - if let nav = child as? UINavigationController { - return nav - } - } - - return nil - } -} - -// MARK: - Detail & Edit Factory - -extension AppFlowController { - private typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - private typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - private typealias AddressSearchVM = AddressSearchViewModel - - fileprivate func makeDetailViewController( - date: Date, - records: [FoodRecord], - scrollToMealType: MealType? = nil, - shouldPopToRoot: Bool = false, - onDismissWithDate: ((Date) -> Void)? = nil - ) -> UIViewController { - guard - let detailVM = try? container.resolve(DetailVM.self, argument: (date, records)) - else { - fatalError("DetailViewController dependencies not registered") - } - - return DetailViewController( - viewModel: detailVM, - initialScrollTarget: scrollToMealType, - shouldPopToRoot: shouldPopToRoot, - onDismissWithDate: onDismissWithDate, - editViewControllerFactory: { [weak self] record in - self?.makeEditViewController(for: record) ?? UIViewController() - }, - presentImagePickerHandler: makeDetailImagePickerHandler() - ) - } - - fileprivate func makeEditViewController(for record: FoodRecord) -> UIViewController { - guard let editVM = try? container.resolve(EditVM.self, argument: record) else { - fatalError("EditFoodRecordViewModel not registered") - } - - let addressSearchVCFactory: - (Int, @escaping (AddressSearchResult) -> Void) -> UIViewController = { - [container] diaryId, onSelect in - guard - let addressVM = try? container.resolve(AddressSearchVM.self, argument: diaryId) - else { - fatalError("AddressSearchViewModel not registered") - } - return AddressSearchViewController( - viewModel: addressVM, onAddressSelected: onSelect) - } - - return EditFoodRecordViewController( - viewModel: editVM, - addressSearchViewControllerFactory: addressSearchVCFactory - ) - } -} - -// MARK: - Image Picker - -extension AppFlowController { - fileprivate func makeDetailImagePickerHandler() - -> (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void - { - return { [weak self] nav, date, onSelected in - guard let self else { return } - self.presentImagePicker( - from: nav, - date: date, - configuration: .withMaxSelectionCount(10), - autoPreselectByProbability: true, - loadPreviewImages: false, - onSelected: { assets, _ in - onSelected(assets) - } - ) + fileprivate func validateToken() async -> Bool { + guard let validateAccessTokenUseCase = try? container.resolve(ValidateAccessTokenUseCase.self) else { + fatalError("ValidateAccessTokenUseCase Failed Resolve") } + return await validateAccessTokenUseCase.execute() } - fileprivate func presentImagePicker( - from nav: UINavigationController, - date: Date, - configuration: ImagePickerConfiguration, - autoPreselectByProbability: Bool, - loadPreviewImages: Bool, - onSelected: @escaping ([any ImageAssetable], [UIImage]) -> Void - ) { - guard let fetchUseCase = try? container.resolve(FetchUseCase.self), - let imageProvider = try? container.resolve(UIImageLoader.self), - let authUseCase = try? container.resolve(AuthUseCase.self) - else { - fatalError("ImagePicker dependencies not registered") - } - - Task { @MainActor in - if !authUseCase.isAuthorized() { - let status = await authUseCase.execute() - if status == .denied || status == .restricted { - Self.presentPhotoAccessDeniedAlert(on: nav) - return - } - } - - do { - let calendar = Calendar.current - let startOfDay = calendar.startOfDay(for: date) - let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) - let photosByDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) - let foodImageAssets = photosByDate[startOfDay] ?? [] - let photos = foodImageAssets.map { $0.imageAsset } - - let preselectedFoodPhotoIds: Set = - autoPreselectByProbability - ? Set(foodImageAssets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) - : [] - - let picker = ImagePickerViewController( - photos: photos, - preselectedFoodPhotoIds: preselectedFoodPhotoIds, - imageProvider: imageProvider, - configuration: configuration - ) - - var cancellable: AnyCancellable? - cancellable = picker.resultPublisher - .sink { [weak nav] result in - defer { cancellable = nil } - switch result { - case .selected(let assets): - nav?.popViewController(animated: true) - if loadPreviewImages { - Task { @MainActor in - var previewImages: [UIImage] = [] - for asset in assets { - if let image = try? await imageProvider.loadImage( - for: asset, - targetSize: CGSize(width: 300, height: 300) - ) { - previewImages.append(image) - } - } - onSelected(assets, previewImages) - } - } else { - onSelected(assets, []) - } - case .cancelled: - break - } - } - - nav.pushViewController(picker, animated: true) - } catch { - Self.presentPhotoLoadFailedAlert(error: error, on: nav) - } + fileprivate func fetchUserProfile() async throws { + guard let fetchUserProfileUseCase = try? container.resolve(FetchUserProfileUseCase.self) else { + fatalError("FetchUserProfileUseCase Failed Resolve") } + try await fetchUserProfileUseCase.execute() } - private static func presentPhotoAccessDeniedAlert(on nav: UINavigationController) { - let alert = UIAlertController( - title: "사진 접근 권한 필요", - message: "음식 사진을 추가하려면 사진 라이브러리 접근 권한이 필요합니다. 설정에서 권한을 허용해 주세요.", - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - alert.addAction( - UIAlertAction(title: "설정으로 이동", style: .default) { _ in - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - }) - nav.topViewController?.present(alert, animated: true) - } - - private static func presentPhotoLoadFailedAlert(error: Error, on nav: UINavigationController) { - let alert = UIAlertController( - title: "사진 불러오기 실패", - message: error.localizedDescription, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "확인", style: .default)) - nav.topViewController?.present(alert, animated: true) + func transition(to viewController: UIViewController) { + transitionHandler.transition(from: self, to: viewController) } } diff --git a/FoodDiary/App/Sources/MainCoordinator.swift b/FoodDiary/App/Sources/MainCoordinator.swift new file mode 100644 index 00000000..887bc36e --- /dev/null +++ b/FoodDiary/App/Sources/MainCoordinator.swift @@ -0,0 +1,76 @@ +// +// MainCoordinator.swift +// App +// +// Created by 강대훈 on 4/10/26. +// + +import Combine +import Presentation +import UIKit + +final class MainCoordinator: Coordinator { + var childCoordinators: [any Coordinator] = [] + weak var parentCoordinator: (any Coordinator)? + weak var sceneTransitioner: SceneTransitioning? + + private let factories: Factories + private var cancellables = Set() + var navigationController: UINavigationController? + + init(factories: Factories) { + self.factories = factories + } + + func start() { + let calendarCoordinator = CalendarCoordinator(factories: factories) + let insightCoordinator = InsightCoordinator(factories: factories) + + calendarCoordinator.parentCoordinator = self + insightCoordinator.parentCoordinator = self + addChild(calendarCoordinator) + addChild(insightCoordinator) + + let calendarVC = calendarCoordinator.makeViewController() + let insightVC = insightCoordinator.makeViewController() + + let tabBarController = RootTabBarController(calendarVC: calendarVC, insightVC: insightVC) + + tabBarController.mypageButtonTapPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.pushMyPageVC() + } + .store(in: &cancellables) + + navigationController = makeNavigationController(root: tabBarController) + calendarCoordinator.configure(navigationController: navigationController) + sceneTransitioner?.transition(to: navigationController!) + } +} + +// MARK: - Screen Routing + +private extension MainCoordinator { + func pushMyPageVC() { + let coord = MyPageCoordinator(factories: factories, navigationController: navigationController) + coord.parentCoordinator = self + addChild(coord) + coord.start() + } +} + +private extension MainCoordinator { + func makeNavigationController(root: UIViewController) -> UINavigationController { + let navController = UINavigationController(rootViewController: root) + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.titleTextAttributes = [.foregroundColor: UIColor(white: 1, alpha: 1)] + appearance.shadowColor = .clear + navController.navigationBar.standardAppearance = appearance + navController.navigationBar.scrollEdgeAppearance = appearance + navController.navigationBar.tintColor = UIColor(white: 1, alpha: 1) + return navController + } +} diff --git a/FoodDiary/App/Sources/SceneDelegate.swift b/FoodDiary/App/Sources/SceneDelegate.swift index d1d1fa2f..0610826b 100644 --- a/FoodDiary/App/Sources/SceneDelegate.swift +++ b/FoodDiary/App/Sources/SceneDelegate.swift @@ -23,12 +23,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) { registerDependencies() - #if DEBUG - // saveDebugImageToPhotoLibrary() - #endif guard let windowScene = scene as? UIWindowScene else { return } window = UIWindow(windowScene: windowScene) - window?.rootViewController = AppFlowController(container: container) + + window?.rootViewController = makeAppFlowController() window?.makeKeyAndVisible() } } @@ -53,6 +51,10 @@ extension SceneDelegate { NotificationAuthorizationProvider() } + container.register(LocalNotificationScheduling.self) { _ in + LocalNotificationScheduler() + } + container.register(KeychainService.self) { _ in KeychainService() } @@ -61,33 +63,27 @@ extension SceneDelegate { HTTPClient() } - container.register(AuthTokenStorage.self) { resolver in + container.register(AuthTokenStorage.self) { resolver in guard let service = resolver.resolve(KeychainService.self) else { fatalError("KeychainService not registered") } - return AuthTokenStorage(keychainService: service) } container.register(AuthRepository.self) { resolver in - guard let storage = resolver.resolve(AuthTokenStorage.self) else { - fatalError("AuthTokenStorage not registered") - } - - guard let client = resolver.resolve(HTTPClient.self) else { - fatalError("HTTPClient not registered") + guard let storage = resolver.resolve(AuthTokenStorage.self), + let client = resolver.resolve(HTTPClient.self) else { + fatalError("AuthRepositoryImpl dependencies not registered") } - return AuthRepositoryImpl(httpClient: client, tokenStorage: storage) } container.register(TokenRepository.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self), + let storage = resolver.resolve(AuthTokenStorage.self), let launchStorage = resolver.resolve(InitialLaunchStorage.self) else { fatalError("TokenRepositoryImpl dependencies not registered") } - return TokenRepositoryImpl(httpClient: client, storage: storage, launchStorage: launchStorage) } @@ -114,12 +110,10 @@ extension SceneDelegate { ClassificationCacheManager() } - container.register(FoodImageAssetFetcher.self) { - resolver in + container.register(FoodImageAssetFetcher.self) { resolver in guard let classifier = resolver.resolve(TFLiteFoodClassifier.self), - let imageRepository = resolver.resolve(UIImageLoader.self), - let cache = resolver.resolve(ClassificationCacheManager.self) - else { + let imageRepository = resolver.resolve(UIImageLoader.self), + let cache = resolver.resolve(ClassificationCacheManager.self) else { fatalError("FoodImageAssetFetcher dependencies not registered") } return FoodImageAssetFetcher( @@ -129,13 +123,10 @@ extension SceneDelegate { ) } - container.register( - FoodRecordRepositoryImpl>.self - ) { resolver in + container.register(FoodRecordRepositoryImpl.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self), - let imageConverter = resolver.resolve(PHAssetConverter.self) - else { + let storage = resolver.resolve(AuthTokenStorage.self), + let imageConverter = resolver.resolve(PHAssetConverter.self) else { fatalError("FoodRecordRepositoryImpl dependencies not registered") } let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? "" @@ -154,8 +145,7 @@ extension SceneDelegate { container.register(AddressSearchRepositoryImpl.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) - else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("AddressSearchRepositoryImpl dependencies not registered") } return AddressSearchRepositoryImpl(httpClient: client, tokenStorage: storage) @@ -163,7 +153,7 @@ extension SceneDelegate { container.register(DeviceRepository.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("DeviceRepository dependencies not registered") } return DeviceRepositoryImpl(httpClient: client, tokenStorage: storage) @@ -171,12 +161,20 @@ extension SceneDelegate { container.register(UserRepository.self) { resolver in guard let client = resolver.resolve(HTTPClient.self), - let storage = resolver.resolve(AuthTokenStorage.self) else { + let storage = resolver.resolve(AuthTokenStorage.self) else { fatalError("UserRepositoryImpl dependencies not registered") } return UserRepositoryImpl(httpClient: client, tokenStorage: storage) } + container.register(InsightRepositoryImpl.self) { resolver in + guard let client = resolver.resolve(HTTPClient.self), + let storage = resolver.resolve(AuthTokenStorage.self) else { + fatalError("InsightRepositoryImpl dependencies not registered") + } + return InsightRepositoryImpl(httpClient: client, tokenStorage: storage) + } + container.register(NicknameStoring.self) { _ in NicknameStorage() } @@ -184,128 +182,86 @@ extension SceneDelegate { container.register(InitialLaunchStorage.self) { _ in InitialLaunchStorage() } + + container.register(CoachmarkStoring.self) { _ in + CoachmarkStorage() + } + + container.register(AnalysisCountStorage.self) { _ in + AnalysisCountStorage() + } } fileprivate func registerDomain() { + container.register(LoginSession.self, scope: .container) { _ in + LoginSession() + } + container.register(FinalizeAppleLoginUseCase.self) { resolver in guard let repository = resolver.resolve(AuthRepository.self), - let pushTokenStorage = resolver.resolve(PushTokenStoring.self), - let notificationAuthProvider = resolver.resolve( - NotificationAuthorizationProviding.self), - let launchStorage = resolver.resolve(InitialLaunchStorage.self), - let deviceId = UIDevice.current.identifierForVendor?.uuidString - else { + let pushTokenStorage = resolver.resolve(PushTokenStoring.self), + let notificationAuthProvider = resolver.resolve(NotificationAuthorizationProviding.self), + let launchStorage = resolver.resolve(InitialLaunchStorage.self), + let loginSession = resolver.resolve(LoginSession.self), + let deviceId = UIDevice.current.identifierForVendor?.uuidString else { fatalError("FinalizeAppleLoginUseCase dependencies not registered") } - return FinalizeAppleLoginUseCase( authRepository: repository, deviceId: deviceId, osVersion: UIDevice.current.systemVersion, pushTokenStorage: pushTokenStorage, notificationAuthorizationProvider: notificationAuthProvider, - initialLaunchStorage: launchStorage + initialLaunchStorage: launchStorage, + loginSession: loginSession ) } - container.register( - ValidateAccessTokenUseCase< - TokenRepositoryImpl, InitialLaunchStorage> - >.self - ) { resolver in + container.register(ValidateAccessTokenUseCase.self) { resolver in guard let repository = resolver.resolve(TokenRepository.self) else { fatalError("TokenRepository not registered") } - - guard - let concreteRepository = repository - as? TokenRepositoryImpl, InitialLaunchStorage> - else { - fatalError("TokenRepository is not of expected type") - } - - return ValidateAccessTokenUseCase(repository: concreteRepository) + return ValidateAccessTokenUseCase(repository: repository) } - container.register( - FetchFoodImageAssetUseCase> - .self - ) { resolver in - guard - let repository = resolver.resolve( - FoodImageAssetFetcher.self) - else { + container.register(FetchFoodImageAssetUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodImageAssetFetcher.self) else { fatalError("FoodImageAssetFetcher not registered") } return FetchFoodImageAssetUseCase(repository: repository) } - container.register( - RequestPhotoAuthorizationUseCase.self - ) { resolver in + container.register(RequestPhotoAuthorizationUseCase.self) { resolver in guard let repository = resolver.resolve(PhotoAuthorizationFetcher.self) else { fatalError("PhotoAuthorizationFetcher not registered") } return RequestPhotoAuthorizationUseCase(repository: repository) } - container.register( - SaveFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let recordRepo = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(SaveFoodRecordUseCase.self) { resolver in + guard let recordRepo = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("SaveFoodRecordUseCase dependencies not registered") } return SaveFoodRecordUseCase(repository: recordRepo) } - container.register( - FetchMonthlyCalendarDaysUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(FetchMonthlyCalendarDaysUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return FetchMonthlyCalendarDaysUseCase(repository: repository) } - container.register( - FetchFoodRecordsUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(FetchFoodRecordsUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return FetchFoodRecordsUseCase(repository: repository) } - container.register( - LoadWeeklyRecordUseCase< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher - >.self - ) { resolver in - guard - let recordRepo = resolver.resolve( - FoodRecordRepositoryImpl>.self), - let fetchAssetUseCase = resolver.resolve( - FetchFoodImageAssetUseCase< - FoodImageAssetFetcher - >.self - ) - else { + container.register(LoadWeeklyRecordUseCase.self) { resolver in + guard let recordRepo = resolver.resolve(FoodRecordRepositoryImpl.self), + let fetchAssetUseCase = resolver.resolve(FetchFoodImageAssetUseCase.self) else { fatalError("LoadWeeklyRecordUseCase dependencies not registered") } return LoadWeeklyRecordUseCase( @@ -315,37 +271,21 @@ extension SceneDelegate { ) } - container.register( - UpdateFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(UpdateFoodRecordUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return UpdateFoodRecordUseCase(repository: repository) } - container.register( - DeleteFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) { resolver in - guard - let repository = resolver.resolve( - FoodRecordRepositoryImpl>.self) - else { + container.register(DeleteFoodRecordUseCase.self) { resolver in + guard let repository = resolver.resolve(FoodRecordRepositoryImpl.self) else { fatalError("FoodRecordRepositoryImpl not registered") } return DeleteFoodRecordUseCase(repository: repository) } - container.register( - SearchAddressUseCase.self - ) { resolver in + container.register(SearchAddressUseCase.self) { resolver in guard let addressRepo = resolver.resolve(AddressSearchRepositoryImpl.self) else { fatalError("AddressSearchRepositoryImpl not registered") } @@ -359,64 +299,60 @@ extension SceneDelegate { return GetNicknameUseCase(nicknameStorage: nicknameStorage) } + container.register(CheckAppReviewEligibilityUseCase.self) { resolver in + guard let storage = resolver.resolve(AnalysisCountStorage.self) else { + fatalError("AnalysisCountStorage not registered") + } + return CheckAppReviewEligibilityUseCase(analysisCountStorage: storage) + } + container.register(GetAppVersionUseCase.self) { _ in let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" return GetAppVersionUseCase(appVersion: version) } container.register(LogoutUseCase.self) { resolver in - guard let authRepository = resolver.resolve(AuthRepository.self) else { - fatalError("AuthRepository not registered") + guard let authRepository = resolver.resolve(AuthRepository.self), + let loginSession = resolver.resolve(LoginSession.self) else { + fatalError("LogoutUseCase dependencies not registered") } - return LogoutUseCase(authRepository: authRepository) + return LogoutUseCase(authRepository: authRepository, loginSession: loginSession) } container.register(WithdrawUserUseCase.self) { resolver in - guard let authRepository = resolver.resolve(AuthRepository.self) else { - fatalError("AuthRepository not registered") + guard let authRepository = resolver.resolve(AuthRepository.self), + let loginSession = resolver.resolve(LoginSession.self) else { + fatalError("WithdrawUserUseCase dependencies not registered") } - return WithdrawUserUseCase(authRepository: authRepository) + return WithdrawUserUseCase(authRepository: authRepository, loginSession: loginSession) } - container.register( - FetchUserProfileUseCase< - UserRepositoryImpl>, - NicknameStorage - >.self - ) { resolver in + container.register(FetchInsightUseCase.self) { resolver in + guard let repository = resolver.resolve(InsightRepositoryImpl.self) else { + fatalError("FetchInsightUseCase dependencies not registered") + } + return FetchInsightUseCase(repository: repository) + } + + container.register(FetchUserProfileUseCase.self) { resolver in guard let repository = resolver.resolve(UserRepository.self), - let concreteRepository = repository - as? UserRepositoryImpl>, - let nicknameStorage = resolver.resolve(NicknameStoring.self), - let concreteNicknameStorage = nicknameStorage as? NicknameStorage - else { - fatalError("FetchUserProfileUseCase dependencies not registered or unexpected type") + let nicknameStorage = resolver.resolve(NicknameStoring.self) else { + fatalError("FetchUserProfileUseCase dependencies not registered") } - return FetchUserProfileUseCase( - repository: concreteRepository, - nicknameStorage: concreteNicknameStorage - ) + return FetchUserProfileUseCase(repository: repository, nicknameStorage: nicknameStorage) } - container.register( - UpdateDeviceNotificationSettingUseCase.self - ) { resolver in + container.register(UpdateDeviceNotificationSettingUseCase.self) { resolver in guard let repository = resolver.resolve(DeviceRepository.self), let pushTokenProvider = resolver.resolve(PushTokenStoring.self), let notificationAuthProvider = resolver.resolve(NotificationAuthorizationProviding.self), let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { fatalError("UpdateDeviceNotificationSettingUseCase dependencies not registered") } - - guard let concreteRepository = repository as? DeviceRepositoryImpl> else { - fatalError("DeviceRepository is not of expected type") - } - let deviceID = UIDevice.current.identifierForVendor?.uuidString let osVersion = UIDevice.current.systemVersion - return UpdateDeviceNotificationSettingUseCase( - repository: concreteRepository, + repository: repository, notificationAuthorizationProvider: notificationAuthProvider, pushTokenProvider: pushTokenProvider, appVersion: appVersion, @@ -424,231 +360,127 @@ extension SceneDelegate { osVersion: osVersion ) } - } - fileprivate func registerPresentation() { - container.register(LoginViewModel.self, scope: .transient) { resolver in - guard let useCase = resolver.resolve(FinalizeAppleLoginUseCase.self) else { - fatalError("FinalizeAppleLoginUseCase not registered") + container.register(ScheduleDailyReminderUseCase.self) { resolver in + guard let scheduler = resolver.resolve(LocalNotificationScheduling.self), + let provider = resolver.resolve(NotificationAuthorizationProviding.self) else { + fatalError("ScheduleDailyReminderUseCase dependencies not registered") } + return ScheduleDailyReminderUseCase(scheduler: scheduler, authorizationProvider: provider) + } + } - return LoginViewModel(finalizeAppleLoginUseCase: useCase) - } - - // WeeklyCalendarViewModel 타입 별칭 - typealias WeeklyVM = WeeklyCalendarViewModel< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher, - PhotoAuthorizationFetcher, - PushNotificationObserver - > - - container.register(WeeklyVM.self, scope: .transient) { resolver in - guard - let requestPhotoAuthUseCase = resolver.resolve( - RequestPhotoAuthorizationUseCase.self - ), - let loadWeeklyUseCase = resolver.resolve( - LoadWeeklyRecordUseCase< - FoodRecordRepositoryImpl>, - FoodImageAssetFetcher - >.self - ), - let saveFoodRecordUseCase = resolver.resolve( - SaveFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let pushObserver = resolver.resolve(PushNotificationObserver.self), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self) - else { - fatalError("WeeklyCalendarViewModel dependencies not registered") - } + fileprivate func registerPresentation() {} - return WeeklyCalendarViewModel( - requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, - loadWeeklyCalendarDataUseCase: loadWeeklyUseCase, - saveFoodRecordUseCase: saveFoodRecordUseCase, - pushNotificationObserver: pushObserver, - getNicknameUseCase: getNicknameUseCase - ) + fileprivate func makeAppFlowController() -> AppFlowController { + guard let finalizeUseCase = try? container.resolve(FinalizeAppleLoginUseCase.self) else { + fatalError("FinalizeAppleLoginUseCase not registered") } - typealias DetailVM = DetailViewModel< - FoodRecordRepositoryImpl>, - PushNotificationObserver - > - - container.register( - DetailVM.self, - argument: (Date, [FoodRecord]).self, - scope: .transient, - factory: { resolver, args in - let (initialDate, initialRecords) = args - guard - let fetchRecordsUseCase = resolver.resolve( - FetchFoodRecordsUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let saveFoodRecordUseCase = resolver.resolve( - SaveFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let deleteFoodRecordUseCase = resolver.resolve( - DeleteFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let pushObserver = resolver.resolve(PushNotificationObserver.self) - else { - fatalError("DetailViewModel dependencies not registered") - } - - return DetailViewModel( - initialDate: initialDate, - initialRecords: initialRecords, - fetchRecordsUseCase: fetchRecordsUseCase, - saveFoodRecordUseCase: saveFoodRecordUseCase, - deleteFoodRecordUseCase: deleteFoodRecordUseCase, - pushNotificationObserver: pushObserver - ) - } - ) + guard let fetchInsightUseCase = try? container.resolve(FetchInsightUseCase.self) else { + fatalError("FetchInsightUseCase not registered") + } - typealias EditVM = EditFoodRecordViewModel< - FoodRecordRepositoryImpl> - > - - container.register( - EditVM.self, - argument: FoodRecord.self, - scope: .transient, - factory: { resolver, record in - guard - let updateUseCase = resolver.resolve( - UpdateFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let deleteUseCase = resolver.resolve( - DeleteFoodRecordUseCase< - FoodRecordRepositoryImpl> - >.self - ) - else { - fatalError("EditFoodRecordViewModel dependencies not registered") - } - - return EditFoodRecordViewModel( - record: record, - updateFoodRecordUseCase: updateUseCase, - deleteFoodRecordUseCase: deleteUseCase - ) - } - ) + guard let updateDeviceUseCase = try? container.resolve(UpdateDeviceNotificationSettingUseCase.self), + let notificationAuthProvider = try? container.resolve(NotificationAuthorizationProviding.self), + let logoutUseCase = try? container.resolve(LogoutUseCase.self), + let withdrawUserUseCase = try? container.resolve(WithdrawUserUseCase.self), + let getNicknameUseCase = try? container.resolve(GetNicknameUseCase.self), + let getAppVersionUseCase = try? container.resolve(GetAppVersionUseCase.self) else { + fatalError("MyPageSceneFactory dependencies not registered") + } - typealias AddressSearchVM = AddressSearchViewModel - - container.register( - AddressSearchVM.self, - argument: Int.self, - scope: .transient, - factory: { resolver, diaryId in - guard - let searchUseCase = resolver.resolve( - SearchAddressUseCase.self - ) - else { - fatalError("SearchAddressUseCase not registered") - } - return AddressSearchViewModel( - searchAddressUseCase: searchUseCase, - diaryId: diaryId - ) - } + guard let fetchImageAssetUseCase = try? container.resolve(FetchFoodImageAssetUseCase.self), + let imageProvider = try? container.resolve(UIImageLoader.self) else { + fatalError("ImagePickerCoordinator dependencies not registered") + } + + let imagePickerFactory = ImagePickerSceneFactory( + imageProvider: imageProvider, + fetchUseCase: fetchImageAssetUseCase ) - // MonthlyCalendarViewModel 타입 별칭 - typealias MonthlyVM = MonthlyCalendarViewModel< - FoodRecordRepositoryImpl>, - PhotoAuthorizationFetcher - > - - container.register(MonthlyVM.self, scope: .transient) { resolver in - guard - let fetchMonthlyUseCase = resolver.resolve( - FetchMonthlyCalendarDaysUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let requestPhotoAuthUseCase = resolver.resolve( - RequestPhotoAuthorizationUseCase.self - ), - let fetchFoodRecordsUseCase = resolver.resolve( - FetchFoodRecordsUseCase< - FoodRecordRepositoryImpl> - >.self - ), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self) - else { - fatalError("MonthlyCalendarViewModel dependencies not registered") - } + guard let fetchRecordsUseCase = try? container.resolve(FetchFoodRecordsUseCase.self), + let saveFoodRecordUseCase = try? container.resolve(SaveFoodRecordUseCase.self), + let deleteFoodRecordUseCase = try? container.resolve(DeleteFoodRecordUseCase.self), + let pushNotificationObserver = try? container.resolve(PushNotificationObserver.self) else { + fatalError("DetailSceneFactory dependencies not registered") + } - return MonthlyCalendarViewModel( - fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, - requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, - fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, - getNicknameUseCase: getNicknameUseCase - ) + guard let updateFoodRecordUseCase = try? container.resolve(UpdateFoodRecordUseCase.self) else { + fatalError("EditSceneFactory dependencies not registered") } - container.register(MyPageViewModel.self, scope: .transient) { resolver in - guard let updateDeviceUseCase = resolver.resolve( - UpdateDeviceNotificationSettingUseCase.self - ), - let notificationAuthProvider = resolver.resolve(NotificationAuthorizationProviding.self), - let logoutUseCase = resolver.resolve(LogoutUseCase.self), - let withdrawUserUseCase = resolver.resolve(WithdrawUserUseCase.self), - let getNicknameUseCase = resolver.resolve(GetNicknameUseCase.self), - let getAppVersionUseCase = resolver.resolve(GetAppVersionUseCase.self) else { - fatalError("MyPageViewModel dependencies not registered") - } + guard let searchAddressUseCase = try? container.resolve(SearchAddressUseCase.self) else { + fatalError("AddressSearchSceneFactory dependencies not registered") + } - return MyPageViewModel( - updateDeviceNotificationSettingUseCase: updateDeviceUseCase, - notificationAuthorizationProvider: notificationAuthProvider, + // Calendar + guard + let requestPhotoAuthUseCase = try? container.resolve(RequestPhotoAuthorizationUseCase.self), + let loadWeeklyUseCase = try? container.resolve(LoadWeeklyRecordUseCase.self), + let coachmarkStorage = try? container.resolve(CoachmarkStoring.self), + let checkAppReviewUseCase = try? container.resolve(CheckAppReviewEligibilityUseCase.self), + let fetchMonthlyUseCase = try? container.resolve(FetchMonthlyCalendarDaysUseCase.self) + else { + fatalError("CalendarSceneFactory dependencies not registered") + } + + guard let photoAuthFetcher = try? container.resolve(PhotoAuthorizationFetcher.self) else { + fatalError("PhotoAuthorizationFetcher not registered") + } + + let factories = Factories( + login: LoginSceneFactory(useCase: finalizeUseCase), + calendar: CalendarSceneFactory( + requestPhotoAuthorizationUseCase: requestPhotoAuthUseCase, + loadWeeklyCalendarDataUseCase: loadWeeklyUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver, + getNicknameUseCase: getNicknameUseCase, + coachmarkStorage: coachmarkStorage, + checkAppReviewEligibilityUseCase: checkAppReviewUseCase, + fetchMonthlyCalendarDaysUseCase: fetchMonthlyUseCase, + fetchFoodRecordsUseCase: fetchRecordsUseCase + ), + insight: InsightSceneFactory(useCase: fetchInsightUseCase), + myPage: MyPageSceneFactory( + updateDeviceUseCase: updateDeviceUseCase, + notificationAuthProvider: notificationAuthProvider, logoutUseCase: logoutUseCase, withdrawUserUseCase: withdrawUserUseCase, getNicknameUseCase: getNicknameUseCase, getAppVersionUseCase: getAppVersionUseCase - ) + ), + detail: DetailSceneFactory( + fetchRecordsUseCase: fetchRecordsUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver + ), + imagePicker: imagePickerFactory, + edit: EditSceneFactory( + updateFoodRecordUseCase: updateFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase + ), + addressSearch: AddressSearchSceneFactory(searchAddressUseCase: searchAddressUseCase), + permission: PermissionSceneFactory() + ) + + guard let loginSession = try? container.resolve(LoginSession.self) else { + fatalError("LoginSession not registered") } - } - #if DEBUG - fileprivate func saveDebugImageToPhotoLibrary() { - // PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in - // guard status == .authorized || status == .limited else { return } - - // guard let path = Bundle.main.path(forResource: "food", ofType: "jpg"), - // let image = UIImage(contentsOfFile: path) - // else { return } - - // let calendar = Calendar.current - // let today = calendar.startOfDay(for: Date()) - - // for dayOffset in 0..<7 { - // guard - // let targetDate = calendar.date(byAdding: .day, value: -dayOffset, to: today) - // else { continue } - // PHPhotoLibrary.shared().performChanges { - // let request = PHAssetChangeRequest.creationRequestForAsset(from: image) - // request.creationDate = targetDate - // } - // } - // } - } - #endif + let appCoordinator = AppCoordinator(factories: factories) + let transitionHandler = DefaultViewTransitionHandler() + let appFlowController = AppFlowController( + appCoordinator: appCoordinator, + loginSession: loginSession, + container: container, + transitionHandler: transitionHandler, + photoAuthFetcher: photoAuthFetcher + ) + appCoordinator.sceneTransitioner = appFlowController + return appFlowController + } } diff --git a/FoodDiary/DI/Project.swift b/FoodDiary/DI/Project.swift index 97c2d7ef..080318f2 100644 --- a/FoodDiary/DI/Project.swift +++ b/FoodDiary/DI/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.di", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], dependencies: [ .external(name: "Swinject"), diff --git a/FoodDiary/Data/Project.swift b/FoodDiary/Data/Project.swift index 8c9a0d66..5b3cda15 100644 --- a/FoodDiary/Data/Project.swift +++ b/FoodDiary/Data/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.data", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], resources: ["Resources/**"], dependencies: [ @@ -29,7 +29,7 @@ let project = Project( destinations: [.iPhone], product: .unitTests, bundleId: "com.fooddiary.data.tests", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Tests/**"], resources: ["Tests/Resources/**"], dependencies: [ diff --git a/FoodDiary/Data/Sources/Cache/PhotoURLCache.swift b/FoodDiary/Data/Sources/Cache/PhotoURLCache.swift new file mode 100644 index 00000000..205bc36b --- /dev/null +++ b/FoodDiary/Data/Sources/Cache/PhotoURLCache.swift @@ -0,0 +1,37 @@ +// +// PhotoURLCache.swift +// Data +// + +import Foundation + +actor PhotoURLCache { + private var entries: [(key: String, value: [Date: [URL]])] = [] + private let capacity = 12 + + func get(for dateRange: ClosedRange) -> [Date: [URL]]? { + let k = cacheKey(for: dateRange) + guard let index = entries.firstIndex(where: { $0.key == k }) else { return nil } + let entry = entries.remove(at: index) + entries.insert(entry, at: 0) + return entry.value + } + + func set(_ value: [Date: [URL]], for dateRange: ClosedRange) { + let k = cacheKey(for: dateRange) + entries.removeAll { $0.key == k } + entries.insert((key: k, value: value), at: 0) + if entries.count > capacity { + entries.removeLast() + } + } + + func remove(for dateRange: ClosedRange) { + let k = cacheKey(for: dateRange) + entries.removeAll { $0.key == k } + } + + private func cacheKey(for dateRange: ClosedRange) -> String { + "\(Int(dateRange.lowerBound.timeIntervalSince1970))-\(Int(dateRange.upperBound.timeIntervalSince1970))" + } +} diff --git a/FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift b/FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift new file mode 100644 index 00000000..14673f0a --- /dev/null +++ b/FoodDiary/Data/Sources/Core/Coachmark/CoachmarkStorage.swift @@ -0,0 +1,23 @@ +// +// CoachmarkStorage.swift +// Data +// +// Created by 강대훈 on 4/9/26. +// + +import Domain +import Foundation + +public struct CoachmarkStorage: CoachmarkStoring { + private let key = "has_shown_coachmark" + + public init() {} + + public func get() -> Bool { + UserDefaults.standard.bool(forKey: key) + } + + public func set() { + UserDefaults.standard.set(true, forKey: key) + } +} diff --git a/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift b/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift index 4644ba23..70b4b991 100644 --- a/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift +++ b/FoodDiary/Data/Sources/Core/FoodImageAsset/FoodImageAssetFetcher.swift @@ -11,12 +11,9 @@ import Photos import UIKit /// 사진 라이브러리에서 음식 사진을 시간순으로 가져오는 `Repository` 구현체 -public struct FoodImageAssetFetcher< - FoodClassifier: FoodClassifierRepresentable, - ImageRepo: RenderableImageRepository ->: FoodImageAssetRepository where ImageRepo.Asset == PHAsset { - private let foodClassifier: FoodClassifier - private let imageRepository: ImageRepo +public struct FoodImageAssetFetcher: FoodImageAssetRepository { + private let foodClassifier: any FoodClassifierRepresentable + private let imageRepository: any RenderableImageRepository private let cache: ClassificationCacheManager private let imageTargetSize: CGSize private let logger = Logger(label: "com.fooddiary.asset-fetcher") @@ -27,8 +24,8 @@ public struct FoodImageAssetFetcher< /// - cache: 분류 결과 캐시 /// - imageTargetSize: ML 분류용 이미지 크기 (기본: 224x224) public init( - foodClassifier: FoodClassifier, - imageRepository: ImageRepo, + foodClassifier: any FoodClassifierRepresentable, + imageRepository: any RenderableImageRepository, cache: ClassificationCacheManager, imageTargetSize: CGSize = CGSize(width: 224, height: 224) ) { @@ -41,7 +38,7 @@ public struct FoodImageAssetFetcher< public func fetchFoodImageAssets( from startDate: Date, to endDate: Date? - ) async throws -> [Date: [FoodImageAsset]] { + ) async throws -> [Date: [FoodImageAsset]] { let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) guard status == .authorized || status == .limited else { logger.error("[Asset Fetch] 사진 라이브러리 권한 없음 (status: \(status.rawValue))") @@ -103,8 +100,6 @@ public struct FoodImageAssetFetcher< } } -public typealias PHFoodImageAsset = FoodImageAsset - // MARK: - Photo Fetching extension FoodImageAssetFetcher { @@ -150,7 +145,7 @@ extension FoodImageAssetFetcher { extension FoodImageAssetFetcher { fileprivate func classifyAllSections(_ sections: [PhotoSection]) async throws -> [Date: - [PHFoodImageAsset]] + [FoodImageAsset]] { let results = try await mapEach(sections) { section in let photos = try await self.classifyPhotosInSection(section) @@ -161,7 +156,7 @@ extension FoodImageAssetFetcher { } fileprivate func classifyPhotosInSection(_ section: PhotoSection) async throws - -> [PHFoodImageAsset] + -> [FoodImageAsset] { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MM/dd" @@ -170,7 +165,7 @@ extension FoodImageAssetFetcher { let start = clock.now return try await withThrowingTaskGroup( - of: (originalIndex: Int, photo: PHFoodImageAsset).self + of: (originalIndex: Int, photo: FoodImageAsset).self ) { group in for (index, asset) in section.assets.enumerated() { group.addTask { @@ -178,7 +173,7 @@ extension FoodImageAssetFetcher { } } - var results: [(originalIndex: Int, photo: PHFoodImageAsset)] = [] + var results: [(originalIndex: Int, photo: FoodImageAsset)] = [] for try await result in group { results.append(result) } @@ -193,7 +188,7 @@ extension FoodImageAssetFetcher { } } - fileprivate func classifyAsset(_ asset: PHAsset) async throws -> PHFoodImageAsset { + fileprivate func classifyAsset(_ asset: PHAsset) async throws -> FoodImageAsset { let identifier = asset.localIdentifier if let cached = await cache.get(identifier) { diff --git a/FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift b/FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift new file mode 100644 index 00000000..e7cea349 --- /dev/null +++ b/FoodDiary/Data/Sources/Core/Notification/LocalNotificationScheduler.swift @@ -0,0 +1,62 @@ +// +// LocalNotificationScheduler.swift +// Data +// + +import UserNotifications +import Domain + +/// 테스트 가능성을 위한 내부 추상화. 실제 구현은 UNUserNotificationCenter +protocol UserNotificationCentering: Sendable { + func add(_ request: UNNotificationRequest) async throws + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) +} + +extension UNUserNotificationCenter: UserNotificationCentering {} + +public final class LocalNotificationScheduler: LocalNotificationScheduling { + static let dailyReminderIdentifier = "daily_reminder" + + private let notificationCenter: any UserNotificationCentering + + public convenience init() { + self.init(notificationCenter: UNUserNotificationCenter.current()) + } + + init(notificationCenter: any UserNotificationCentering) { + self.notificationCenter = notificationCenter + } + + public func scheduleDailyReminder(hour: Int, minute: Int) async { + let content = UNMutableNotificationContent() + content.title = "오늘 먹은 음식, 기록해볼까요?" + content.body = "하루가 끝나기 전에 오늘의 식사를 남겨보세요." + content.sound = .default + content.userInfo = ["notification_type": "daily_reminder"] + + var dateComponents = DateComponents() + dateComponents.hour = hour + dateComponents.minute = minute + + let trigger = UNCalendarNotificationTrigger(dateMatching: dateComponents, repeats: true) + + notificationCenter.removePendingNotificationRequests( + withIdentifiers: [Self.dailyReminderIdentifier] + ) + + let request = UNNotificationRequest( + identifier: Self.dailyReminderIdentifier, + content: content, + trigger: trigger + ) + try? await notificationCenter.add(request) + print("[LocalNotification] 매일 \(hour):\(String(format: "%02d", minute)) 리마인더 등록 완료") + } + + public func cancelDailyReminder() async { + notificationCenter.removePendingNotificationRequests( + withIdentifiers: [Self.dailyReminderIdentifier] + ) + print("[LocalNotification] 매일 리마인더 취소 완료") + } +} diff --git a/FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift b/FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift new file mode 100644 index 00000000..db3a81f1 --- /dev/null +++ b/FoodDiary/Data/Sources/Core/Review/AnalysisCountStorage.swift @@ -0,0 +1,31 @@ +// +// AnalysisCountStorage.swift +// Data +// + +import Domain +import Foundation + +public struct AnalysisCountStorage: AnalysisCountStoring { + private let countKey = "analysis_push_count" + private let reviewRequestedKey = "app_review_requested" + + public init() {} + + public func getCount() -> Int { + UserDefaults.standard.integer(forKey: countKey) + } + + public func incrementCount() { + let current = getCount() + UserDefaults.standard.set(current + 1, forKey: countKey) + } + + public func hasRequestedReview() -> Bool { + UserDefaults.standard.bool(forKey: reviewRequestedKey) + } + + public func setReviewRequested() { + UserDefaults.standard.set(true, forKey: reviewRequestedKey) + } +} diff --git a/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift b/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift index 0b8768f5..6efc7fdb 100644 --- a/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift +++ b/FoodDiary/Data/Sources/Core/TokenManager/AuthTokenStorage.swift @@ -9,11 +9,11 @@ import Foundation import Domain /// Keychain을 사용한 인증 토큰 저장소 구현체 -public struct AuthTokenStorage: AuthTokenStoring { - private let keychainService: Service +public struct AuthTokenStorage: AuthTokenStoring { + private let keychainService: any KeychainServicing private let tokenKey = "access_token" - public init(keychainService: Service) { + public init(keychainService: any KeychainServicing) { self.keychainService = keychainService } diff --git a/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift b/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift index 231bcc2b..5e6aacca 100644 --- a/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift +++ b/FoodDiary/Data/Sources/Core/TokenManager/KeychainServicing.swift @@ -7,7 +7,7 @@ import Foundation -public protocol KeychainServicing { +public protocol KeychainServicing: Sendable { func save(key: String, value: String) -> Bool func load(key: String) -> String? func delete(key: String) -> Bool diff --git a/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift new file mode 100644 index 00000000..0d5f9923 --- /dev/null +++ b/FoodDiary/Data/Sources/DTO/InsightResponseDTO.swift @@ -0,0 +1,192 @@ +// +// InsightResponseDTO.swift +// Data +// + +import Domain +import Foundation + +struct InsightResponseDTO: Decodable { + let month: String + let photoStats: PhotoStatsDTO + let categoryStats: CategoryStatsDTO + let diaryTimeStats: DiaryTimeStatsDTO + let locationStats: [LocationStatDTO] + let tagStats: [KeywordStatDTO] + let weeklyStats: WeeklyStatsDTO + + enum CodingKeys: String, CodingKey { + case month + case photoStats = "photo_stats" + case categoryStats = "category_stats" + case diaryTimeStats = "diary_time_stats" + case locationStats = "location_stats" + case tagStats = "tag_stats" + case weeklyStats = "weekly_stats" + } + + func toInsight() -> Insight { + Insight( + month: month, + photoStats: photoStats.toEntity(), + categoryStats: categoryStats.toEntity(), + diaryTimeStats: diaryTimeStats.toEntity(), + locationStats: locationStats.map { $0.toEntity() }, + tagStats: tagStats.map { $0.toEntity() }, + weeklyStats: weeklyStats.toEntity() + ) + } +} + +struct PhotoStatsDTO: Decodable { + let currentMonthCount: Int + let previousMonthCount: Int + let changeRate: Double + + enum CodingKeys: String, CodingKey { + case currentMonthCount = "current_month_count" + case previousMonthCount = "previous_month_count" + case changeRate = "change_rate" + } + + func toEntity() -> PhotoStats { + PhotoStats( + currentMonthCount: currentMonthCount, + previousMonthCount: previousMonthCount, + changeRate: Int(changeRate) + ) + } +} + +struct CategoryStatsDTO: Decodable { + let currentMonth: CategoryStatDTO + let previousMonth: CategoryStatDTO + let currentMonthCounts: CategoryCountsDTO + + enum CodingKeys: String, CodingKey { + case currentMonth = "current_month" + case previousMonth = "previous_month" + case currentMonthCounts = "current_month_counts" + } + + func toEntity() -> CategoryStats { + CategoryStats( + currentMonth: currentMonth.toEntity(), + previousMonth: previousMonth.toEntity(), + currentMonthCounts: currentMonthCounts.toEntity() + ) + } +} + +struct CategoryStatDTO: Decodable { + let topCategory: String + let count: Int + + enum CodingKeys: String, CodingKey { + case topCategory = "top_category" + case count + } + + func toEntity() -> CategoryStat { + CategoryStat(topCategory: topCategory, count: count) + } +} + +struct CategoryCountsDTO: Decodable { + let chinese: Int + let etc: Int + let homeCooked: Int + let japanese: Int + let korean: Int + let western: Int + + enum CodingKeys: String, CodingKey { + case chinese + case etc + case homeCooked = "home_cooked" + case japanese + case korean + case western + } + + func toEntity() -> CategoryCounts { + CategoryCounts( + chinese: chinese, + etc: etc, + homeCooked: homeCooked, + japanese: japanese, + korean: korean, + western: western + ) + } +} + +struct DiaryTimeStatsDTO: Decodable { + let mostActiveTime: String + let distribution: [TimeCountDTO] + + enum CodingKeys: String, CodingKey { + case mostActiveTime = "most_active_time" + case distribution + } + + func toEntity() -> DiaryTimeStats { + DiaryTimeStats( + mostActiveTime: mostActiveTime, + distribution: distribution.map { $0.toEntity() } + ) + } +} + +struct TimeCountDTO: Decodable { + let time: String + let count: Int + + func toEntity() -> TimeCount { + TimeCount(time: time, count: count) + } +} + +struct KeywordStatDTO: Decodable { + let keyword: String + let count: Int + + func toEntity() -> KeywordStat { + KeywordStat(keyword: keyword, count: count) + } +} + +struct LocationStatDTO: Decodable { + let dong: String + let count: Int + + func toEntity() -> LocationStat { + LocationStat(dong: dong, count: count) + } +} + +struct WeeklyStatsDTO: Decodable { + let mostActiveWeek: Int + let weeklyCounts: [WeekCountDTO] + + enum CodingKeys: String, CodingKey { + case mostActiveWeek = "most_active_week" + case weeklyCounts = "weekly_counts" + } + + func toEntity() -> WeeklyStats { + WeeklyStats( + mostActiveWeek: mostActiveWeek, + weeklyCounts: weeklyCounts.map { $0.toEntity() } + ) + } +} + +struct WeekCountDTO: Decodable { + let week: Int + let count: Int + + func toEntity() -> WeekCount { + WeekCount(week: week, count: count) + } +} diff --git a/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift new file mode 100644 index 00000000..14ce444b --- /dev/null +++ b/FoodDiary/Data/Sources/Network/Endpoints/InsightEndpoint.swift @@ -0,0 +1,55 @@ +// +// InsightEndpoint.swift +// Data +// + +import Foundation + +public enum InsightEndpoint { + case fetch +} + +extension InsightEndpoint: Requestable { + public var baseURL: String { + guard let url = Bundle.main.infoDictionary?["BASE_URL"] as? String else { + fatalError("BASE_URL이 Info.plist에 설정되지 않았습니다.") + } + + return url + } + + public var path: String { + switch self { + case .fetch: + "/me/insights" + } + } + + public var httpMethod: HTTPMethod { + switch self { + case .fetch: + .get + } + } + + public var queryParameters: Encodable? { + switch self { + case .fetch: + nil + } + } + + public var bodyParameters: HTTPBody { + switch self { + case .fetch: + .none + } + } + + public var headers: [String: String] { + switch self { + case .fetch: + [:] + } + } +} diff --git a/FoodDiary/Data/Sources/Network/HTTPClienting.swift b/FoodDiary/Data/Sources/Network/HTTPClienting.swift index 534464e0..c7bba17e 100644 --- a/FoodDiary/Data/Sources/Network/HTTPClienting.swift +++ b/FoodDiary/Data/Sources/Network/HTTPClienting.swift @@ -7,7 +7,7 @@ import Foundation -public protocol HTTPClienting { +public protocol HTTPClienting: Sendable { func request(_ request: some Requestable, accessToken: String?) async throws -> T func request(_ request: some Requestable, accessToken: String?) async throws } diff --git a/FoodDiary/Data/Sources/Repository/AuthRepository.swift b/FoodDiary/Data/Sources/Repository/AuthRepository.swift index d8dacb16..1ba16c94 100644 --- a/FoodDiary/Data/Sources/Repository/AuthRepository.swift +++ b/FoodDiary/Data/Sources/Repository/AuthRepository.swift @@ -8,11 +8,11 @@ import Domain import Foundation -public struct AuthRepositoryImpl: AuthRepository { - let httpClient: Client - let tokenStorage: Storage +public struct AuthRepositoryImpl: AuthRepository { + let httpClient: any HTTPClienting + let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift index 187c2725..2726e1eb 100644 --- a/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/DeviceRepositoryImpl.swift @@ -8,11 +8,11 @@ import Domain import Foundation -public struct DeviceRepositoryImpl: DeviceRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct DeviceRepositoryImpl: DeviceRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift index 7b5e121a..1946d260 100644 --- a/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/FoodRecordRepositoryImpl.swift @@ -9,15 +9,13 @@ import Photos import UIKit /// FoodRecordRepository 구현체 -public struct FoodRecordRepositoryImpl< - Client: HTTPClienting & Sendable, - Storage: AuthTokenStoring & Sendable ->: FoodRecordRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct FoodRecordRepositoryImpl: FoodRecordRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring private let deviceId: String private let imageConverter: PHAssetConverter private let calendar = Calendar.current + private let photoURLCache = PhotoURLCache() #if DEBUG let testMode: Bool = true @@ -26,7 +24,7 @@ public struct FoodRecordRepositoryImpl< #endif public init( - httpClient: Client, tokenStorage: Storage, deviceId: String, + httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring, deviceId: String, imageConverter: PHAssetConverter ) { self.httpClient = httpClient @@ -97,31 +95,50 @@ public struct FoodRecordRepositoryImpl< return records[startOfDay] ?? [] } - public func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date: [URL]] { - guard let accessToken = tokenStorage.get() else { - throw FoodRecordError.noAccessToken - } + public func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> { + AsyncThrowingStream { continuation in + Task { + // 1. 캐시 HIT 시 즉시 방출 + if let cached = await photoURLCache.get(for: dateRange) { + continuation.yield(cached) + } - let startDateString = formatDate(dateRange.lowerBound) - let endDateString = formatDate(dateRange.upperBound) + // 2. 항상 서버 검증 + do { + guard let accessToken = tokenStorage.get() else { + throw FoodRecordError.noAccessToken + } - let endpoint = DiaryEndpoint.byDateRangeSummary( - startDate: startDateString, - endDate: endDateString, - testMode: testMode - ) + let endpoint = DiaryEndpoint.byDateRangeSummary( + startDate: formatDate(dateRange.lowerBound), + endDate: formatDate(dateRange.upperBound), + testMode: testMode + ) - let response: DiariesByDateRangeSummaryResponseDTO = try await httpClient.request( - endpoint, - accessToken: accessToken - ) + let response: DiariesByDateRangeSummaryResponseDTO = try await httpClient.request( + endpoint, + accessToken: accessToken + ) - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" - return response.reduce(into: [Date: [URL]]()) { result, entry in - guard let date = formatter.date(from: entry.key) else { return } - result[date] = entry.value.photos.compactMap { URL(string: $0.url) } + let fresh = response.reduce(into: [Date: [URL]]()) { result, entry in + guard let date = formatter.date(from: entry.key) else { return } + result[date] = entry.value.photos.compactMap { URL(string: $0.url) } + } + + let current = await photoURLCache.get(for: dateRange) + if current != fresh { + await photoURLCache.set(fresh, for: dateRange) + continuation.yield(fresh) + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } } } diff --git a/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift new file mode 100644 index 00000000..b0a80455 --- /dev/null +++ b/FoodDiary/Data/Sources/Repository/InsightRepositoryImpl.swift @@ -0,0 +1,29 @@ +// +// InsightRepositoryImpl.swift +// Data +// + +import Domain +import Foundation + +public struct InsightRepositoryImpl: InsightRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring + + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { + self.httpClient = httpClient + self.tokenStorage = tokenStorage + } + + public func fetchInsight() async throws -> Insight { + do { + let response: InsightResponseDTO = try await httpClient.request( + InsightEndpoint.fetch, + accessToken: tokenStorage.get() + ) + return response.toInsight() + } catch NetworkError.httpError(statusCode: 400, _) { + throw InsightError.insufficientData + } + } +} diff --git a/FoodDiary/Data/Sources/Repository/TokenRepository.swift b/FoodDiary/Data/Sources/Repository/TokenRepository.swift index 604b5642..168a3acd 100644 --- a/FoodDiary/Data/Sources/Repository/TokenRepository.swift +++ b/FoodDiary/Data/Sources/Repository/TokenRepository.swift @@ -8,16 +8,12 @@ import Domain import Foundation -public struct TokenRepositoryImpl< - Client: HTTPClienting, - Storage: AuthTokenStoring, - LaunchStorage: InitialLaunchStoring ->: TokenRepository { - let httpClient: Client - let storage: Storage - let launchStorage: LaunchStorage +public struct TokenRepositoryImpl: TokenRepository { + let httpClient: any HTTPClienting + let storage: any AuthTokenStoring + let launchStorage: any InitialLaunchStoring - public init(httpClient: Client, storage: Storage, launchStorage: LaunchStorage) { + public init(httpClient: any HTTPClienting, storage: any AuthTokenStoring, launchStorage: any InitialLaunchStoring) { self.httpClient = httpClient self.storage = storage self.launchStorage = launchStorage diff --git a/FoodDiary/Data/Sources/Repository/UIImageLoader.swift b/FoodDiary/Data/Sources/Repository/UIImageLoader.swift index 25ab1656..7ff6b80e 100644 --- a/FoodDiary/Data/Sources/Repository/UIImageLoader.swift +++ b/FoodDiary/Data/Sources/Repository/UIImageLoader.swift @@ -14,8 +14,11 @@ public struct UIImageLoader: RenderableImageRepository { self.imageConverter = imageLoader } - public func loadImage(for asset: PHAsset, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage { + public func loadImage(for asset: any ImageAssetable, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage { + guard let phAsset = asset as? PHAsset else { + throw FoodRecordError.imageConversionFailed + } let deliveryMode: PHImageRequestOptionsDeliveryMode = preferFastDelivery ? .fastFormat : .highQualityFormat - return try await imageConverter.convert(from: asset, targetSize: targetSize, deliveryMode: deliveryMode) + return try await imageConverter.convert(from: phAsset, targetSize: targetSize, deliveryMode: deliveryMode) } } diff --git a/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift b/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift index 83ae229b..81c3d87c 100644 --- a/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift +++ b/FoodDiary/Data/Sources/Repository/UserRepositoryImpl.swift @@ -8,11 +8,11 @@ import Domain import Foundation -public struct UserRepositoryImpl: UserRepository { - private let httpClient: Client - private let tokenStorage: Storage +public struct UserRepositoryImpl: UserRepository { + private let httpClient: any HTTPClienting + private let tokenStorage: any AuthTokenStoring - public init(httpClient: Client, tokenStorage: Storage) { + public init(httpClient: any HTTPClienting, tokenStorage: any AuthTokenStoring) { self.httpClient = httpClient self.tokenStorage = tokenStorage } diff --git a/FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift b/FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift new file mode 100644 index 00000000..15474f26 --- /dev/null +++ b/FoodDiary/Data/Tests/LocalNotificationSchedulerTests.swift @@ -0,0 +1,71 @@ +// +// LocalNotificationSchedulerTests.swift +// Data +// + +@testable import Data +import Foundation +import Testing +import UserNotifications + +@Suite("LocalNotificationScheduler Tests") +struct LocalNotificationSchedulerTests { + + @Test("scheduleDailyReminder는 21:00 매일 반복되는 캘린더 트리거를 등록한다") + func scheduleDailyReminder_addsCalendarTriggerAt21WithRepeats() async throws { + let center = MockUserNotificationCenter() + let sut = LocalNotificationScheduler(notificationCenter: center) + + await sut.scheduleDailyReminder(hour: 21, minute: 0) + + // 동일 identifier에 대해 기존 pending 제거 후 새로 add 되었는지 + #expect(center.removedIdentifiers.count == 1) + #expect(center.removedIdentifiers.first == [LocalNotificationScheduler.dailyReminderIdentifier]) + + #expect(center.addedRequests.count == 1) + let request = try #require(center.addedRequests.first) + + #expect(request.identifier == LocalNotificationScheduler.dailyReminderIdentifier) + + // 매일 반복되는 캘린더 트리거인지 + let trigger = try #require(request.trigger as? UNCalendarNotificationTrigger) + #expect(trigger.repeats == true) + #expect(trigger.dateComponents.hour == 21) + #expect(trigger.dateComponents.minute == 0) + + // 컨텐츠 검증 — 알림이 실제로 표시될 때 사용자가 보는 부분 + #expect(request.content.title == "오늘 먹은 음식, 기록해볼까요?") + #expect(request.content.body == "하루가 끝나기 전에 오늘의 식사를 남겨보세요.") + #expect(request.content.sound == .default) + #expect(request.content.userInfo["notification_type"] as? String == "daily_reminder") + // 분석 결과 푸시 분기와의 충돌 방지: is_local_notification 키는 들어가면 안 됨 + #expect(request.content.userInfo["is_local_notification"] == nil) + } + + @Test("cancelDailyReminder는 등록된 리마인더를 제거하고 새로 추가하지 않는다") + func cancelDailyReminder_removesPendingRequestsWithoutAdding() async { + let center = MockUserNotificationCenter() + let sut = LocalNotificationScheduler(notificationCenter: center) + + await sut.cancelDailyReminder() + + #expect(center.removedIdentifiers.count == 1) + #expect(center.removedIdentifiers.first == [LocalNotificationScheduler.dailyReminderIdentifier]) + #expect(center.addedRequests.isEmpty) + } + + @Test("scheduleDailyReminder를 두 번 호출해도 동일 identifier로 덮어쓰기된다 (idempotent)") + func scheduleDailyReminder_calledTwice_isIdempotent() async { + let center = MockUserNotificationCenter() + let sut = LocalNotificationScheduler(notificationCenter: center) + + await sut.scheduleDailyReminder(hour: 21, minute: 0) + await sut.scheduleDailyReminder(hour: 21, minute: 0) + + #expect(center.removedIdentifiers.count == 2) + #expect(center.addedRequests.count == 2) + #expect(center.addedRequests.allSatisfy { + $0.identifier == LocalNotificationScheduler.dailyReminderIdentifier + }) + } +} diff --git a/FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift b/FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift new file mode 100644 index 00000000..41e5ddd7 --- /dev/null +++ b/FoodDiary/Data/Tests/Mock/MockUserNotificationCenter.swift @@ -0,0 +1,24 @@ +// +// MockUserNotificationCenter.swift +// Data +// + +@testable import Data +import UserNotifications + +final class MockUserNotificationCenter: UserNotificationCentering, @unchecked Sendable { + private(set) var addedRequests: [UNNotificationRequest] = [] + private(set) var removedIdentifiers: [[String]] = [] + var addError: Error? + + func add(_ request: UNNotificationRequest) async throws { + if let addError { + throw addError + } + addedRequests.append(request) + } + + func removePendingNotificationRequests(withIdentifiers identifiers: [String]) { + removedIdentifiers.append(identifiers) + } +} diff --git a/FoodDiary/Data/Tests/RequestableTests.swift b/FoodDiary/Data/Tests/RequestableTests.swift index 68a76362..31969b33 100644 --- a/FoodDiary/Data/Tests/RequestableTests.swift +++ b/FoodDiary/Data/Tests/RequestableTests.swift @@ -75,6 +75,7 @@ struct RequestableTests { func testMultipartBody() throws { let formData = MultipartFormData( date: "2026-02-17", + deviceId: "abcd", photos: [ File(fileName: "photo1.jpg", mimeType: "image/jpeg", data: "dummy-1".data(using: .utf8)!), File(fileName: "photo2.jpg", mimeType: "image/jpeg", data: "dummy-2".data(using: .utf8)!) @@ -91,6 +92,7 @@ struct RequestableTests { func testMultipartContentTypeHeader() throws { let formData = MultipartFormData( date: "2026-02-17", + deviceId: "abcd", photos: [ File(fileName: "photo1.jpg", mimeType: "image/jpeg", data: "dummy-1".data(using: .utf8)!), File(fileName: "photo2.jpg", mimeType: "image/jpeg", data: "dummy-2".data(using: .utf8)!) diff --git a/FoodDiary/DesignSystem/Project.swift b/FoodDiary/DesignSystem/Project.swift index 005902f6..3bddb1dc 100644 --- a/FoodDiary/DesignSystem/Project.swift +++ b/FoodDiary/DesignSystem/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.designsystem", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], resources: ["Resources/**"], dependencies: [ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientEnd.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientEnd.colorset/Contents.json new file mode 100644 index 00000000..9274779e --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientEnd.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE6", + "green" : "0xA6", + "red" : "0x8A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientStart.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientStart.colorset/Contents.json new file mode 100644 index 00000000..4dadb55b --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/blueGradientStart.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x99", + "green" : "0x51", + "red" : "0x41" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientEnd.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientEnd.colorset/Contents.json new file mode 100644 index 00000000..fc8f545e --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientEnd.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x83", + "green" : "0xB1", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json new file mode 100644 index 00000000..3e0dcabe --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/primaryGradientStart.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x0E", + "green" : "0x67", + "red" : "0xFE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json new file mode 100644 index 00000000..3bb76f8d --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/Colors/sd850.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x33", + "green" : "0x29", + "red" : "0x2A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json new file mode 100644 index 00000000..7bbfd977 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon-permission-photo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon-permission-photo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon-permission-photo@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo.png new file mode 100644 index 00000000..61cc1855 Binary files /dev/null and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@2x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@2x.png new file mode 100644 index 00000000..1ee85816 Binary files /dev/null and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@2x.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@3x.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@3x.png new file mode 100644 index 00000000..2c8cca2a Binary files /dev/null and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/icon-permission-photo.imageset/icon-permission-photo@3x.png differ diff --git a/FoodDiary/Domain/Project.swift b/FoodDiary/Domain/Project.swift index 29f31154..00784fd2 100644 --- a/FoodDiary/Domain/Project.swift +++ b/FoodDiary/Domain/Project.swift @@ -15,7 +15,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.domain", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], dependencies: [] ), @@ -24,7 +24,7 @@ let project = Project( destinations: [.iPhone], product: .unitTests, bundleId: "com.fooddiary.domain.tests", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Tests/**"], dependencies: [ .target(name: "Domain") diff --git a/FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift b/FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift new file mode 100644 index 00000000..5ffb9207 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Coachmark/CoachmarkStoring.swift @@ -0,0 +1,13 @@ +// +// CoachmarkStoring.swift +// Domain +// +// Created by 강대훈 on 4/9/26. +// + +import Foundation + +public protocol CoachmarkStoring { + func get() -> Bool + func set() +} diff --git a/FoodDiary/Domain/Sources/Core/Error/InsightError.swift b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift new file mode 100644 index 00000000..344e5241 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Error/InsightError.swift @@ -0,0 +1,10 @@ +// +// InsightError.swift +// Domain +// + +import Foundation + +public enum InsightError: Error { + case insufficientData +} diff --git a/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift b/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift index 1563f29b..6490937f 100644 --- a/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift +++ b/FoodDiary/Domain/Sources/Core/InitialLaunch/InitialLaunchStoring.swift @@ -7,7 +7,7 @@ import Foundation -public protocol InitialLaunchStoring { +public protocol InitialLaunchStoring: Sendable { func get() -> Bool func set() } diff --git a/FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift b/FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift new file mode 100644 index 00000000..938a5164 --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/LoginSession/LoginSession.swift @@ -0,0 +1,33 @@ +// +// LoginSession.swift +// Domain +// +// Created by 강대훈 on 4/10/26. +// + +// TODO: 추후 적절한 레이어로 이동 예정 + +import Combine + +public final class LoginSession { + private let loginResultSubject = PassthroughSubject() + private let logoutSubject = PassthroughSubject() + + public var loginResultPublisher: AnyPublisher { + loginResultSubject.eraseToAnyPublisher() + } + + public var logoutPublisher: AnyPublisher { + logoutSubject.eraseToAnyPublisher() + } + + public init() {} + + public func notifyLoginSuccess(_ result: LoginResult) { + loginResultSubject.send(result) + } + + public func notifyLogout() { + logoutSubject.send() + } +} diff --git a/FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift b/FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift new file mode 100644 index 00000000..3dd41b1a --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Notification/LocalNotificationScheduling.swift @@ -0,0 +1,11 @@ +// +// LocalNotificationScheduling.swift +// Domain +// + +import Foundation + +public protocol LocalNotificationScheduling: Sendable { + func scheduleDailyReminder(hour: Int, minute: Int) async + func cancelDailyReminder() async +} diff --git a/FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift b/FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift new file mode 100644 index 00000000..0d8e4f6d --- /dev/null +++ b/FoodDiary/Domain/Sources/Core/Review/AnalysisCountStoring.swift @@ -0,0 +1,13 @@ +// +// AnalysisCountStoring.swift +// Domain +// + +import Foundation + +public protocol AnalysisCountStoring { + func getCount() -> Int + func incrementCount() + func hasRequestedReview() -> Bool + func setReviewRequested() +} diff --git a/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift b/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift index 1663c96f..e425783b 100644 --- a/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift +++ b/FoodDiary/Domain/Sources/Core/TokenManager/AuthTokenStoring.swift @@ -8,7 +8,7 @@ import Foundation /// 인증 토큰(Access Token)을 저장하고 관리하는 프로토콜 -public protocol AuthTokenStoring { +public protocol AuthTokenStoring: Sendable { /// 저장된 인증 토큰을 가져옴 /// - Returns: 인증 토큰 문자열, 없을 경우 nil func get() -> String? diff --git a/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift b/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift index 743d3b50..8b34961e 100644 --- a/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift +++ b/FoodDiary/Domain/Sources/Entity/FoodImageAsset.swift @@ -7,15 +7,15 @@ import Foundation -public struct FoodImageAsset: Sendable, Equatable { - public let imageAsset: ImageAsset +public struct FoodImageAsset: Sendable, Equatable { + public let imageAsset: any ImageAssetable /// 음식일 확률 (0.0 ~ 1.0) public let foodProbability: Float public var id: String { imageAsset.id } public var creationDate: Date? { imageAsset.creationDate } - public init(imageAsset: ImageAsset, foodProbability: Float) { + public init(imageAsset: any ImageAssetable, foodProbability: Float) { self.imageAsset = imageAsset self.foodProbability = foodProbability } diff --git a/FoodDiary/Domain/Sources/Entity/Insight.swift b/FoodDiary/Domain/Sources/Entity/Insight.swift new file mode 100644 index 00000000..2547061e --- /dev/null +++ b/FoodDiary/Domain/Sources/Entity/Insight.swift @@ -0,0 +1,146 @@ +// +// Insight.swift +// Domain +// + +import Foundation + +public struct Insight: Equatable, Sendable { + public let month: String + public let photoStats: PhotoStats + public let categoryStats: CategoryStats + public let diaryTimeStats: DiaryTimeStats + public let locationStats: [LocationStat] + public let tagStats: [KeywordStat] + public let weeklyStats: WeeklyStats + + public init( + month: String, + photoStats: PhotoStats, + categoryStats: CategoryStats, + diaryTimeStats: DiaryTimeStats, + locationStats: [LocationStat], + tagStats: [KeywordStat], + weeklyStats: WeeklyStats + ) { + self.month = month + self.photoStats = photoStats + self.categoryStats = categoryStats + self.diaryTimeStats = diaryTimeStats + self.locationStats = locationStats + self.tagStats = tagStats + self.weeklyStats = weeklyStats + } +} + +public struct PhotoStats: Equatable, Sendable { + public let currentMonthCount: Int + public let previousMonthCount: Int + public let changeRate: Int + + public init(currentMonthCount: Int, previousMonthCount: Int, changeRate: Int) { + self.currentMonthCount = currentMonthCount + self.previousMonthCount = previousMonthCount + self.changeRate = changeRate + } +} + +public struct CategoryStats: Equatable, Sendable { + public let currentMonth: CategoryStat + public let previousMonth: CategoryStat + public let currentMonthCounts: CategoryCounts + + public init(currentMonth: CategoryStat, previousMonth: CategoryStat, currentMonthCounts: CategoryCounts) { + self.currentMonth = currentMonth + self.previousMonth = previousMonth + self.currentMonthCounts = currentMonthCounts + } +} + +public struct CategoryStat: Equatable, Sendable { + public let topCategory: String + public let count: Int + + public init(topCategory: String, count: Int) { + self.topCategory = topCategory + self.count = count + } +} + +public struct CategoryCounts: Equatable, Sendable { + public let chinese: Int + public let etc: Int + public let homeCooked: Int + public let japanese: Int + public let korean: Int + public let western: Int + + public init(chinese: Int, etc: Int, homeCooked: Int, japanese: Int, korean: Int, western: Int) { + self.chinese = chinese + self.etc = etc + self.homeCooked = homeCooked + self.japanese = japanese + self.korean = korean + self.western = western + } +} + +public struct DiaryTimeStats: Equatable, Sendable { + public let mostActiveTime: String + public let distribution: [TimeCount] + + public init(mostActiveTime: String, distribution: [TimeCount]) { + self.mostActiveTime = mostActiveTime + self.distribution = distribution + } +} + +public struct TimeCount: Equatable, Sendable { + public let time: String + public let count: Int + + public init(time: String, count: Int) { + self.time = time + self.count = count + } +} + +public struct KeywordStat: Equatable, Sendable { + public let keyword: String + public let count: Int + + public init(keyword: String, count: Int) { + self.keyword = keyword + self.count = count + } +} + +public struct LocationStat: Equatable, Sendable { + public let dong: String + public let count: Int + + public init(dong: String, count: Int) { + self.dong = dong + self.count = count + } +} + +public struct WeeklyStats: Equatable, Sendable { + public let mostActiveWeek: Int + public let weeklyCounts: [WeekCount] + + public init(mostActiveWeek: Int, weeklyCounts: [WeekCount]) { + self.mostActiveWeek = mostActiveWeek + self.weeklyCounts = weeklyCounts + } +} + +public struct WeekCount: Equatable, Sendable { + public let week: Int + public let count: Int + + public init(week: Int, count: Int) { + self.week = week + self.count = count + } +} diff --git a/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift b/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift index 41fb9dcb..6a36024b 100644 --- a/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/FoodImageAssetRepository.swift @@ -8,8 +8,6 @@ import Foundation public protocol FoodImageAssetRepository: Sendable { - associatedtype Asset: ImageAssetable - /// 날짜별 음식 사진 조회 (음식 확률 순 정렬) /// - Parameters: /// - startDate: 조회 시작 날짜 (필수) @@ -18,7 +16,7 @@ public protocol FoodImageAssetRepository: Sendable { func fetchFoodImageAssets( from startDate: Date, to endDate: Date? - ) async throws -> [Date: [FoodImageAsset]] + ) async throws -> [Date: [FoodImageAsset]] /// 현재 주 + 과거 N주 범위의 사진 데이터를 백그라운드에서 미리 로드하여 캐시 워밍 /// - Parameters: diff --git a/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift b/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift index e48a5dbd..2d833abc 100644 --- a/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/FoodRecordRepository.swift @@ -7,10 +7,11 @@ import Foundation /// 음식 기록 Repository 프로토콜 (서버 API) public protocol FoodRecordRepository: Sendable { - /// 특정 날짜 범위 내의 사진 URL 조회 + /// 특정 날짜 범위 내의 사진 URL 조회 (stale-while-revalidate) + /// - 캐시가 있으면 즉시 방출 후 서버 검증, 데이터가 다를 경우 갱신 값 재방출 + /// - 캐시가 없으면 서버 응답만 방출 /// - Parameter dateRange: 조회할 날짜 범위 - /// - Returns: 날짜별 기록된 이미지 URL - func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date: [URL]] + func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> /// 특정 날짜 범위 내의 기록 조회 /// - Parameter dateRange: 조회할 날짜 범위 diff --git a/FoodDiary/Domain/Sources/Repository/InsightRepository.swift b/FoodDiary/Domain/Sources/Repository/InsightRepository.swift new file mode 100644 index 00000000..d0c439f9 --- /dev/null +++ b/FoodDiary/Domain/Sources/Repository/InsightRepository.swift @@ -0,0 +1,10 @@ +// +// InsightRepository.swift +// Domain +// + +import Foundation + +public protocol InsightRepository { + func fetchInsight() async throws -> Insight +} diff --git a/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift b/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift index 65c62978..3faf0e65 100644 --- a/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift +++ b/FoodDiary/Domain/Sources/Repository/RenderableImageRepository.swift @@ -6,20 +6,18 @@ import UIKit /// 이미지 로딩을 담당하는 Repository 프로토콜 -public protocol RenderableImageRepository: Sendable { - associatedtype Asset: ImageAssetable - +public protocol RenderableImageRepository: Sendable { /// 지정된 Asset의 이미지를 로드합니다. /// - Parameters: /// - asset: 이미지 에셋 /// - targetSize: 요청 이미지 크기 /// - preferFastDelivery: true이면 품질보다 속도를 우선합니다 (ML 전처리 등) /// - Returns: 로드된 UIImage - func loadImage(for asset: Asset, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage + func loadImage(for asset: any ImageAssetable, targetSize: CGSize, preferFastDelivery: Bool) async throws -> UIImage } extension RenderableImageRepository { - public func loadImage(for asset: Asset, targetSize: CGSize) async throws -> UIImage { + public func loadImage(for asset: any ImageAssetable, targetSize: CGSize) async throws -> UIImage { try await loadImage(for: asset, targetSize: targetSize, preferFastDelivery: false) } } diff --git a/FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift b/FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift new file mode 100644 index 00000000..22f34a73 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/CheckAppReviewEligibilityUseCase.swift @@ -0,0 +1,30 @@ +// +// CheckAppReviewEligibilityUseCase.swift +// Domain +// + +import Foundation + +public struct CheckAppReviewEligibilityUseCase { + private let analysisCountStorage: AnalysisCountStoring + private let requiredCount: Int + + public init( + analysisCountStorage: AnalysisCountStoring, + requiredCount: Int = 2 + ) { + self.analysisCountStorage = analysisCountStorage + self.requiredCount = requiredCount + } + + /// 카운트를 증가시키고, 리뷰 요청 조건 충족 여부를 반환 + public func execute() -> Bool { + guard !analysisCountStorage.hasRequestedReview() else { return false } + analysisCountStorage.incrementCount() + if analysisCountStorage.getCount() >= requiredCount { + analysisCountStorage.setReviewRequested() + return true + } + return false + } +} diff --git a/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift index a7cd42b9..9c1da5c4 100644 --- a/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/DeleteFoodRecordUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct DeleteFoodRecordUseCase: Sendable { - private let repository: Repository +public struct DeleteFoodRecordUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift index 78cd908a..66ebea69 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchFoodImageAssetUseCase.swift @@ -7,10 +7,10 @@ import Foundation -public struct FetchFoodImageAssetUseCase { - private let repository: Repository +public struct FetchFoodImageAssetUseCase { + private let repository: any FoodImageAssetRepository - public init(repository: Repository) { + public init(repository: any FoodImageAssetRepository) { self.repository = repository } @@ -18,7 +18,7 @@ public struct FetchFoodImageAssetUseCase { from startDate: Date, to endDate: Date?, priority: TaskPriority = .utility - ) async throws -> [Date: [FoodImageAsset]] { + ) async throws -> [Date: [FoodImageAsset]] { try await Task(priority: priority) { try await repository.fetchFoodImageAssets(from: startDate, to: endDate) }.value diff --git a/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift index 2204f561..abec85c5 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchFoodRecordsUseCase.swift @@ -6,10 +6,10 @@ import Foundation /// 특정 날짜의 음식 기록 조회 UseCase -public struct FetchFoodRecordsUseCase: Sendable { - private let repository: Repository +public struct FetchFoodRecordsUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift new file mode 100644 index 00000000..907c3080 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/FetchInsightUseCase.swift @@ -0,0 +1,18 @@ +// +// FetchInsightUseCase.swift +// Domain +// + +import Foundation + +public struct FetchInsightUseCase { + private let repository: any InsightRepository + + public init(repository: any InsightRepository) { + self.repository = repository + } + + public func execute() async throws -> Insight { + try await repository.fetchInsight() + } +} diff --git a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift index 80fca9ec..8b99f1fb 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchMonthlyCalendarDaysUseCase.swift @@ -6,24 +6,48 @@ import Foundation /// 월간 캘린더 데이터 조회 UseCase -public struct FetchMonthlyCalendarDaysUseCase: Sendable { - private let repository: Repository +public struct FetchMonthlyCalendarDaysUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } - public func execute(for period: DateInterval, currentMonth: Date) async throws -> [MonthlyCalendarDay] { - let calendar = Calendar.seoul - let recordsByDate = try await repository.fetchPhotoURLs(in: period.start...period.end) + public func execute(for period: DateInterval, currentMonth: Date) -> AsyncThrowingStream<[MonthlyCalendarDay], Error> { + AsyncThrowingStream { continuation in + Task { + do { + for try await recordsByDate in repository.fetchPhotoURLs(in: period.start...period.end) { + let days = generateCalendarDays( + for: period, + currentMonth: currentMonth, + calendar: .current, + recordsByDate: recordsByDate + ) + continuation.yield(days) + } + prefetchAdjacentMonths(for: currentMonth) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + private func prefetchAdjacentMonths(for date: Date) { + let calendar = Calendar.current + let neighbors = [-1, 1].compactMap { calendar.date(byAdding: .month, value: $0, to: date) } + for neighbor in neighbors { + Task { await prefetch(neighbor) } + } + } - // 캘린더 날짜 배열 생성 (records 포함) - return generateCalendarDays( - for: period, - currentMonth: currentMonth, - calendar: calendar, - recordsByDate: recordsByDate - ) + private func prefetch(_ date: Date) async { + let period = Calendar.current.monthlyCalendarPeriod(for: date) + do { + for try await _ in repository.fetchPhotoURLs(in: period.start...period.end) {} + } catch {} } // MARK: - Private Methods diff --git a/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift index b4959964..0b42d3c9 100644 --- a/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FetchUserProfileUseCase.swift @@ -7,11 +7,11 @@ import Foundation -public struct FetchUserProfileUseCase { - private let repository: Repository - private let nicknameStorage: Storage +public struct FetchUserProfileUseCase { + private let repository: any UserRepository + private let nicknameStorage: any NicknameStoring - public init(repository: Repository, nicknameStorage: Storage) { + public init(repository: any UserRepository, nicknameStorage: any NicknameStoring) { self.repository = repository self.nicknameStorage = nicknameStorage } diff --git a/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift b/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift index e5427a68..07129ee7 100644 --- a/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/FinalizeAppleLoginUseCase.swift @@ -19,6 +19,7 @@ public struct FinalizeAppleLoginUseCase { private let pushTokenStorage: PushTokenStoring private let notificationAuthorizationProvider: NotificationAuthorizationProviding private let initialLaunchStorage: InitialLaunchStoring + private let loginSession: LoginSession public init( authRepository: AuthRepository, @@ -26,7 +27,8 @@ public struct FinalizeAppleLoginUseCase { osVersion: String, pushTokenStorage: PushTokenStoring, notificationAuthorizationProvider: NotificationAuthorizationProviding, - initialLaunchStorage: InitialLaunchStoring + initialLaunchStorage: InitialLaunchStoring, + loginSession: LoginSession ) { self.authRepository = authRepository self.deviceId = deviceId @@ -34,9 +36,10 @@ public struct FinalizeAppleLoginUseCase { self.pushTokenStorage = pushTokenStorage self.notificationAuthorizationProvider = notificationAuthorizationProvider self.initialLaunchStorage = initialLaunchStorage + self.loginSession = loginSession } - public func execute(_ appleIdentityToken: String) async throws -> LoginResult { + public func execute(_ appleIdentityToken: String) async throws { let fcmToken = pushTokenStorage.get() let notificationEnabled = await notificationAuthorizationProvider.isNotificationEnabled() @@ -51,6 +54,6 @@ public struct FinalizeAppleLoginUseCase { let result = try await authRepository.login(loginRequest) initialLaunchStorage.set() - return result + loginSession.notifyLoginSuccess(result) } } diff --git a/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift index 06f474f8..7f760bb8 100644 --- a/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/LoadWeeklyRecordUseCase.swift @@ -6,10 +6,7 @@ import Foundation /// 주간 캘린더 데이터 로딩을 담당하는 UseCase -public struct LoadWeeklyRecordUseCase< - RecordRepo: FoodRecordRepository, - AssetRepo: FoodImageAssetRepository ->: Sendable { +public struct LoadWeeklyRecordUseCase: Sendable { public struct WeekData: Sendable { public let weekDays: [WeeklyCalendarDay] public let monthText: String @@ -21,11 +18,11 @@ public struct LoadWeeklyRecordUseCase< } public struct DateData: Sendable { - public let photos: [FoodImageAsset] + public let photos: [FoodImageAsset] public let records: [FoodRecord] public let startOfDay: Date - public init(photos: [FoodImageAsset], records: [FoodRecord], startOfDay: Date) { + public init(photos: [FoodImageAsset], records: [FoodRecord], startOfDay: Date) { self.photos = photos self.records = records self.startOfDay = startOfDay @@ -33,13 +30,13 @@ public struct LoadWeeklyRecordUseCase< } private let calendar: Calendar - private let recordRepository: RecordRepo - private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase + private let recordRepository: any FoodRecordRepository + private let fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase public init( calendar: Calendar, - recordRepository: RecordRepo, - fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase + recordRepository: any FoodRecordRepository, + fetchFoodImageAssetUseCase: FetchFoodImageAssetUseCase ) { self.calendar = calendar self.recordRepository = recordRepository @@ -75,7 +72,7 @@ public struct LoadWeeklyRecordUseCase< } /// 특정 날짜의 사진만 로드 - public func loadPhotos(for date: Date) async throws -> [FoodImageAsset] { + public func loadPhotos(for date: Date) async throws -> [FoodImageAsset] { let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) diff --git a/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift b/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift index b6c797f2..a24cf296 100644 --- a/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/LogoutUseCase.swift @@ -9,15 +9,19 @@ import Foundation public struct LogoutUseCase { private let authRepository: AuthRepository + private let loginSession: LoginSession - public init(authRepository: AuthRepository) { + public init(authRepository: AuthRepository, loginSession: LoginSession) { self.authRepository = authRepository + self.loginSession = loginSession } /// 로그아웃을 수행합니다. /// - /// AuthTokenStorage를 clear하여 저장된 액세스 토큰을 제거합니다. + /// AuthTokenStorage를 clear하여 저장된 액세스 토큰을 제거하고, + /// LoginSession을 통해 로그아웃 이벤트를 전달합니다. public func execute() throws { try authRepository.logout() + loginSession.notifyLogout() } } diff --git a/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift b/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift index f60a2eef..d7d43ae9 100644 --- a/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/RequestPhotoAuthorizationUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct RequestPhotoAuthorizationUseCase { - private let repository: Repository +public struct RequestPhotoAuthorizationUseCase { + private let repository: any PhotoAuthorizationRepository - public init(repository: Repository) { + public init(repository: any PhotoAuthorizationRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift index e874de84..082fca04 100644 --- a/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift @@ -7,13 +7,11 @@ import Foundation /// Asset을 서버에 업로드하는 UseCase /// 분석 결과는 Remote Push로 수신 -public struct SaveFoodRecordUseCase< - RecordRepo: FoodRecordRepository ->: Sendable { - private let repository: RecordRepo +public struct SaveFoodRecordUseCase: Sendable { + private let repository: any FoodRecordRepository public init( - repository: RecordRepo + repository: any FoodRecordRepository ) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift b/FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift new file mode 100644 index 00000000..c8731e95 --- /dev/null +++ b/FoodDiary/Domain/Sources/UseCase/ScheduleDailyReminderUseCase.swift @@ -0,0 +1,27 @@ +// +// ScheduleDailyReminderUseCase.swift +// Domain +// + +import Foundation + +public struct ScheduleDailyReminderUseCase: Sendable { + private let scheduler: any LocalNotificationScheduling + private let authorizationProvider: any NotificationAuthorizationProviding + + public init( + scheduler: any LocalNotificationScheduling, + authorizationProvider: any NotificationAuthorizationProviding + ) { + self.scheduler = scheduler + self.authorizationProvider = authorizationProvider + } + + public func execute() async { + if await authorizationProvider.isNotificationEnabled() { + await scheduler.scheduleDailyReminder(hour: 21, minute: 0) + } else { + await scheduler.cancelDailyReminder() + } + } +} diff --git a/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift b/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift index 16d7c4c4..1fe66eda 100644 --- a/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/SearchAddressUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct SearchAddressUseCase: Sendable { - private let repository: Repository +public struct SearchAddressUseCase: Sendable { + private let repository: any AddressSearchRepository - public init(repository: Repository) { + public init(repository: any AddressSearchRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift index fbcaa1aa..671e8f55 100644 --- a/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/UpdateFoodRecordUseCase.swift @@ -5,10 +5,10 @@ import Foundation -public struct UpdateFoodRecordUseCase: Sendable { - private let repository: Repository +public struct UpdateFoodRecordUseCase: Sendable { + private let repository: any FoodRecordRepository - public init(repository: Repository) { + public init(repository: any FoodRecordRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift b/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift index 5ae70946..b59e9e6a 100644 --- a/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/ValidateAccessTokenUseCase.swift @@ -7,10 +7,10 @@ import Foundation -public struct ValidateAccessTokenUseCase { - private let repository: Repository - - public init(repository: Repository) { +public struct ValidateAccessTokenUseCase { + private let repository: any TokenRepository + + public init(repository: any TokenRepository) { self.repository = repository } diff --git a/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift b/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift index c315b15b..7e9c355b 100644 --- a/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/WithdrawUserUseCase.swift @@ -9,15 +9,19 @@ import Foundation public struct WithdrawUserUseCase { private let authRepository: AuthRepository + private let loginSession: LoginSession - public init(authRepository: AuthRepository) { + public init(authRepository: AuthRepository, loginSession: LoginSession) { self.authRepository = authRepository + self.loginSession = loginSession } /// 회원탈퇴를 수행합니다. /// /// 서버에 회원탈퇴 요청을 전송하고, 로컬에 저장된 토큰을 삭제합니다. + /// LoginSession을 통해 로그아웃 이벤트를 전달합니다. public func execute() async throws { try await authRepository.withdraw() + loginSession.notifyLogout() } } diff --git a/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift b/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift index 3dae8fe2..1adad6c7 100644 --- a/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift +++ b/FoodDiary/Domain/Tests/FoodImageAssetFetchUseCaseTests.swift @@ -68,7 +68,7 @@ private func createMockFoodImageAssets( startIndex: Int = 0, count: Int, probabilities: [Float] -) -> [FoodImageAsset] { +) -> [FoodImageAsset] { (0..]] = [:] - - func fetchFoodImageAssets(from startDate: Date, to endDate: Date?) async throws -> [Date: [FoodImageAsset]] { + func fetchFoodImageAssets(from startDate: Date, to endDate: Date?) async throws -> [Date: [FoodImageAsset]] { resultToReturn } diff --git a/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift b/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift index 4bbcbeb3..b3d7be45 100644 --- a/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift +++ b/FoodDiary/Domain/Tests/Mock/MockFoodRecordRepository.swift @@ -14,8 +14,19 @@ final class MockFoodRecordRepository: FoodRecordRepository, @unchecked Sendable ] var shouldThrowError: Bool = false - func fetchPhotoURLs(in dateRange: ClosedRange) async throws -> [Date : [URL]] { - return [:] + var photoURLsToReturn: [Date: [URL]] = [:] + + func fetchPhotoURLs(in dateRange: ClosedRange) -> AsyncThrowingStream<[Date: [URL]], Error> { + let result = photoURLsToReturn + let shouldThrow = shouldThrowError + return AsyncThrowingStream { continuation in + if shouldThrow { + continuation.finish(throwing: MockError.testError) + } else { + continuation.yield(result) + continuation.finish() + } + } } func fetchRecords(in dateRange: ClosedRange) async throws -> [Date: [FoodRecord]] { diff --git a/FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift b/FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift new file mode 100644 index 00000000..52904b66 --- /dev/null +++ b/FoodDiary/Domain/Tests/Mock/MockLocalNotificationScheduler.swift @@ -0,0 +1,24 @@ +// +// MockLocalNotificationScheduler.swift +// Domain +// + +@testable import Domain +import Foundation + +final class MockLocalNotificationScheduler: LocalNotificationScheduling, @unchecked Sendable { + private(set) var scheduleCallCount = 0 + private(set) var cancelCallCount = 0 + private(set) var lastScheduledHour: Int? + private(set) var lastScheduledMinute: Int? + + func scheduleDailyReminder(hour: Int, minute: Int) async { + scheduleCallCount += 1 + lastScheduledHour = hour + lastScheduledMinute = minute + } + + func cancelDailyReminder() async { + cancelCallCount += 1 + } +} diff --git a/FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift b/FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift new file mode 100644 index 00000000..801a786a --- /dev/null +++ b/FoodDiary/Domain/Tests/Mock/MockNotificationAuthorizationProvider.swift @@ -0,0 +1,15 @@ +// +// MockNotificationAuthorizationProvider.swift +// Domain +// + +@testable import Domain +import Foundation + +final class MockNotificationAuthorizationProvider: NotificationAuthorizationProviding, @unchecked Sendable { + var isEnabledToReturn: Bool = true + + func isNotificationEnabled() async -> Bool { + isEnabledToReturn + } +} diff --git a/FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift b/FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift new file mode 100644 index 00000000..ddc9823c --- /dev/null +++ b/FoodDiary/Domain/Tests/ScheduleDailyReminderUseCaseTests.swift @@ -0,0 +1,50 @@ +// +// ScheduleDailyReminderUseCaseTests.swift +// Domain +// + +@testable import Domain +import Foundation +import Testing + +@Suite("ScheduleDailyReminderUseCase Tests") +struct ScheduleDailyReminderUseCaseTests { + + @Test("알림 권한이 켜져 있으면 매일 21:00 리마인더가 등록된다") + func whenAuthorizationEnabled_schedulesDailyReminderAt21() async { + let scheduler = MockLocalNotificationScheduler() + let authProvider = MockNotificationAuthorizationProvider() + authProvider.isEnabledToReturn = true + + let sut = ScheduleDailyReminderUseCase( + scheduler: scheduler, + authorizationProvider: authProvider + ) + + await sut.execute() + + #expect(scheduler.scheduleCallCount == 1) + #expect(scheduler.cancelCallCount == 0) + #expect(scheduler.lastScheduledHour == 21) + #expect(scheduler.lastScheduledMinute == 0) + } + + @Test("알림 권한이 꺼져 있으면 리마인더가 등록되지 않고 취소된다") + func whenAuthorizationDisabled_cancelsReminderInsteadOfScheduling() async { + let scheduler = MockLocalNotificationScheduler() + let authProvider = MockNotificationAuthorizationProvider() + authProvider.isEnabledToReturn = false + + let sut = ScheduleDailyReminderUseCase( + scheduler: scheduler, + authorizationProvider: authProvider + ) + + await sut.execute() + + #expect(scheduler.scheduleCallCount == 0) + #expect(scheduler.cancelCallCount == 1) + #expect(scheduler.lastScheduledHour == nil) + #expect(scheduler.lastScheduledMinute == nil) + } +} diff --git a/FoodDiary/Presentation/Project.swift b/FoodDiary/Presentation/Project.swift index 88461f36..aaa0c48a 100644 --- a/FoodDiary/Presentation/Project.swift +++ b/FoodDiary/Presentation/Project.swift @@ -16,7 +16,7 @@ let project = Project( destinations: [.iPhone], product: .framework, bundleId: "com.fooddiary.presentation", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Sources/**"], resources: ["Resources/**"], dependencies: [ @@ -32,7 +32,7 @@ let project = Project( destinations: [.iPhone], product: .unitTests, bundleId: "com.fooddiary.presentation.tests", - deploymentTargets: .iOS("18.0"), + deploymentTargets: .iOS("26.0"), sources: ["Tests/**"], dependencies: [ .target(name: "Presentation") diff --git a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift index 4ff81883..69ef0060 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift @@ -23,15 +23,20 @@ enum AddressSearchConstants { static let resultCellHeight: CGFloat = 72 } -public final class AddressSearchViewController< - AddressRepo: AddressSearchRepository ->: UIViewController, UITextFieldDelegate { +public final class AddressSearchViewController: UIViewController, UITextFieldDelegate { // MARK: - Dependencies - private let viewModel: AddressSearchViewModel + private let viewModel: AddressSearchViewModel private let onAddressSelected: ((AddressSearchResult) -> Void)? private var cancellables = Set() + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } private var containerHeightConstraint: Constraint? // MARK: - TableView Handler @@ -107,7 +112,7 @@ public final class AddressSearchViewController< // MARK: - Init public init( - viewModel: AddressSearchViewModel, + viewModel: AddressSearchViewModel, onAddressSelected: ((AddressSearchResult) -> Void)? ) { self.viewModel = viewModel @@ -136,6 +141,13 @@ public final class AddressSearchViewController< searchTextField.becomeFirstResponder() } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isBeingDismissed || isMovingFromParent { + flowSubject.send(.finish) + } + } + public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let contentHeight = resultsTableView.contentSize.height @@ -219,7 +231,7 @@ public final class AddressSearchViewController< // MARK: - Private Methods - private func updateUI(with state: AddressSearchViewModel.State) { + private func updateUI(with state: AddressSearchViewModel.State) { let displayResults: [AddressSearchResult] switch state.mode { @@ -254,7 +266,7 @@ public final class AddressSearchViewController< } } - private func handleEvent(_ event: AddressSearchViewModel.Event) { + private func handleEvent(_ event: AddressSearchViewModel.Event) { switch event { case .addressSelected(let result): onAddressSelected?(result) diff --git a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift index 562a76d9..17ca299b 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewModel.swift @@ -7,7 +7,7 @@ import Combine import Domain import Foundation -public final class AddressSearchViewModel { +public final class AddressSearchViewModel { // MARK: - Output @@ -28,13 +28,13 @@ public final class AddressSearchViewModel private let stateSubject: CurrentValueSubject private let eventSubject = PassthroughSubject() private var cancellables = Set() - private let searchAddressUseCase: SearchAddressUseCase + private let searchAddressUseCase: SearchAddressUseCase private let diaryId: Int // MARK: - Init public init( - searchAddressUseCase: SearchAddressUseCase, + searchAddressUseCase: SearchAddressUseCase, diaryId: Int ) { self.searchAddressUseCase = searchAddressUseCase diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift new file mode 100644 index 00000000..192133c5 --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchCoordinator.swift @@ -0,0 +1,49 @@ +// +// AddressSearchCoordinator.swift +// Presentation +// + +import Combine +import Data +import UIKit + +public final class AddressSearchCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start(input: AddressSearchSceneInput) { + guard let presentingVC = navigationController?.topViewController else { return } + let vc = factories.addressSearch.makeScene(input: input) + flowBind(vc: vc) + presentingVC.present(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension AddressSearchCoordinator { + func flowBind(vc: AddressSearchViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .finish: + self?.finish() + } + } + .store(in: &cancellables) + } + + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift new file mode 100644 index 00000000..21b6e3a8 --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchFlow.swift @@ -0,0 +1,10 @@ +// +// AddressSearchFlow.swift +// Presentation +// + +import Foundation + +public enum AddressSearchFlow { + case finish +} diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift new file mode 100644 index 00000000..fb57329b --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneFactory.swift @@ -0,0 +1,29 @@ +// +// AddressSearchSceneFactory.swift +// Presentation +// + +import Data +import Domain +import UIKit + +// MARK: - Factory + +public final class AddressSearchSceneFactory { + private let searchAddressUseCase: SearchAddressUseCase + + public init(searchAddressUseCase: SearchAddressUseCase) { + self.searchAddressUseCase = searchAddressUseCase + } + + public func makeScene(input: AddressSearchSceneInput) -> AddressSearchViewController { + let viewModel = AddressSearchViewModel( + searchAddressUseCase: searchAddressUseCase, + diaryId: input.diaryId + ) + return AddressSearchViewController( + viewModel: viewModel, + onAddressSelected: input.onSelected + ) + } +} diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift new file mode 100644 index 00000000..4d0086af --- /dev/null +++ b/FoodDiary/Presentation/Sources/AddressSearch/Coordinator/AddressSearchSceneInput.swift @@ -0,0 +1,16 @@ +// +// AddressSearchSceneInput.swift +// Presentation +// + +import Domain + +public struct AddressSearchSceneInput { + public let diaryId: Int + public let onSelected: (AddressSearchResult) -> Void + + public init(diaryId: Int, onSelected: @escaping (AddressSearchResult) -> Void) { + self.diaryId = diaryId + self.onSelected = onSelected + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift index 5f857d40..731c02f5 100644 --- a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift @@ -5,10 +5,10 @@ // Created by 강대훈 on 2/13/26. // -import UIKit import Combine import DesignSystem import SnapKit +import UIKit public final class CalendarViewController: UIViewController { public enum ViewMode { @@ -21,16 +21,32 @@ public final class CalendarViewController: UIViewController { private let currentModeSubject = CurrentValueSubject(.weekly) private var currentChild: UIViewController? + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } + private var cancellables = Set() + public var currentModePublisher: AnyPublisher { currentModeSubject.eraseToAnyPublisher() } - public init(weeklyVC: UIViewController, monthlyVC: UIViewController) { + public init( + weeklyVC: UIViewController, + monthlyVC: UIViewController, + weeklyFlowPublisher: AnyPublisher, + monthlyFlowPublisher: AnyPublisher + ) { self.weeklyVC = weeklyVC self.monthlyVC = monthlyVC super.init(nibName: nil, bundle: nil) + + Publishers.Merge(weeklyFlowPublisher, monthlyFlowPublisher) + .sink { [weak self] flow in self?.flowSubject.send(flow) } + .store(in: &cancellables) } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift new file mode 100644 index 00000000..76fb93af --- /dev/null +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarCoordinator.swift @@ -0,0 +1,70 @@ +// +// CalendarCoordinator.swift +// Presentation +// + +import Combine +import UIKit + +public final class CalendarCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + public weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories) { + self.factories = factories + } + + public func configure(navigationController: UINavigationController?) { + self.navigationController = navigationController + } + + public func makeViewController() -> CalendarViewController { + let calendarVC = factories.calendar.makeScene() + flowBind(vc: calendarVC) + return calendarVC + } +} + +// MARK: - Screen Routing + +private extension CalendarCoordinator { + func flowBind(vc: CalendarViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .pushDetail(let input): + self?.pushDetail(input: input) + case .pushImagePicker(let input): + self?.pushImagePicker(input: input) + } + } + .store(in: &cancellables) + } +} + +private extension CalendarCoordinator { + func pushDetail(input: DetailSceneInput) { + let coord = DetailCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } + + func pushImagePicker(input: ImagePickerSceneInput) { + let coord = ImagePickerCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift new file mode 100644 index 00000000..0fa77c2d --- /dev/null +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarFlow.swift @@ -0,0 +1,12 @@ +// +// CalendarFlow.swift +// Presentation +// + +import Combine + +public enum CalendarFlow { + case pushDetail(DetailSceneInput) + case pushImagePicker(ImagePickerSceneInput) +} + diff --git a/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift new file mode 100644 index 00000000..0b36ebbc --- /dev/null +++ b/FoodDiary/Presentation/Sources/Calendar/Coordinator/CalendarSceneFactory.swift @@ -0,0 +1,68 @@ +// +// CalendarSceneFactory.swift +// Presentation +// + +import Data +import Domain +import UIKit + +public final class CalendarSceneFactory { + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let pushNotificationObserver: PushNotificationObserver + private let getNicknameUseCase: GetNicknameUseCase + private let coachmarkStorage: any CoachmarkStoring + private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase + private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase + private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase + + public init( + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + pushNotificationObserver: PushNotificationObserver, + getNicknameUseCase: GetNicknameUseCase, + coachmarkStorage: any CoachmarkStoring, + checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase, + fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, + fetchFoodRecordsUseCase: FetchFoodRecordsUseCase + ) { + self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase + self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase + self.saveFoodRecordUseCase = saveFoodRecordUseCase + self.pushNotificationObserver = pushNotificationObserver + self.getNicknameUseCase = getNicknameUseCase + self.coachmarkStorage = coachmarkStorage + self.checkAppReviewEligibilityUseCase = checkAppReviewEligibilityUseCase + self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase + self.fetchFoodRecordsUseCase = fetchFoodRecordsUseCase + } + + public func makeScene() -> CalendarViewController { + let weeklyVM = WeeklyCalendarViewModel( + requestPhotoAuthorizationUseCase: requestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: loadWeeklyCalendarDataUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver, + getNicknameUseCase: getNicknameUseCase, + coachmarkStorage: coachmarkStorage, + checkAppReviewEligibilityUseCase: checkAppReviewEligibilityUseCase + ) + let monthlyVM = MonthlyCalendarViewModel( + fetchMonthlyCalendarDaysUseCase: fetchMonthlyCalendarDaysUseCase, + requestPhotoAuthorizationUseCase: requestPhotoAuthorizationUseCase, + fetchFoodRecordsUseCase: fetchFoodRecordsUseCase, + getNicknameUseCase: getNicknameUseCase + ) + let weeklyVC = WeeklyCalendarViewController(viewModel: weeklyVM) + let monthlyVC = MonthlyCalendarViewController(viewModel: monthlyVM) + return CalendarViewController( + weeklyVC: weeklyVC, + monthlyVC: monthlyVC, + weeklyFlowPublisher: weeklyVC.flowPublisher, + monthlyFlowPublisher: monthlyVC.flowPublisher + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift index 2a4fe246..3ab1254b 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/Components/MonthlyCalendarDayCell.swift @@ -57,6 +57,12 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { return view }() + private lazy var gradientBackgroundView: GradientBackgroundView = { + let view = GradientBackgroundView(cornerRadius: Constants.cornerRadius) + view.isHidden = true + return view + }() + // MARK: - Init override init(frame: CGRect) { @@ -82,6 +88,7 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { dashedBorderView.layer.cornerRadius = Constants.cornerRadius contentView.addSubview(containerView) + containerView.addSubview(gradientBackgroundView) containerView.addSubview(stackView) } @@ -103,6 +110,10 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { polaroidImageCardsView.snp.makeConstraints { $0.height.equalTo(polaroidImageCardsView.snp.width) } + + gradientBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } } // MARK: - Configuration @@ -134,10 +145,11 @@ final class MonthlyCalendarDayCell: UICollectionViewCell { dashedBorderView.isHidden = false polaroidImageCardsView.isHidden = true polaroidImageCardsView.alpha = 1 + gradientBackgroundView.isHidden = true } private func applyTodayStyle() { - containerView.backgroundColor = .primary + gradientBackgroundView.isHidden = false containerView.layer.borderWidth = Constants.todayBorderWidth containerView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor containerView.applyGlow( diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index 8930e368..b1832561 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -4,15 +4,13 @@ // import Combine +import Data import DesignSystem import Domain import SnapKit import UIKit -public final class MonthlyCalendarViewController< - RecordRepo: FoodRecordRepository, - AuthRepo: PhotoAuthorizationRepository ->: UIViewController, UICollectionViewDelegate { +public final class MonthlyCalendarViewController: UIViewController, UICollectionViewDelegate { private enum Section: Hashable { case calendar @@ -20,8 +18,14 @@ public final class MonthlyCalendarViewController< // MARK: - Dependencies - private let viewModel: MonthlyCalendarViewModel - private let detailViewControllerFactory: ((Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController) + private let viewModel: MonthlyCalendarViewModel + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } // MARK: - UI Components @@ -68,11 +72,9 @@ public final class MonthlyCalendarViewController< // MARK: - Init public init( - viewModel: MonthlyCalendarViewModel, - detailViewControllerFactory: @escaping (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController + viewModel: MonthlyCalendarViewModel ) { self.viewModel = viewModel - self.detailViewControllerFactory = detailViewControllerFactory super.init(nibName: nil, bundle: nil) } @@ -115,6 +117,8 @@ public final class MonthlyCalendarViewController< view.addSubview(containerView) containerView.addSubview(stackView) + setupSwipeGestures() + let mypageButton = UIBarButtonItem( image: DesignSystemAsset.iconMypage.image, style: .plain, @@ -240,6 +244,27 @@ public final class MonthlyCalendarViewController< .store(in: &cancellables) } + private func setupSwipeGestures() { + let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) + swipeLeft.direction = .left + containerView.addGestureRecognizer(swipeLeft) + + let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:))) + swipeRight.direction = .right + containerView.addGestureRecognizer(swipeRight) + } + + @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + let calendar = Calendar.current + let offset = gesture.direction == .left ? 1 : -1 + guard let newDate = calendar.date( + byAdding: .month, + value: offset, + to: viewModel.state.currentDisplayDate + ) else { return } + viewModel.input.send(.selectMonth(newDate)) + } + // MARK: - Private Methods private func updateCalendar(days: [MonthlyCalendarDay], numberOfWeeks: Int) { @@ -300,11 +325,14 @@ public final class MonthlyCalendarViewController< // MARK: - Navigation private func navigateToDetail(date: Date, records: [FoodRecord]) { - let detailVC = detailViewControllerFactory(date, records, nil, false) { [weak self] date in - self?.viewModel.input.send(.updateMonth(date)) - } - detailVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(detailVC, animated: true) + let input = DetailSceneInput( + date: date, + records: records, + onDismissWithDate: { [weak self] date in + self?.viewModel.input.send(.updateMonth(date)) + } + ) + flowSubject.send(.pushDetail(input)) } private func showErrorAlert(_ error: Error) { @@ -314,6 +342,7 @@ public final class MonthlyCalendarViewController< } } + // MARK: - Constants extension MonthlyCalendarViewController { diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 27c60797..2dca5981 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -7,10 +7,7 @@ import Combine import Domain import Foundation -public final class MonthlyCalendarViewModel< - RecordRepo: FoodRecordRepository, - AuthRepo: PhotoAuthorizationRepository -> { +public final class MonthlyCalendarViewModel { // MARK: - Output var statePublisher: AnyPublisher { @@ -37,20 +34,21 @@ public final class MonthlyCalendarViewModel< private let stateSubject: CurrentValueSubject private let eventSubject = PassthroughSubject() private var cancellables = Set() + @MainActor private var currentLoadTask: Task? // MARK: - Dependencies - private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase - private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase - private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase + private let fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let fetchFoodRecordsUseCase: FetchFoodRecordsUseCase private let getNicknameUseCase: GetNicknameUseCase // MARK: - Init public init( - fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, - requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, + fetchMonthlyCalendarDaysUseCase: FetchMonthlyCalendarDaysUseCase, + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + fetchFoodRecordsUseCase: FetchFoodRecordsUseCase, getNicknameUseCase: GetNicknameUseCase ) { self.fetchMonthlyCalendarDaysUseCase = fetchMonthlyCalendarDaysUseCase @@ -95,11 +93,17 @@ public final class MonthlyCalendarViewModel< case .loadInitialData: await requestPhotoAuthorizationIfNeeded() - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) case .selectMonth(let date): + let calendar = Calendar.current + let newComponents = calendar.dateComponents([.year, .month], from: date) + let todayComponents = calendar.dateComponents([.year, .month], from: Date()) + guard let newYearMonth = calendar.date(from: newComponents), + let todayYearMonth = calendar.date(from: todayComponents), + newYearMonth <= todayYearMonth else { return } state.currentDisplayDate = date - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) case .selectDay(let date): do { @@ -110,32 +114,44 @@ public final class MonthlyCalendarViewModel< } case .refreshCurrentMonth: - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) case .updateMonth(let date): let calendar = Calendar.current if calendar.component(.year, from: date) != calendar.component(.year, from: state.currentDisplayDate) || calendar.component(.month, from: date) != calendar.component(.month, from: state.currentDisplayDate) { - await loadMonth(for: date) + startLoadMonth(for: date) } else { - await loadMonth(for: state.currentDisplayDate) + startLoadMonth(for: state.currentDisplayDate) } } } // MARK: - Private Methods + @MainActor + private func startLoadMonth(for date: Date) { + currentLoadTask?.cancel() + currentLoadTask = nil + currentLoadTask = Task { + await loadMonth(for: date) + } + } + @MainActor private func loadMonth(for date: Date) async { state.currentDisplayDate = date state.monthYearText = date.formatMonthText() let period = Calendar.current.monthlyCalendarPeriod(for: date) - + do { - let monthDays = try await fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) - state.monthDays = monthDays - state.numberOfWeeks = monthDays.count / 7 + for try await monthDays in fetchMonthlyCalendarDaysUseCase.execute(for: period, currentMonth: date) { + state.monthDays = monthDays + state.numberOfWeeks = monthDays.count / 7 + } + } catch is CancellationError { + print("Task Cancelled") } catch { print("Failed to load monthly calendar: \(error)") } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift index ca5bcc3a..7533aa77 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/DayCellView.swift @@ -27,6 +27,12 @@ final class DayCellView: UIView { return view }() + private lazy var gradientBackgroundView: GradientBackgroundView = { + let view = GradientBackgroundView(cornerRadius: Constants.cornerRadius) + view.isHidden = true + return view + }() + private let dayOfWeekLabel: UILabel = { let label = UILabel() label.textAlignment = .center @@ -64,12 +70,17 @@ final class DayCellView: UIView { private func setupUI() { addSubview(containerView) + containerView.addSubview(gradientBackgroundView) containerView.addSubview(dayOfWeekLabel) containerView.addSubview(dayNumberLabel) containerView.addSubview(recordIndicator) containerView.snp.makeConstraints { $0.edges.equalToSuperview().inset(2) } + gradientBackgroundView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + recordIndicator.snp.makeConstraints { $0.top.equalToSuperview().offset(8) $0.centerX.equalToSuperview() @@ -109,9 +120,12 @@ final class DayCellView: UIView { } private func applyStyle(dayOfWeek: String, dayNumber: String, isToday: Bool, isFuture: Bool, isSelected: Bool) { + containerView.backgroundColor = .clear + containerView.layer.cornerRadius = Constants.cornerRadius + if isSelected { - containerView.backgroundColor = DesignSystemAsset.primary.color - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = false + gradientBackgroundView.alpha = 1 containerView.applyGlow( glowColor: .primary, borderColor: UIColor.white.withAlphaComponent(0.3), @@ -121,22 +135,20 @@ final class DayCellView: UIView { dayNumberLabel.setText(dayNumber, style: .p12, color: .white) recordIndicator.backgroundColor = .white } else if isFuture { - containerView.backgroundColor = .clear - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = true containerView.removeGlow() dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray700) dayNumberLabel.setText(dayNumber, style: .p12, color: .gray700) recordIndicator.isHidden = true } else if isToday { - containerView.backgroundColor = DesignSystemAsset.primary.color.withAlphaComponent(0.2) - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = false + gradientBackgroundView.alpha = 0.2 containerView.removeGlow() dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray300) dayNumberLabel.setText(dayNumber, style: .p12, color: .white) recordIndicator.backgroundColor = DesignSystemAsset.primary.color } else { - containerView.backgroundColor = .clear - containerView.layer.cornerRadius = Constants.cornerRadius + gradientBackgroundView.isHidden = true containerView.removeGlow() dayOfWeekLabel.setText(dayOfWeek, style: .p12, color: .gray300) dayNumberLabel.setText(dayNumber, style: .p12, color: .white) diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift index 049afd1f..ed512f84 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/Components/WeeklyCalendarHeaderView.swift @@ -17,6 +17,9 @@ final class WeeklyCalendarHeaderView: UIView { static let headerHeight: CGFloat = 44 static let navigationSpacing: CGFloat = 8 static let buttonSize: CGFloat = 44 + static let todayButtonSpacing: CGFloat = 8 + static let todayButtonCornerRadius: CGFloat = 12 + static let todayButtonInsets = UIEdgeInsets(top: 4, left: 10, bottom: 4, right: 10) } // MARK: - Publishers @@ -29,8 +32,13 @@ final class WeeklyCalendarHeaderView: UIView { nextTapSubject.eraseToAnyPublisher() } + var todayTapPublisher: AnyPublisher { + todayTapSubject.eraseToAnyPublisher() + } + private let previousTapSubject = PassthroughSubject() private let nextTapSubject = PassthroughSubject() + private let todayTapSubject = PassthroughSubject() // MARK: - UI Components @@ -41,6 +49,20 @@ final class WeeklyCalendarHeaderView: UIView { return label }() + private let todayButton: UIButton = { + let button = UIButton() + button.setAttributedTitle( + Typography.p12.styled("오늘", color: DesignSystemAsset.primary.color), + for: .normal + ) + button.contentEdgeInsets = Constants.todayButtonInsets + button.layer.cornerRadius = Constants.todayButtonCornerRadius + button.layer.borderWidth = 1 + button.layer.borderColor = DesignSystemAsset.primary.color.cgColor + button.isHidden = true + return button + }() + private let previousButton: UIButton = { let button = UIButton() button.setImage(DesignSystemAsset.iconNext.image.withHorizontallyFlippedOrientation(), for: .normal) @@ -80,6 +102,7 @@ final class WeeklyCalendarHeaderView: UIView { private func setupUI() { addSubview(monthLabel) + addSubview(todayButton) addSubview(navigationStack) navigationStack.addArrangedSubview(previousButton) navigationStack.addArrangedSubview(nextButton) @@ -95,6 +118,11 @@ final class WeeklyCalendarHeaderView: UIView { $0.centerY.equalToSuperview() } + todayButton.snp.makeConstraints { + $0.leading.equalTo(monthLabel.snp.trailing).offset(Constants.todayButtonSpacing) + $0.centerY.equalToSuperview() + } + navigationStack.snp.makeConstraints { $0.trailing.equalToSuperview() $0.centerY.equalToSuperview() @@ -112,6 +140,7 @@ final class WeeklyCalendarHeaderView: UIView { private func setupActions() { previousButton.addTarget(self, action: #selector(previousTapped), for: .touchUpInside) nextButton.addTarget(self, action: #selector(nextTapped), for: .touchUpInside) + todayButton.addTarget(self, action: #selector(todayTapped), for: .touchUpInside) } // MARK: - Public Methods @@ -125,6 +154,10 @@ final class WeeklyCalendarHeaderView: UIView { nextButton.alpha = isEnabled ? 1.0 : 0.3 } + func setTodayButtonHidden(_ isHidden: Bool) { + todayButton.isHidden = isHidden + } + // MARK: - Actions @objc private func previousTapped() { @@ -134,4 +167,8 @@ final class WeeklyCalendarHeaderView: UIView { @objc private func nextTapped() { nextTapSubject.send() } + + @objc private func todayTapped() { + todayTapSubject.send() + } } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index 5ea07ba5..ca15e09f 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -10,12 +10,7 @@ import UIKit // MARK: - ViewModel -public final class WeeklyCalendarViewModel< - RecordRepo: FoodRecordRepository, - AssetRepo: FoodImageAssetRepository, - AuthRepo: PhotoAuthorizationRepository, - PushObserver: PushNotificationObserving -> { +public final class WeeklyCalendarViewModel { // MARK: - Output public var statePublisher: AnyPublisher { @@ -45,26 +40,32 @@ public final class WeeklyCalendarViewModel< // MARK: - Dependencies - private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase - private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase - private let saveFoodRecordUseCase: SaveFoodRecordUseCase - private let pushNotificationObserver: PushObserver + private let requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase + private let loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let pushNotificationObserver: any PushNotificationObserving private let getNicknameUseCase: GetNicknameUseCase + private let coachmarkStorage: any CoachmarkStoring + private let checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase // MARK: - Init public init( - requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, - loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, - saveFoodRecordUseCase: SaveFoodRecordUseCase, - pushNotificationObserver: PushObserver, - getNicknameUseCase: GetNicknameUseCase + requestPhotoAuthorizationUseCase: RequestPhotoAuthorizationUseCase, + loadWeeklyCalendarDataUseCase: LoadWeeklyRecordUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + pushNotificationObserver: any PushNotificationObserving, + getNicknameUseCase: GetNicknameUseCase, + coachmarkStorage: any CoachmarkStoring, + checkAppReviewEligibilityUseCase: CheckAppReviewEligibilityUseCase ) { self.requestPhotoAuthorizationUseCase = requestPhotoAuthorizationUseCase self.loadWeeklyCalendarDataUseCase = loadWeeklyCalendarDataUseCase self.saveFoodRecordUseCase = saveFoodRecordUseCase self.pushNotificationObserver = pushNotificationObserver self.getNicknameUseCase = getNicknameUseCase + self.coachmarkStorage = coachmarkStorage + self.checkAppReviewEligibilityUseCase = checkAppReviewEligibilityUseCase let cal = Calendar.current self.calendar = cal @@ -146,6 +147,13 @@ public final class WeeklyCalendarViewModel< await loadWeekData(for: currentWeekBaseDate) await updateDateContent(for: state.selectedDate) + case .goToToday: + let today = calendar.startOfDay(for: Date()) + currentWeekBaseDate = today + state.selectedDate = today + await loadWeekData(for: currentWeekBaseDate) + await updateDateContent(for: today) + case .selectDate(let date): if !calendar.isDate(state.selectedDate, inSameDayAs: date) { state.selectedDate = date @@ -166,6 +174,15 @@ public final class WeeklyCalendarViewModel< } await loadWeekData(for: currentWeekBaseDate) await updateDateContent(for: state.selectedDate) + + case .viewDidAppear: + if !coachmarkStorage.get() { + state.shouldShowCoachmark = true + } + + case .dismissCoachmark: + coachmarkStorage.set() + state.shouldShowCoachmark = false } } @@ -219,7 +236,7 @@ public final class WeeklyCalendarViewModel< } @MainActor - private func savePhotosAsRecord(_ assets: [AssetRepo.Asset]) async { + private func savePhotosAsRecord(_ assets: [any ImageAssetable]) async { guard !assets.isEmpty else { return } do { @@ -293,6 +310,10 @@ public final class WeeklyCalendarViewModel< if notificationDate == selectedDate { await updateDateContent(for: state.selectedDate) } + + if checkAppReviewEligibilityUseCase.execute() { + eventSubject.send(.requestAppReview) + } } } @@ -308,6 +329,7 @@ extension WeeklyCalendarViewModel { public internal(set) var dateContent: DateContent? public internal(set) var nickname: String? = nil public internal(set) var canGoToNextWeek: Bool = false + public internal(set) var shouldShowCoachmark: Bool = false } public enum Input { @@ -316,10 +338,13 @@ extension WeeklyCalendarViewModel { case requestPhotoAuthorization case goToPreviousWeek case goToNextWeek + case goToToday case selectDate(Date) - case saveSelectedPhotos([AssetRepo.Asset]) + case saveSelectedPhotos([any ImageAssetable]) case handlePushNotification(AnalysisResultNotification) case refreshData(Date? = nil) + case viewDidAppear + case dismissCoachmark } public enum Event { @@ -327,6 +352,7 @@ extension WeeklyCalendarViewModel { case uploadCompleted(date: Date, mealType: MealType) case saveFailed(Error) case loadFailed(Error) + case requestAppReview } } @@ -352,8 +378,4 @@ extension WeeklyCalendarViewModel { return nextWeekStart <= today } - /// 특정 날짜의 사진 로드 - public func photos(for date: Date) async throws -> [FoodImageAsset] { - try await loadWeeklyCalendarDataUseCase.loadPhotos(for: date) - } } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index 3eacdb34..8da63cf8 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -7,28 +7,25 @@ import Combine import DesignSystem import Domain import SnapKit +import StoreKit import UIKit private enum Constants { static let horizontalInset: CGFloat = 20 } -public final class WeeklyCalendarViewController< - RecordRepo: FoodRecordRepository, - AssetRepo: FoodImageAssetRepository, - AuthRepo: PhotoAuthorizationRepository, - ImageProvider: RenderableImageRepository, - PushObserver: PushNotificationObserving ->: UIViewController where ImageProvider.Asset == AssetRepo.Asset { +public final class WeeklyCalendarViewController: UIViewController { // MARK: - Dependencies - private let viewModel: - WeeklyCalendarViewModel< - RecordRepo, AssetRepo, AuthRepo, PushObserver - > - private let imageProvider: ImageProvider - private let detailViewControllerFactory: (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController + private let viewModel: WeeklyCalendarViewModel + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } // MARK: - UI Components @@ -52,30 +49,16 @@ public final class WeeklyCalendarViewController< return sv }() - private let imagePickerLoadingView: UIActivityIndicatorView = { - let indicator = UIActivityIndicatorView(style: .medium) - indicator.color = .gray400 - indicator.hidesWhenStopped = true - return indicator - }() - // MARK: - State private var cancellables = Set() - private var isLoadingImagePicker = false // MARK: - Init public init( - viewModel: WeeklyCalendarViewModel< - RecordRepo, AssetRepo, AuthRepo, PushObserver - >, - imageProvider: ImageProvider, - detailViewControllerFactory: @escaping (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController + viewModel: WeeklyCalendarViewModel ) { self.viewModel = viewModel - self.imageProvider = imageProvider - self.detailViewControllerFactory = detailViewControllerFactory super.init(nibName: nil, bundle: nil) } @@ -95,6 +78,11 @@ public final class WeeklyCalendarViewController< viewModel.input.send(.loadInitialData) } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.input.send(.viewDidAppear) + } + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) } @@ -107,7 +95,6 @@ public final class WeeklyCalendarViewController< view.addSubview(recordPromptHeaderView) view.addSubview(headerView) view.addSubview(containerStackView) - view.addSubview(imagePickerLoadingView) } private func setupConstraints() { @@ -126,7 +113,6 @@ public final class WeeklyCalendarViewController< $0.leading.trailing.equalToSuperview().inset(Constants.horizontalInset) $0.bottom.equalTo(view.safeAreaLayoutGuide).offset(-34) } - } private func setupBindings() { @@ -143,6 +129,12 @@ public final class WeeklyCalendarViewController< } .store(in: &cancellables) + headerView.todayTapPublisher + .sink { [weak self] in + self?.viewModel.input.send(.goToToday) + } + .store(in: &cancellables) + weekGridView.dateTapPublisher .sink { [weak self] date in self?.viewModel.input.send(.selectDate(date)) @@ -183,6 +175,15 @@ public final class WeeklyCalendarViewController< } .store(in: &cancellables) + viewModel.statePublisher + .map(\.selectedDate) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] date in + self?.headerView.setTodayButtonHidden(Calendar.current.isDateInToday(date)) + } + .store(in: &cancellables) + viewModel.statePublisher .map(\.canGoToNextWeek) .removeDuplicates() @@ -234,6 +235,15 @@ public final class WeeklyCalendarViewController< } .store(in: &cancellables) + // 코치마크 표시 + viewModel.statePublisher + .map(\.shouldShowCoachmark) + .removeDuplicates() + .filter { $0 } + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.showCoachmarkOverlay() } + .store(in: &cancellables) + // Event: 권한 거부 시 설정 이동 안내 Alert 표시 및 저장 결과 처리 viewModel.eventPublisher .receive(on: DispatchQueue.main) @@ -247,31 +257,47 @@ public final class WeeklyCalendarViewController< self?.showSaveErrorAlert(error) case .loadFailed(let error): self?.showLoadErrorAlert(error) + case .requestAppReview: + self?.requestAppReview() } } .store(in: &cancellables) } - // MARK: - Image Picker Loading + // MARK: - Coachmark + + private func showCoachmarkOverlay() { + let overlay = CoachmarkOverlayView() + view.addSubview(overlay) + overlay.snp.makeConstraints { $0.edges.equalToSuperview() } + overlay.alpha = 0 + UIView.animate(withDuration: 0.3) { overlay.alpha = 1 } - private func setImagePickerLoading(_ isLoading: Bool) { - isLoadingImagePicker = isLoading - bottomContentView.isUserInteractionEnabled = !isLoading - bottomContentView.alpha = isLoading ? 0.5 : 1.0 + overlay.didDismissPublisher + .receive(on: DispatchQueue.main) + .first() + .sink { [weak self, weak overlay] _ in + UIView.animate( + withDuration: 0.3, + animations: { overlay?.alpha = 0 }, + completion: { _ in + overlay?.removeFromSuperview() + self?.viewModel.input.send(.dismissCoachmark) + } + ) + } + .store(in: &cancellables) } // MARK: - Actions private func handleAddButtonTap() { - guard !isLoadingImagePicker else { return } - if viewModel.checkPhotoAuthorizationForAddingPhoto() { - Task { - await presentImagePicker() + let date = viewModel.state.selectedDate + flowSubject.send(.pushImagePicker( + ImagePickerSceneInput(date: date) { [weak self] assets in + self?.viewModel.input.send(.saveSelectedPhotos(assets)) } - } else { - // 권한이 없으면 재요청 (notDetermined면 시스템 다이얼로그, 아니면 denied 이벤트 발생) - viewModel.input.send(.requestPhotoAuthorization) - } + )) } private func showPhotoAuthorizationDeniedAlert() { @@ -294,50 +320,22 @@ public final class WeeklyCalendarViewController< // MARK: - Navigation - @MainActor - private func presentImagePicker() async { - setImagePickerLoading(true) - defer { setImagePickerLoading(false) } - - let selectedDate = viewModel.state.selectedDate - - do { - let foodImageAssets = try await viewModel.photos(for: selectedDate) - let selectedPhotos = foodImageAssets.map { $0.imageAsset } - - // 음식 확률 0.6 이상인 사진 ID를 미리 선택 - let preselectedFoodPhotoIds = Set( - foodImageAssets - .filter { $0.foodProbability >= 0.6 } - .map { $0.id } - ) - - let picker = ImagePickerViewController( - photos: selectedPhotos, - preselectedFoodPhotoIds: preselectedFoodPhotoIds, - imageProvider: imageProvider, - configuration: .withMaxSelectionCount(10) - ) - - picker.resultPublisher - .sink { [weak self] result in - self?.handleImagePickerResult(result) - } - .store(in: &cancellables) - - navigationController?.pushViewController(picker, animated: true) - } catch { - showLoadErrorAlert(error) - } - } - - private func handleImagePickerResult(_ result: ImagePickerResult) { - switch result { - case .selected(let assets): - viewModel.input.send(.saveSelectedPhotos(assets)) - case .cancelled: - navigationController?.popViewController(animated: true) - } + private func navigateToDetail( + for date: Date, + scrollTo mealType: MealType? = nil, + shouldPopToRoot: Bool = false + ) { + let records = viewModel.state.weekDays.records(for: date) + let input = DetailSceneInput( + date: date, + records: records, + scrollToMealType: mealType, + shouldPopToRoot: shouldPopToRoot, + onDismissWithDate: { [weak self] date in + self?.viewModel.input.send(.refreshData(date)) + } + ) + flowSubject.send(.pushDetail(input)) } private func showLoadErrorAlert(_ error: Error) { @@ -360,13 +358,9 @@ public final class WeeklyCalendarViewController< present(alert, animated: true) } - private func navigateToDetail(for date: Date, scrollTo mealType: MealType? = nil, shouldPopToRoot: Bool = false) { - let records = viewModel.state.weekDays.records(for: date) - let detailVC = detailViewControllerFactory(date, records, mealType, shouldPopToRoot) { [weak self] date in - self?.viewModel.input.send(.refreshData(date)) - } - navigationController?.pushViewController(detailVC, animated: true) + private func requestAppReview() { + guard let windowScene = view.window?.windowScene else { return } + AppStore.requestReview(in: windowScene) } - - } + diff --git a/FoodDiary/Presentation/Sources/Core/Coordinator.swift b/FoodDiary/Presentation/Sources/Core/Coordinator.swift new file mode 100644 index 00000000..a60ed151 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/Coordinator.swift @@ -0,0 +1,24 @@ +// +// Coordinator.swift +// Presentation +// + +import UIKit + +public protocol Coordinator: AnyObject { + var childCoordinators: [any Coordinator] { get set } +} + +public extension Coordinator { + func addChild(_ coordinator: any Coordinator) { + childCoordinators.append(coordinator) + } + + func removeChild(_ coordinator: any Coordinator) { + childCoordinators = childCoordinators.filter { $0 !== coordinator } + } +} + +public protocol SceneTransitioning: AnyObject { + func transition(to viewController: UIViewController) +} diff --git a/FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift b/FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift new file mode 100644 index 00000000..c1200b07 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/GradientBackgroundView.swift @@ -0,0 +1,34 @@ +// +// GradientBackgroundView.swift +// Presentation +// + +import DesignSystem +import UIKit + +final class GradientBackgroundView: UIView { + + private let gradientLayer = CAGradientLayer() + + init(cornerRadius: CGFloat) { + super.init(frame: .zero) + gradientLayer.colors = [ + DesignSystemAsset.primary.color.cgColor, + DesignSystemAsset.primaryLight.color.cgColor, + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + gradientLayer.cornerRadius = cornerRadius + layer.insertSublayer(gradientLayer, at: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} diff --git a/FoodDiary/Presentation/Sources/Core/SceneFactories.swift b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift new file mode 100644 index 00000000..b443cf90 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/SceneFactories.swift @@ -0,0 +1,40 @@ +// +// SceneProducing.swift +// Presentation +// + +import UIKit + +public struct Factories { + public let login: LoginSceneFactory + public let calendar: CalendarSceneFactory + public let insight: InsightSceneFactory + public let myPage: MyPageSceneFactory + public let detail: DetailSceneFactory + public let imagePicker: ImagePickerSceneFactory + public let edit: EditSceneFactory + public let addressSearch: AddressSearchSceneFactory + public let permission: PermissionSceneFactory + + public init( + login: LoginSceneFactory, + calendar: CalendarSceneFactory, + insight: InsightSceneFactory, + myPage: MyPageSceneFactory, + detail: DetailSceneFactory, + imagePicker: ImagePickerSceneFactory, + edit: EditSceneFactory, + addressSearch: AddressSearchSceneFactory, + permission: PermissionSceneFactory + ) { + self.login = login + self.calendar = calendar + self.insight = insight + self.myPage = myPage + self.detail = detail + self.imagePicker = imagePicker + self.edit = edit + self.addressSearch = addressSearch + self.permission = permission + } +} diff --git a/FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift b/FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift new file mode 100644 index 00000000..ee41526f --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/Transition/DefaultViewTransitionHandler.swift @@ -0,0 +1,38 @@ +// +// DefaultViewTransitionHandler.swift +// Presentation +// + +import SnapKit +import UIKit + +public final class DefaultViewTransitionHandler: ViewTransitionHandling { + private weak var currentChild: UIViewController? + + public init() {} + + public func transition(from parent: UIViewController, to viewController: UIViewController) { + let previousChild = currentChild + + parent.addChild(viewController) + parent.view.addSubview(viewController.view) + viewController.view.snp.makeConstraints { $0.edges.equalToSuperview() } + viewController.view.alpha = 0 + + UIView.animate( + withDuration: 0.3, + animations: { + previousChild?.view.alpha = 0 + viewController.view.alpha = 1 + }, + completion: { _ in + previousChild?.willMove(toParent: nil) + previousChild?.view.removeFromSuperview() + previousChild?.removeFromParent() + + viewController.didMove(toParent: parent) + self.currentChild = viewController + } + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift b/FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift new file mode 100644 index 00000000..be881ed8 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/Transition/ViewTransitionHandling.swift @@ -0,0 +1,10 @@ +// +// ViewTransitionHandling.swift +// Presentation +// + +import UIKit + +public protocol ViewTransitionHandling: AnyObject { + func transition(from parent: UIViewController, to viewController: UIViewController) +} diff --git a/FoodDiary/Presentation/Sources/Core/Typography.swift b/FoodDiary/Presentation/Sources/Core/Typography.swift index ede4dd66..f68a8164 100644 --- a/FoodDiary/Presentation/Sources/Core/Typography.swift +++ b/FoodDiary/Presentation/Sources/Core/Typography.swift @@ -26,6 +26,8 @@ public enum Typography { case hd18 /// 페이지 서브 타이틀 2nd - 16pt Bold case hd16 + /// 소형 헤드라인 - 15pt Bold + case hd15 // MARK: - Paragraph (Regular, 120% line height) /// 본문 - 18pt Regular @@ -50,6 +52,8 @@ public enum Typography { return DesignSystemFontFamily.Pretendard.bold.font(size: 18) case .hd16: return DesignSystemFontFamily.Pretendard.bold.font(size: 16) + case .hd15: + return DesignSystemFontFamily.Pretendard.bold.font(size: 15) case .p18: return DesignSystemFontFamily.Pretendard.regular.font(size: 18) case .p15: diff --git a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift index 40688ba5..0deb0932 100644 --- a/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift +++ b/FoodDiary/Presentation/Sources/Core/UIColor+DesignSystem.swift @@ -10,6 +10,14 @@ public extension UIColor { // MARK: - Primary static let primary = DesignSystemAsset.primary.color + static let primaryLight = DesignSystemAsset.primaryLight.color + static let primaryGradientStart = DesignSystemAsset.primaryGradientStart.color + static let primaryGradientEnd = DesignSystemAsset.primaryGradientEnd.color + + // MARK: - Blue + + static let blueGradientStart = DesignSystemAsset.blueGradientStart.color + static let blueGradientEnd = DesignSystemAsset.blueGradientEnd.color // MARK: - Red Positive @@ -31,6 +39,7 @@ public extension UIColor { static let sdBase = DesignSystemAsset.sdBase.color static let sd900 = DesignSystemAsset.sd900.color + static let sd850 = DesignSystemAsset.sd850.color static let sd800 = DesignSystemAsset.sd800.color static let sd700 = DesignSystemAsset.sd700.color static let sd600 = DesignSystemAsset.sd600.color diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift new file mode 100644 index 00000000..70b00e6c --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailCoordinator.swift @@ -0,0 +1,75 @@ +// +// DetailCoordinator.swift +// Presentation +// + +import Combine +import UIKit +import Data + +public final class DetailCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start(input: DetailSceneInput) { + let vc = factories.detail.makeScene(input: input) + vc.hidesBottomBarWhenPushed = true + flowBind(vc: vc) + navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension DetailCoordinator { + func flowBind(vc: DetailViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .pushEdit(let input): + self?.pushEdit(input: input) + case .pushImagePicker(let input): + self?.pushImagePicker(input: input) + case .finish: + self?.finish() + } + } + .store(in: &cancellables) + } +} + +private extension DetailCoordinator { + func pushImagePicker(input: ImagePickerSceneInput) { + let coord = ImagePickerCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } + + func pushEdit(input: EditSceneInput) { + let coord = EditCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } + + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift new file mode 100644 index 00000000..72230eb1 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailFlow.swift @@ -0,0 +1,13 @@ +// +// DetailFlow.swift +// Presentation +// + +import Combine + +public enum DetailFlow { + case pushEdit(EditSceneInput) + case pushImagePicker(ImagePickerSceneInput) + case finish +} + diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift new file mode 100644 index 00000000..b0296f50 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneFactory.swift @@ -0,0 +1,44 @@ +// +// DetailSceneFactory.swift +// Presentation +// + +import Data +import Domain +import UIKit + +public final class DetailSceneFactory { + private let fetchRecordsUseCase: FetchFoodRecordsUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let pushNotificationObserver: PushNotificationObserver + + public init( + fetchRecordsUseCase: FetchFoodRecordsUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase, + pushNotificationObserver: PushNotificationObserver + ) { + self.fetchRecordsUseCase = fetchRecordsUseCase + self.saveFoodRecordUseCase = saveFoodRecordUseCase + self.deleteFoodRecordUseCase = deleteFoodRecordUseCase + self.pushNotificationObserver = pushNotificationObserver + } + + public func makeScene(input: DetailSceneInput) -> DetailViewController { + let viewModel = DetailViewModel( + initialDate: input.date, + initialRecords: input.records, + fetchRecordsUseCase: fetchRecordsUseCase, + saveFoodRecordUseCase: saveFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase, + pushNotificationObserver: pushNotificationObserver + ) + return DetailViewController( + viewModel: viewModel, + initialScrollTarget: input.scrollToMealType, + shouldPopToRoot: input.shouldPopToRoot, + onDismissWithDate: input.onDismissWithDate + ) + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift new file mode 100644 index 00000000..50827e7c --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Coordinator/DetailSceneInput.swift @@ -0,0 +1,29 @@ +// +// DetailSceneInput.swift +// Presentation +// + +import Domain +import Foundation + +public struct DetailSceneInput { + public let date: Date + public let records: [FoodRecord] + public let scrollToMealType: MealType? + public let shouldPopToRoot: Bool + public let onDismissWithDate: ((Date) -> Void)? + + public init( + date: Date, + records: [FoodRecord], + scrollToMealType: MealType? = nil, + shouldPopToRoot: Bool = false, + onDismissWithDate: ((Date) -> Void)? = nil + ) { + self.date = date + self.records = records + self.scrollToMealType = scrollToMealType + self.shouldPopToRoot = shouldPopToRoot + self.onDismissWithDate = onDismissWithDate + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index b071560d..aac598ff 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -10,10 +10,7 @@ import Kingfisher import SnapKit import UIKit -public final class DetailViewController< - RecordRepo: FoodRecordRepository, - PushObserver: PushNotificationObserving ->: UIViewController { +public final class DetailViewController: UIViewController { // MARK: - Constants @@ -29,11 +26,15 @@ public final class DetailViewController< // MARK: - Dependencies - private let viewModel: DetailViewModel + private let viewModel: DetailViewModel private let onDismissWithDate: ((Date) -> Void)? - private let editViewControllerFactory: ((FoodRecord) -> UIViewController)? - private let presentImagePickerHandler: - ((UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void)? + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } // MARK: - UI Components @@ -120,23 +121,17 @@ public final class DetailViewController< // MARK: - Init public init( - viewModel: DetailViewModel, + viewModel: DetailViewModel, initialScrollTarget: MealType? = nil, scrollToFirstRecord: Bool = true, shouldPopToRoot: Bool = false, - onDismissWithDate: ((Date) -> Void)? = nil, - editViewControllerFactory: ((FoodRecord) -> UIViewController)? = nil, - presentImagePickerHandler: ( - (UINavigationController, Date, @escaping ([any ImageAssetable]) -> Void) -> Void - )? = nil + onDismissWithDate: ((Date) -> Void)? = nil ) { self.viewModel = viewModel self.pendingScrollTarget = initialScrollTarget self.scrollToFirstRecord = scrollToFirstRecord self.shouldPopToRoot = shouldPopToRoot self.onDismissWithDate = onDismissWithDate - self.editViewControllerFactory = editViewControllerFactory - self.presentImagePickerHandler = presentImagePickerHandler super.init(nibName: nil, bundle: nil) } @@ -170,6 +165,13 @@ public final class DetailViewController< } } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + flowSubject.send(.finish) + } + } + // MARK: - Setup private func setupNavigation() { @@ -204,7 +206,6 @@ public final class DetailViewController< } private func dismissDetail() { - onDismissWithDate?(viewModel.state.currentDate) if shouldPopToRoot { navigationController?.popToRootViewController(animated: true) } else { @@ -477,7 +478,7 @@ public final class DetailViewController< } private func formatRecordForShare(_ name: String, _ url: String) -> String { - return "\(name) 맛을 기억하시나요?\n\n\(url)" + return "\(name) 맛을 기억하시나요?\n뭐먹었지에서 확인해보세요.\n\(url)\n\n 나도 시작하기\nhttps://mumuk.ai.kr/" } private func presentShareSheet(items: [Any]) { @@ -506,9 +507,8 @@ public final class DetailViewController< } private func handleEdit(record: FoodRecord) { - guard let editVC = editViewControllerFactory?(record) else { return } pendingScrollTarget = record.mealType - navigationController?.pushViewController(editVC, animated: true) + flowSubject.send(.pushEdit(EditSceneInput(record: record))) } @objc private func floatingAddButtonTapped() { @@ -516,12 +516,12 @@ public final class DetailViewController< } private func handleAddPhoto() { - guard let nav = navigationController else { return } let date = viewModel.state.currentDate - - presentImagePickerHandler?(nav, date) { [weak self] assets in - self?.viewModel.input.send(.saveSelectedPhotos(assets)) - } + flowSubject.send(.pushImagePicker( + ImagePickerSceneInput(date: date) { [weak self] assets in + self?.viewModel.input.send(.saveSelectedPhotos(assets)) + } + )) } private func showShareUnavailableAlert() { @@ -570,7 +570,7 @@ public final class DetailViewController< } private func resolveScrollTarget( - _ state: DetailViewModel.State + _ state: DetailViewModel.State ) -> MealType? { if let mealType = pendingScrollTarget { pendingScrollTarget = nil @@ -582,7 +582,7 @@ public final class DetailViewController< return nil } - private func firstContentMealType(_ state: DetailViewModel.State) + private func firstContentMealType(_ state: DetailViewModel.State) -> MealType? { let orderedMealTypes: [MealType] = [.breakfast, .lunch, .dinner, .snack] @@ -595,6 +595,7 @@ public final class DetailViewController< private func scrollToMealSection(_ mealType: MealType) { let targetSection = mealSectionView(for: mealType) let sectionFrame = targetSection.convert(targetSection.bounds, to: scrollView) + let targetOffset = CGPoint( x: 0, y: sectionFrame.origin.y - scrollView.adjustedContentInset.top @@ -602,3 +603,4 @@ public final class DetailViewController< scrollView.setContentOffset(targetOffset, animated: false) } } + diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift index e4792ed3..3b3b4ea9 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift @@ -7,10 +7,7 @@ import Combine import Domain import Foundation -public final class DetailViewModel< - RecordRepo: FoodRecordRepository, - PushObserver: PushNotificationObserving -> { +public final class DetailViewModel { // MARK: - Output @@ -41,20 +38,20 @@ public final class DetailViewModel< // MARK: - Dependencies - private let fetchRecordsUseCase: FetchFoodRecordsUseCase - private let saveFoodRecordUseCase: SaveFoodRecordUseCase - private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase - private let pushNotificationObserver: PushObserver + private let fetchRecordsUseCase: FetchFoodRecordsUseCase + private let saveFoodRecordUseCase: SaveFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let pushNotificationObserver: any PushNotificationObserving // MARK: - Init public init( initialDate: Date, initialRecords: [FoodRecord], - fetchRecordsUseCase: FetchFoodRecordsUseCase, - saveFoodRecordUseCase: SaveFoodRecordUseCase, - deleteFoodRecordUseCase: DeleteFoodRecordUseCase, - pushNotificationObserver: PushObserver + fetchRecordsUseCase: FetchFoodRecordsUseCase, + saveFoodRecordUseCase: SaveFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase, + pushNotificationObserver: any PushNotificationObserving ) { self.fetchRecordsUseCase = fetchRecordsUseCase self.saveFoodRecordUseCase = saveFoodRecordUseCase diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift new file mode 100644 index 00000000..88dab305 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditCoordinator.swift @@ -0,0 +1,64 @@ +// +// EditCoordinator.swift +// Presentation +// + +import Combine +import UIKit +import Data + +// MARK: - Coordinator + +public final class EditCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start(input: EditSceneInput) { + let vc = factories.edit.makeScene(input: input) + flowBind(vc: vc) + navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension EditCoordinator { + func flowBind(vc: EditFoodRecordViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .presentAddressSearch(let input): + self?.presentAddressSearch(input: input) + case .finish: + self?.finish() + } + } + .store(in: &cancellables) + } +} + +private extension EditCoordinator { + func presentAddressSearch(input: AddressSearchSceneInput) { + let coord = AddressSearchCoordinator( + factories: factories, + navigationController: navigationController + ) + coord.parentCoordinator = self + addChild(coord) + coord.start(input: input) + } + + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift new file mode 100644 index 00000000..e9ba0a67 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditFlow.swift @@ -0,0 +1,12 @@ +// +// EditFlow.swift +// Presentation +// + +import Combine + +public enum EditFlow { + case presentAddressSearch(AddressSearchSceneInput) + case finish +} + diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift new file mode 100644 index 00000000..283ebe67 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneFactory.swift @@ -0,0 +1,32 @@ +// +// EditSceneFactory.swift +// Presentation +// + +import Data +import Domain +import UIKit + +// MARK: - Factory + +public final class EditSceneFactory { + private let updateFoodRecordUseCase: UpdateFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + + public init( + updateFoodRecordUseCase: UpdateFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase + ) { + self.updateFoodRecordUseCase = updateFoodRecordUseCase + self.deleteFoodRecordUseCase = deleteFoodRecordUseCase + } + + public func makeScene(input: EditSceneInput) -> EditFoodRecordViewController { + let viewModel = EditFoodRecordViewModel( + record: input.record, + updateFoodRecordUseCase: updateFoodRecordUseCase, + deleteFoodRecordUseCase: deleteFoodRecordUseCase + ) + return EditFoodRecordViewController(viewModel: viewModel) + } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift new file mode 100644 index 00000000..1bf2b160 --- /dev/null +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/Coordinator/EditSceneInput.swift @@ -0,0 +1,14 @@ +// +// EditSceneInput.swift +// Presentation +// + +import Domain + +public struct EditSceneInput { + public let record: FoodRecord + + public init(record: FoodRecord) { + self.record = record + } +} diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift index c83fcf5d..d24348ee 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift @@ -22,14 +22,19 @@ private enum EditFoodRecordConstants { static let buttonCornerRadius: CGFloat = 25 } -public final class EditFoodRecordViewController< - RecordRepo: FoodRecordRepository ->: UIViewController, UIGestureRecognizerDelegate { +public final class EditFoodRecordViewController: UIViewController, UIGestureRecognizerDelegate { // MARK: - Dependencies - private let viewModel: EditFoodRecordViewModel - private let addressSearchViewControllerFactory: ((Int, @escaping (AddressSearchResult) -> Void) -> UIViewController)? + private let viewModel: EditFoodRecordViewModel + + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } + // MARK: - UI Components private let scrollView: UIScrollView = { @@ -109,11 +114,9 @@ public final class EditFoodRecordViewController< // MARK: - Init public init( - viewModel: EditFoodRecordViewModel, - addressSearchViewControllerFactory: ((Int, @escaping (AddressSearchResult) -> Void) -> UIViewController)? = nil + viewModel: EditFoodRecordViewModel ) { self.viewModel = viewModel - self.addressSearchViewControllerFactory = addressSearchViewControllerFactory super.init(nibName: nil, bundle: nil) } @@ -144,6 +147,13 @@ public final class EditFoodRecordViewController< super.viewWillDisappear(animated) } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + flowSubject.send(.finish) + } + } + // MARK: - Setup private func setupNavigation() { @@ -383,7 +393,7 @@ public final class EditFoodRecordViewController< } } - private func handleEvent(_ event: EditFoodRecordViewModel.Event) { + private func handleEvent(_ event: EditFoodRecordViewModel.Event) { switch event { case .saveCompleted: ToastView.show(type: .infoUpdate) @@ -405,10 +415,11 @@ public final class EditFoodRecordViewController< private func presentAddressSearchModal() { guard let diaryId = Int(viewModel.state.originalRecord.id) else { return } - guard let addressSearchVC = addressSearchViewControllerFactory?(diaryId, { [weak self] result in - self?.viewModel.input.send(.selectAddress(result)) - }) else { return } - present(addressSearchVC, animated: true) + flowSubject.send(.presentAddressSearch( + AddressSearchSceneInput(diaryId: diaryId) { [weak self] result in + self?.viewModel.input.send(.selectAddress(result)) + } + )) } private func presentAddTagAlert() { @@ -484,3 +495,4 @@ public final class EditFoodRecordViewController< return true } } + diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift index 8064910f..2e30b1d9 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift @@ -8,7 +8,7 @@ import Domain import Foundation import UIKit -public final class EditFoodRecordViewModel { +public final class EditFoodRecordViewModel { // MARK: - Output @@ -37,15 +37,15 @@ public final class EditFoodRecordViewModel { // MARK: - Dependencies - private let updateFoodRecordUseCase: UpdateFoodRecordUseCase - private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase + private let updateFoodRecordUseCase: UpdateFoodRecordUseCase + private let deleteFoodRecordUseCase: DeleteFoodRecordUseCase // MARK: - Init public init( record: FoodRecord, - updateFoodRecordUseCase: UpdateFoodRecordUseCase, - deleteFoodRecordUseCase: DeleteFoodRecordUseCase + updateFoodRecordUseCase: UpdateFoodRecordUseCase, + deleteFoodRecordUseCase: DeleteFoodRecordUseCase ) { self.updateFoodRecordUseCase = updateFoodRecordUseCase self.deleteFoodRecordUseCase = deleteFoodRecordUseCase diff --git a/FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift b/FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift deleted file mode 100644 index 983b30fa..00000000 --- a/FoodDiary/Presentation/Sources/FoodRecordDetail/MockFoodRecordDetailViewController.swift +++ /dev/null @@ -1,86 +0,0 @@ -import Combine -import DesignSystem -import Domain -import SnapKit -import UIKit - -public final class MockFoodRecordDetailViewController: UIViewController { - - // MARK: - Properties - private let date: Date - - // MARK: - UI Components - - private lazy var closeButton: UIButton = { - let button = UIButton(type: .system) - button.setImage(UIImage(systemName: "xmark"), for: .normal) - button.tintColor = .white - button.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - return button - }() - - private let dateLabel: UILabel = { - let label = UILabel() - label.textAlignment = .center - return label - }() - - // MARK: - Init - - public init(date: Date) { - self.date = date - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - Lifecycle - - public override func viewDidLoad() { - super.viewDidLoad() - setupUI() - setupConstraints() - configureContent() - } - - // MARK: - Setup - - private func setupUI() { - view.backgroundColor = DesignSystemAsset.sdBase.color - view.addSubview(closeButton) - view.addSubview(dateLabel) - } - - private func setupConstraints() { - closeButton.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).inset(20) - $0.trailing.equalToSuperview().inset(20) - $0.width.height.equalTo(44) - } - - dateLabel.snp.makeConstraints { - $0.top.equalTo(closeButton.snp.bottom).offset(40) - $0.leading.trailing.equalToSuperview().inset(20) - } - } - - private func configureContent() { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "ko_KR") - formatter.dateFormat = "yyyy년 M월 d일" - let dateText = formatter.string(from: date) - - dateLabel.setText(dateText, style: .hd20, color: .white) - - // TODO: FoodRecord 표시 UI 구현 - } - - // MARK: - Actions - - @objc private func closeButtonTapped() { - dismiss(animated: true) - } -} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift new file mode 100644 index 00000000..708c80da --- /dev/null +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerCoordinator.swift @@ -0,0 +1,49 @@ +// +// ImagePickerCoordinator.swift +// Presentation +// + +import Combine +import Data +import Domain +import Photos +import UIKit + +// MARK: - Coordinator + +public final class ImagePickerCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start(input: ImagePickerSceneInput) { + let vc = factories.imagePicker.makeScene(input: input) + flowBind(vc: vc) + navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension ImagePickerCoordinator { + func flowBind(vc: ImagePickerViewController) { + vc.resultPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.finish() + } + .store(in: &cancellables) + } + + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift new file mode 100644 index 00000000..ad3fdde6 --- /dev/null +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneFactory.swift @@ -0,0 +1,47 @@ +// +// ImagePickerSceneFactory.swift +// Presentation +// + +import Data +import Domain +import Photos +import UIKit + +// MARK: - Factory + +public final class ImagePickerSceneFactory { + private let imageProvider: UIImageLoader + private let fetchUseCase: FetchFoodImageAssetUseCase + + public init( + imageProvider: UIImageLoader, + fetchUseCase: FetchFoodImageAssetUseCase + ) { + self.imageProvider = imageProvider + self.fetchUseCase = fetchUseCase + } + + public func makeScene(input: ImagePickerSceneInput) -> ImagePickerViewController { + let date = input.date + let fetchUseCase = self.fetchUseCase + + let fetcher: () async throws -> (photos: [any ImageAssetable], preselectedIds: Set) = { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: date) + let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)! + let byDate = try await fetchUseCase.execute(from: startOfDay, to: endOfDay) + let assets = byDate[startOfDay] ?? [] + let photos = assets.map { $0.imageAsset } + let preselectedIds = Set(assets.filter { $0.foodProbability >= 0.5 }.map { $0.id }) + return (photos, preselectedIds) + } + + return ImagePickerViewController( + imageProvider: imageProvider, + configuration: .withMaxSelectionCount(10), + photosFetcher: fetcher, + onSelected: { assets in input.onSelected(assets) } + ) + } +} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift new file mode 100644 index 00000000..c095ee33 --- /dev/null +++ b/FoodDiary/Presentation/Sources/ImagePicker/Coordinator/ImagePickerSceneInput.swift @@ -0,0 +1,17 @@ +// +// ImagePickerSceneInput.swift +// Presentation +// + +import Domain +import Foundation + +public struct ImagePickerSceneInput { + public let date: Date + public let onSelected: ([any ImageAssetable]) -> Void + + public init(date: Date, onSelected: @escaping ([any ImageAssetable]) -> Void) { + self.date = date + self.onSelected = onSelected + } +} diff --git a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift index aaf446e8..cb7a5560 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerResult.swift @@ -8,9 +8,9 @@ import Domain /// 이미지 피커 결과 -public enum ImagePickerResult { +public enum ImagePickerResult { /// 사진 선택 완료 - case selected([Asset]) + case selected([any ImageAssetable]) /// 취소 case cancelled } diff --git a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift index 7ace58e6..b103f8f3 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift @@ -11,49 +11,7 @@ import UIKit // MARK: - ImagePickerViewController -/// 음식 사진을 선택할 수 있는 커스텀 이미지 피커 뷰 컨트롤러입니다. -/// -/// ## Overview -/// `ImagePickerViewController`는 `FoodImageAsset` 배열을 받아 그리드 형태로 표시하고, -/// 사용자가 사진을 선택하면 `resultPublisher`를 통해 결과를 전달합니다. -/// -/// ## Usage -/// ```swift -/// // 1. 피커 생성 -/// let picker = ImagePickerViewController( -/// photos: foodPhotos, -/// imageProvider: myImageProvider, -/// configuration: .default -/// ) -/// -/// // 2. 결과 구독 -/// picker.resultPublisher -/// .sink { result in -/// switch result { -/// case .selected(let photos): -/// // 선택된 사진 처리 -/// self.dismiss(animated: true) -/// case .cancelled: -/// // 취소 처리 -/// self.dismiss(animated: true) -/// } -/// } -/// .store(in: &cancellables) -/// -/// // 3. 피커 표시 -/// present(picker, animated: true) -/// ``` -/// -/// ## Configuration -/// `ImagePickerConfiguration`을 통해 다음 항목을 커스터마이징할 수 있습니다: -/// - `primaryColor`: 선택 테두리 및 버튼 색상 -/// - `maxSelectionCount`: 최대 선택 가능 수 (nil이면 무제한) -/// - `confirmButtonTitle`: 확인 버튼 텍스트 -/// -public final class ImagePickerViewController< - Asset: ImageAssetable, - ImageProvider: RenderableImageRepository ->: +public final class ImagePickerViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout { @@ -89,22 +47,24 @@ public final class ImagePickerViewController< // MARK: - Public Publisher /// 피커 결과 Publisher - public var resultPublisher: AnyPublisher, Never> { + public var resultPublisher: AnyPublisher { resultSubject.eraseToAnyPublisher() } // MARK: - Private Properties - private let resultSubject = PassthroughSubject, Never>() + private let resultSubject = PassthroughSubject() private var cancellables = Set() - private let photos: [Asset] - private let preselectedFoodPhotoIds: Set - private let imageProvider: ImageProvider + private var photos: [any ImageAssetable] = [] + private var preselectedFoodPhotoIds: Set = [] + private let imageProvider: any RenderableImageRepository private let configuration: ImagePickerConfiguration - // preselected된 사진은 food/all 양쪽 섹션에 나타날 수 있어, id 기준 인덱스를 캐싱한다. - private let foodPhotos: [Asset] - private let indexPathsByPhotoId: [String: [IndexPath]] + private var foodPhotos: [any ImageAssetable] = [] + private var indexPathsByPhotoId: [String: [IndexPath]] = [:] + + private let photosFetcher: () async throws -> (photos: [any ImageAssetable], preselectedIds: Set) + private let onSelected: (([any ImageAssetable]) -> Void)? // MARK: - State @@ -112,7 +72,7 @@ public final class ImagePickerViewController< // MARK: - Computed Properties - private func photosInSection(_ section: PhotoSection) -> [Asset] { + private func photosInSection(_ section: PhotoSection) -> [any ImageAssetable] { switch section { case .food: return foodPhotos case .all: return photos @@ -192,30 +152,16 @@ public final class ImagePickerViewController< // MARK: - Initialization - /// 이미지 피커 초기화 - /// - Parameters: - /// - photos: 표시할 사진 목록 - /// - preselectedFoodPhotoIds: 미리 선택될 음식 사진 ID 집합 - /// - imageProvider: 이미지 로딩 제공자 - /// - configuration: 피커 설정 public init( - photos: [Asset], - preselectedFoodPhotoIds: Set = [], - imageProvider: ImageProvider, - configuration: ImagePickerConfiguration = .default + imageProvider: any RenderableImageRepository, + configuration: ImagePickerConfiguration = .default, + photosFetcher: @escaping () async throws -> (photos: [any ImageAssetable], preselectedIds: Set), + onSelected: (([any ImageAssetable]) -> Void)? = nil ) { - // 매 접근마다 filter하지 않도록 음식 섹션 데이터를 1회 계산해 둔다. - let foodPhotos = photos.filter { preselectedFoodPhotoIds.contains($0.id) } - - self.photos = photos - self.preselectedFoodPhotoIds = preselectedFoodPhotoIds self.imageProvider = imageProvider self.configuration = configuration - self.foodPhotos = foodPhotos - self.indexPathsByPhotoId = Self.makeIndexPathsByPhotoId( - allPhotos: photos, - foodPhotos: foodPhotos - ) + self.photosFetcher = photosFetcher + self.onSelected = onSelected super.init(nibName: nil, bundle: nil) } @@ -231,7 +177,17 @@ public final class ImagePickerViewController< setupNavigationBar() setupUI() setupConstraints() - applyPreselection() + loadPhotos() + + resultPublisher + .compactMap { if case .selected(let a) = $0 { return a } else { return nil } } + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] assets in + self?.navigationController?.popViewController(animated: true) + self?.onSelected?(assets) + } + .store(in: &cancellables) } public override func viewWillAppear(_ animated: Bool) { @@ -246,6 +202,35 @@ public final class ImagePickerViewController< } } + // MARK: - Data Loading + + private func loadPhotos() { + Task { @MainActor in + do { + let (photos, preselectedIds) = try await photosFetcher() + applyPhotos(photos, preselectedIds: preselectedIds) + } catch { + showLoadErrorAndPop(error) + } + } + } + + private func applyPhotos(_ photos: [any ImageAssetable], preselectedIds: Set) { + self.photos = photos + self.preselectedFoodPhotoIds = preselectedIds + let foodPhotos = photos.filter { preselectedIds.contains($0.id) } + self.foodPhotos = foodPhotos + self.indexPathsByPhotoId = Self.makeIndexPathsByPhotoId( + allPhotos: photos, + foodPhotos: foodPhotos + ) + + emptyView.isHidden = !photos.isEmpty + collectionView.isHidden = photos.isEmpty + collectionView.reloadData() + applyPreselection() + } + // MARK: - Setup private func setupNavigationBar() { @@ -266,8 +251,8 @@ public final class ImagePickerViewController< view.addSubview(emptyView) view.addSubview(confirmButton) - emptyView.isHidden = !photos.isEmpty - collectionView.isHidden = photos.isEmpty + emptyView.isHidden = true + collectionView.isHidden = true } private func setupConstraints() { @@ -319,7 +304,6 @@ public final class ImagePickerViewController< selectedPhotoIds.insert(photo.id) } - // 같은 사진이 양쪽 섹션에 동시에 보일 수 있어, 매핑된 셀을 함께 갱신한다. let affectedIndexPaths = indexPathsByPhotoId[photo.id] ?? [indexPath] collectionView.reloadItems(at: affectedIndexPaths) @@ -327,10 +311,9 @@ public final class ImagePickerViewController< } private static func makeIndexPathsByPhotoId( - allPhotos: [Asset], - foodPhotos: [Asset] + allPhotos: [any ImageAssetable], + foodPhotos: [any ImageAssetable] ) -> [String: [IndexPath]] { - // photo id -> [food 섹션 indexPath, all 섹션 indexPath] var indexPathsById: [String: [IndexPath]] = [:] for (item, photo) in foodPhotos.enumerated() { @@ -392,6 +375,20 @@ public final class ImagePickerViewController< resultSubject.send(.selected(selectedAssets)) } + // MARK: - Error + + private func showLoadErrorAndPop(_ error: Error) { + let alert = UIAlertController( + title: "사진 불러오기 실패", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "확인", style: .default) { [weak self] _ in + self?.navigationController?.popViewController(animated: true) + }) + present(alert, animated: true) + } + // MARK: - UICollectionViewDataSource public func numberOfSections(in collectionView: UICollectionView) -> Int { @@ -451,7 +448,7 @@ public final class ImagePickerViewController< return header } - private func loadImage(for photo: Asset, cell: ImagePickerCell, at indexPath: IndexPath) { + private func loadImage(for photo: any ImageAssetable, cell: ImagePickerCell, at indexPath: IndexPath) { Task { do { let image = try await imageProvider.loadImage( diff --git a/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift b/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift new file mode 100644 index 00000000..e0b5a3a5 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/BubbleChartView.swift @@ -0,0 +1,236 @@ +// +// BubbleChartView.swift +// Presentation +// + +import DesignSystem +import UIKit + +final class BubbleChartView: UIView { + + struct BubbleData { + let label: String + let count: Int + let color: UIColor + } + + var data: [BubbleData] = [] + var animationDuration: CFTimeInterval = 0.6 + + private var didLayoutBubbles = false + private let padding: CGFloat = 4 + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + guard bounds.width > 0, !data.isEmpty, !didLayoutBubbles else { return } + didLayoutBubbles = true + drawBubbles() + } + + // MARK: - Drawing + + private func drawBubbles() { + layer.sublayers?.forEach { $0.removeFromSuperlayer() } + + let sortedData = data.sorted { $0.count > $1.count } + let topData = Array(sortedData.prefix(3)) + + guard let maxCount = topData.first?.count, maxCount > 0 else { return } + + let maxDiameter = min(bounds.width, bounds.height) * 0.65 + let minDiameter: CGFloat = 50 + + // 순위별 고정 비율: 1등 100%, 2등 65%, 3등 45% + let rankRatios: [CGFloat] = [1.0, 0.65, 0.45] + let diameters = topData.enumerated().map { index, _ -> CGFloat in + max(rankRatios[index] * maxDiameter, minDiameter) + } + + let centers = calculateNonOverlappingCenters(diameters: diameters) + + for (index, item) in topData.enumerated() { + let bubbleLayer = makeBubbleLayer( + center: centers[index], + diameter: diameters[index], + color: item.color, + label: item.label, + count: item.count + ) + layer.addSublayer(bubbleLayer) + + animateBubble(bubbleLayer, delay: Double(index) * 0.15) + } + } + + // MARK: - 겹치지 않는 배치 계산 + + private func calculateNonOverlappingCenters(diameters: [CGFloat]) -> [CGPoint] { + let radii = diameters.map { $0 / 2 } + + switch diameters.count { + case 1: + return [CGPoint(x: bounds.midX, y: bounds.midY)] + + case 2: + let totalWidth = diameters[0] + padding + diameters[1] + let startX = (bounds.width - totalWidth) / 2 + radii[0] + return [ + CGPoint(x: startX, y: bounds.midY), + CGPoint(x: startX + radii[0] + padding + radii[1], y: bounds.midY) + ] + + default: + // 1번(큼): 왼쪽, 2번(중간): 오른쪽 위, 3번(작음): 오른쪽 아래 + let r0 = radii[0] + let r1 = radii[1] + let r2 = radii[2] + + // 원점(0,0) 기준으로 배치 후 중앙 정렬 + let c0 = CGPoint.zero + + // 2번 버블: 1번의 오른쪽 위 + let dist01 = r0 + r1 + padding + let angle01: CGFloat = -.pi / 3 // 60도 위 + let c1 = CGPoint( + x: c0.x + dist01 * cos(angle01), + y: c0.y + dist01 * sin(angle01) + ) + + // 3번 버블: 1번의 오른쪽 + let dist02 = r0 + r2 + padding + let angle02: CGFloat = -.pi / 8 // 살짝 위 + var c2 = CGPoint( + x: c0.x + dist02 * cos(angle02), + y: c0.y + dist02 * sin(angle02) + ) + + // 2번과 겹치면 밀어냄 + let dist12 = hypot(c2.x - c1.x, c2.y - c1.y) + let minDist12 = r1 + r2 + padding + if dist12 < minDist12 { + let pushAngle = atan2(c2.y - c1.y, c2.x - c1.x) + c2 = CGPoint( + x: c1.x + minDist12 * cos(pushAngle), + y: c1.y + minDist12 * sin(pushAngle) + ) + } + + // 전체 바운딩 박스 계산 후 중앙 정렬 + var centers = [c0, c1, c2] + let allLeft = zip(centers, radii).map { $0.0.x - $0.1 }.min()! + let allRight = zip(centers, radii).map { $0.0.x + $0.1 }.max()! + let allTop = zip(centers, radii).map { $0.0.y - $0.1 }.min()! + let allBottom = zip(centers, radii).map { $0.0.y + $0.1 }.max()! + + let groupWidth = allRight - allLeft + let groupHeight = allBottom - allTop + let offsetX = (bounds.width - groupWidth) / 2 - allLeft + let offsetY = (bounds.height - groupHeight) / 2 - allTop + + for i in 0.. CALayer { + let container = CALayer() + container.frame = CGRect( + x: center.x - diameter / 2, + y: center.y - diameter / 2, + width: diameter, + height: diameter + ) + + // 원형 배경 + let circleLayer = CAShapeLayer() + let circlePath = UIBezierPath( + ovalIn: CGRect(x: 0, y: 0, width: diameter, height: diameter) + ) + circleLayer.path = circlePath.cgPath + circleLayer.fillColor = color.cgColor + container.addSublayer(circleLayer) + + // 라벨 텍스트 + let nameFontSize: CGFloat = diameter > 100 ? 15 : (diameter > 70 ? 13 : 11) + let countFontSize: CGFloat = diameter > 100 ? 12 : (diameter > 70 ? 10 : 9) + let nameFont = DesignSystemFontFamily.Pretendard.bold.font(size: nameFontSize) + let countFont = DesignSystemFontFamily.Pretendard.regular.font(size: countFontSize) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + paragraphStyle.lineSpacing = 2 + + let attributed = NSMutableAttributedString() + attributed.append(NSAttributedString( + string: label, + attributes: [.font: nameFont, .foregroundColor: UIColor.white, .paragraphStyle: paragraphStyle] + )) + attributed.append(NSAttributedString( + string: "\n(\(count)회)", + attributes: [.font: countFont, .foregroundColor: UIColor.white, .paragraphStyle: paragraphStyle] + )) + + let textSize = attributed.boundingRect( + with: CGSize(width: diameter - 8, height: CGFloat.greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + context: nil + ).size + + let textLayer = CATextLayer() + textLayer.string = attributed + textLayer.isWrapped = true + textLayer.alignmentMode = .center + textLayer.contentsScale = UIScreen.main.scale + textLayer.frame = CGRect( + x: (diameter - textSize.width) / 2, + y: (diameter - textSize.height) / 2, + width: textSize.width, + height: textSize.height + ) + container.addSublayer(textLayer) + + return container + } + + // MARK: - Animation + + private func animateBubble(_ bubbleLayer: CALayer, delay: TimeInterval) { + bubbleLayer.opacity = 0 + bubbleLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1) + + CATransaction.begin() + CATransaction.setDisableActions(true) + bubbleLayer.opacity = 1 + bubbleLayer.transform = CATransform3DIdentity + CATransaction.commit() + + let scaleAnim = CABasicAnimation(keyPath: "transform.scale") + scaleAnim.fromValue = 0.3 + scaleAnim.toValue = 1.0 + + let opacityAnim = CABasicAnimation(keyPath: "opacity") + opacityAnim.fromValue = 0 + opacityAnim.toValue = 1 + + let group = CAAnimationGroup() + group.animations = [scaleAnim, opacityAnim] + group.duration = animationDuration + group.beginTime = CACurrentMediaTime() + delay + group.timingFunction = CAMediaTimingFunction(name: .easeOut) + group.fillMode = .backwards + + bubbleLayer.add(group, forKey: "appear") + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift b/FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift new file mode 100644 index 00000000..8f3df2e1 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/DonutChartView.swift @@ -0,0 +1,189 @@ +// +// DonutChartView.swift +// Presentation +// + +import UIKit + +final class DonutChartView: UIView { + struct SliceData { + let value: Double + let colors: [UIColor] + let label: String + } + + var data: [SliceData] = [] + var innerRadiusRatio: CGFloat = 0.5 + var animationDuration: CFTimeInterval = 1.2 + var separatorColor: UIColor = .black + + private var total: Double { data.reduce(0) { $0 + $1.value } } + private let sliceContainer = CALayer() + private var labelLayers: [CATextLayer] = [] + + override init(frame: CGRect) { + super.init(frame: frame) + layer.addSublayer(sliceContainer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + layer.addSublayer(sliceContainer) + } + + override func layoutSubviews() { + super.layoutSubviews() + sliceContainer.frame = bounds + setup() + } + + // MARK: - Setup + + private func setup() { + guard bounds.width > 0, total > 0 else { return } + + sliceContainer.sublayers?.forEach { $0.removeFromSuperlayer() } + labelLayers.forEach { $0.removeFromSuperlayer() } + labelLayers.removeAll() + sliceContainer.mask = nil + + let center = CGPoint(x: bounds.midX, y: bounds.midY) + let outerRadius = min(bounds.width, bounds.height) / 2 + let innerRadius = outerRadius * innerRadiusRatio + let midRadius = (outerRadius + innerRadius) / 2 + let startOffset = -CGFloat.pi / 2 + var currentAngle = startOffset + var boundaryAngles: [CGFloat] = [] + + for item in data { + let sweep = CGFloat(item.value / total) * 2 * .pi + + let path = UIBezierPath() + path.move(to: CGPoint( + x: center.x + outerRadius * cos(currentAngle), + y: center.y + outerRadius * sin(currentAngle) + )) + path.addArc(withCenter: center, radius: outerRadius, + startAngle: currentAngle, endAngle: currentAngle + sweep, clockwise: true) + path.addArc(withCenter: center, radius: innerRadius, + startAngle: currentAngle + sweep, endAngle: currentAngle, clockwise: false) + path.close() + + let shapeMask = CAShapeLayer() + shapeMask.path = path.cgPath + + let gradientLayer = CAGradientLayer() + gradientLayer.frame = bounds + gradientLayer.colors = item.colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + gradientLayer.mask = shapeMask + sliceContainer.addSublayer(gradientLayer) + + let midAngle = currentAngle + sweep / 2 + let labelPoint = CGPoint( + x: center.x + midRadius * cos(midAngle), + y: center.y + midRadius * sin(midAngle) + ) + let textLayer = makeTextLayer(item.label, at: labelPoint) + textLayer.opacity = 0 + layer.addSublayer(textLayer) + labelLayers.append(textLayer) + + boundaryAngles.append(currentAngle) + currentAngle += sweep + } + + // 슬라이스 경계에 배경색 선을 덧그려 균일한 간격 효과 적용 + for angle in boundaryAngles { + let separatorLayer = CAShapeLayer() + let separatorPath = UIBezierPath() + separatorPath.move(to: CGPoint( + x: center.x + innerRadius * cos(angle), + y: center.y + innerRadius * sin(angle) + )) + separatorPath.addLine(to: CGPoint( + x: center.x + outerRadius * cos(angle), + y: center.y + outerRadius * sin(angle) + )) + separatorLayer.path = separatorPath.cgPath + separatorLayer.strokeColor = separatorColor.cgColor + separatorLayer.lineWidth = 3 + separatorLayer.lineCap = .square + sliceContainer.addSublayer(separatorLayer) + } + + let ringWidth = outerRadius - innerRadius + let animMask = CAShapeLayer() + let circlePath = UIBezierPath( + arcCenter: center, + radius: midRadius, + startAngle: startOffset, + endAngle: startOffset + 2 * .pi, + clockwise: true + ) + animMask.path = circlePath.cgPath + animMask.fillColor = UIColor.clear.cgColor + animMask.strokeColor = UIColor.black.cgColor + animMask.lineWidth = ringWidth + 2 + animMask.strokeEnd = 0 + sliceContainer.mask = animMask + + animateMask(animMask) + } + + // MARK: - Animation + + private func animateMask(_ maskLayer: CAShapeLayer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + maskLayer.strokeEnd = 1 + CATransaction.commit() + + let anim = CABasicAnimation(keyPath: "strokeEnd") + anim.fromValue = 0 + anim.toValue = 1 + anim.duration = animationDuration + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + maskLayer.add(anim, forKey: "drawDonut") + + Task { + try? await Task.sleep(for: .seconds(animationDuration)) + fadeInLabels() + } + } + + private func fadeInLabels() { + for label in labelLayers { + let anim = CABasicAnimation(keyPath: "opacity") + anim.fromValue = 0 + anim.toValue = 1 + anim.duration = 0.3 + label.opacity = 1 + label.add(anim, forKey: "fadeIn") + } + } + + // MARK: - Helper + + private func makeTextLayer(_ text: String, at point: CGPoint) -> CATextLayer { + let attributed = Typography.p12.styled(text, color: .white) + let size = attributed.boundingRect( + with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), + options: .usesLineFragmentOrigin, + context: nil + ).size + + let textLayer = CATextLayer() + textLayer.string = attributed + textLayer.alignmentMode = .center + textLayer.contentsScale = UIScreen.main.scale + textLayer.frame = CGRect( + x: point.x - size.width / 2, + y: point.y - size.height / 2, + width: size.width, + height: size.height + ) + return textLayer + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift b/FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift new file mode 100644 index 00000000..e46423fc --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/GradientBarView.swift @@ -0,0 +1,33 @@ +// +// GradientBarView.swift +// Presentation +// + +import UIKit + +final class GradientBarView: UIView { + + private let gradientLayer = CAGradientLayer() + + init(colors: [UIColor]) { + super.init(frame: .zero) + clipsToBounds = true + layer.cornerRadius = 4 + layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + gradientLayer.colors = colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0.5, y: 0) + gradientLayer.endPoint = CGPoint(x: 0.5, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift new file mode 100644 index 00000000..3bc2fb95 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightCategoryStatsView.swift @@ -0,0 +1,86 @@ +// +// InsightCategoryStatsView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightCategoryStatsView: UIView { + + // MARK: - UI Components + + private let descriptionLabel = UILabel() + private let rankLabel = UILabel() + private let donutChartView = DonutChartView() + + // MARK: - Init + + init(categoryStats: CategoryStats) { + super.init(frame: .zero) + setupUI(categoryStats: categoryStats) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(categoryStats: CategoryStats) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + let current = categoryStats.currentMonth + let previous = categoryStats.previousMonth + let isSameCategory = current.topCategory == previous.topCategory + + let currentName = FoodGenre(rawValue: current.topCategory)?.displayName ?? current.topCategory + let previousName = FoodGenre(rawValue: previous.topCategory)?.displayName ?? previous.topCategory + + let descriptionText = isSameCategory ? "왕좌가 유지되었어요." : "왕좌가 바뀌었어요." + descriptionLabel.setText(descriptionText, style: .hd16, color: .gray050) + addSubview(descriptionLabel) + + let attributed = NSMutableAttributedString() + if !isSameCategory { + attributed.append(Typography.hd16.styled(previousName, color: .blueGradientStart)) + attributed.append(Typography.hd16.styled(" 대신 ", color: .gray050)) + } + attributed.append(Typography.hd16.styled(currentName, color: .primary)) + attributed.append(Typography.hd16.styled("이 1등이에요.", color: .gray050)) + rankLabel.attributedText = attributed + addSubview(rankLabel) + + donutChartView.innerRadiusRatio = 0.3 + donutChartView.separatorColor = .sd900 + donutChartView.data = [ + DonutChartView.SliceData(value: Double(current.count), colors: [.primaryGradientStart, .primaryGradientEnd], label: "\(current.count)회"), + DonutChartView.SliceData(value: Double(previous.count), colors: [.blueGradientStart, .blueGradientEnd], label: "\(previous.count)회") + ] + addSubview(donutChartView) + } + + private func setupConstraints() { + descriptionLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + rankLabel.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(8) + $0.leading.trailing.equalToSuperview().inset(20) + } + + donutChartView.snp.makeConstraints { + $0.top.equalTo(rankLabel.snp.bottom).offset(20) + $0.centerX.equalToSuperview() + $0.width.height.equalTo(220) + $0.bottom.equalToSuperview().inset(20) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift new file mode 100644 index 00000000..1629d7d9 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightDiaryTimeStatsView.swift @@ -0,0 +1,72 @@ +// +// InsightDiaryTimeStatsView.swift +// Presentation +// + +import DesignSystem +import Domain +import SnapKit +import UIKit + +final class InsightDiaryTimeStatsView: UIView { + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let bigTimeLabel = UILabel() + + // MARK: - Init + + init(diaryTimeStats: DiaryTimeStats) { + super.init(frame: .zero) + setupUI(diaryTimeStats: diaryTimeStats) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(diaryTimeStats: DiaryTimeStats) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + let timeText = diaryTimeStats.mostActiveTime + + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd15.styled("식사는 늘,\n", color: .white, lineSpacing: 6)) + attributed.append(Typography.hd15.styled(timeText, color: .primary)) + attributed.append(Typography.hd15.styled(" 이 제일 많았어요", color: .gray200)) + + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + addSubview(titleLabel) + + bigTimeLabel.attributedText = NSAttributedString( + string: timeText, + attributes: [ + .font: DesignSystemFontFamily.Pretendard.bold.font(size: 50), + .foregroundColor: UIColor.primary + ] + ) + bigTimeLabel.textAlignment = .center + addSubview(bigTimeLabel) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + bigTimeLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(24) + $0.centerX.equalToSuperview() + $0.bottom.equalToSuperview().inset(28) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift new file mode 100644 index 00000000..a767964d --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightHeaderView.swift @@ -0,0 +1,57 @@ +// +// InsightHeaderView.swift +// Presentation +// + +import DesignSystem +import SnapKit +import UIKit + +final class InsightHeaderView: UIView { + + // MARK: - UI Components + + private let dateLabel = UILabel() + private let titleLabel = UILabel() + + // MARK: - Init + + init(date: Date = Date()) { + super.init(frame: .zero) + setupUI(date: date) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(date: Date) { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy.MM.dd" + dateLabel.setText(formatter.string(from: date), style: .p12, color: .gray050) + + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd20.styled("이번 달, ", color: .primary)) + attributed.append(Typography.hd20.styled("잘 먹었습니다.", color: .gray050)) + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + + addSubview(dateLabel) + addSubview(titleLabel) + } + + private func setupConstraints() { + dateLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview() + } + + titleLabel.snp.makeConstraints { + $0.top.equalTo(dateLabel.snp.bottom).offset(12) + $0.leading.trailing.bottom.equalToSuperview() + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift new file mode 100644 index 00000000..8d98e9a6 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightKeywordsView.swift @@ -0,0 +1,127 @@ +// +// InsightKeywordsView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightKeywordsView: UIView { + + // MARK: - Constants + + private enum Constants { + static let inset: CGFloat = 20 + static let tagHeight: CGFloat = 36 + static let horizontalSpacing: CGFloat = 8 + static let verticalSpacing: CGFloat = 8 + } + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let tagsContainer = UIView() + private let keywords: [KeywordStat] + private var tagViews: [UIView] = [] + private var tagsContainerHeightConstraint: Constraint? + private var lastTagsHeight: CGFloat = 0 + + // MARK: - Init + + init(keywords: [KeywordStat]) { + self.keywords = keywords + super.init(frame: .zero) + setupUI() + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + titleLabel.setText("나의 입맛과\n가장 잘 어울리는 키워드", style: .hd15, color: .gray050, lineSpacing: 6) + titleLabel.numberOfLines = 2 + addSubview(titleLabel) + addSubview(tagsContainer) + + tagViews = keywords.map { makeTagView(text: $0.keyword) } + tagViews.forEach { tagsContainer.addSubview($0) } + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview().inset(Constants.inset) + } + + tagsContainer.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(Constants.inset) + $0.bottom.equalToSuperview().inset(Constants.inset) + tagsContainerHeightConstraint = $0.height.equalTo(Constants.tagHeight).constraint + } + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + updateTagLayout() + } + + private func updateTagLayout() { + let containerWidth = tagsContainer.bounds.width + guard containerWidth > 0 else { return } + + var x: CGFloat = 0 + var y: CGFloat = 0 + + for tagView in tagViews { + let width = tagWidth(for: tagView) + if x + width > containerWidth, x > 0 { + x = 0 + y += Constants.tagHeight + Constants.verticalSpacing + } + tagView.frame = CGRect(x: x, y: y, width: width, height: Constants.tagHeight) + x += width + Constants.horizontalSpacing + } + + let totalHeight = tagViews.isEmpty ? Constants.tagHeight : y + Constants.tagHeight + guard totalHeight != lastTagsHeight else { return } + lastTagsHeight = totalHeight + tagsContainerHeightConstraint?.update(offset: totalHeight) + setNeedsLayout() + } + + private func tagWidth(for tagView: UIView) -> CGFloat { + guard let label = tagView.subviews.first as? UILabel else { return 80 } + return ceil(label.intrinsicContentSize.width) + 28 + } + + // MARK: - Tag + + private func makeTagView(text: String) -> UIView { + let container = UIView() + container.backgroundColor = .sd850 + container.layer.cornerRadius = Constants.tagHeight / 2 + + let label = UILabel() + label.setText("#\(text)", style: .p14, color: .gray400) + container.addSubview(label) + + label.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(14) + $0.centerY.equalToSuperview() + } + + return container + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift new file mode 100644 index 00000000..3ce244e8 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightLocationStatsView.swift @@ -0,0 +1,84 @@ +// +// InsightLocationStatsView.swift +// Presentation +// + +import DesignSystem +import Domain +import SnapKit +import UIKit + +final class InsightLocationStatsView: UIView { + + // MARK: - Constants + + private enum Constants { + static let bubbleChartHeight: CGFloat = 250 + } + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let bubbleChartView = BubbleChartView() + + // MARK: - Init + + init(locationStats: [LocationStat]) { + super.init(frame: .zero) + setupUI(locationStats: locationStats) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(locationStats: [LocationStat]) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + let sorted = locationStats.sorted { $0.count > $1.count } + guard let topLocation = sorted.first else { return } + + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd15.styled("이번 달 가장 많이 간 지역은\n", color: .gray050, lineSpacing: 6)) + attributed.append(Typography.hd15.styled("\(topLocation.count)회 ", color: .gray050)) + attributed.append(Typography.hd15.styled(topLocation.dong, color: .primary)) + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + addSubview(titleLabel) + + let colors: [UIColor] = [ + .primary, + .primary.withAlphaComponent(0.5), + .primary.withAlphaComponent(0.25) + ] + + bubbleChartView.data = sorted.prefix(3).enumerated().map { index, stat in + BubbleChartView.BubbleData( + label: stat.dong, + count: stat.count, + color: colors[index] + ) + } + addSubview(bubbleChartView) + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + bubbleChartView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Constants.bubbleChartHeight) + $0.bottom.equalToSuperview().inset(20) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift new file mode 100644 index 00000000..94a62d4d --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightPhotoStatsView.swift @@ -0,0 +1,209 @@ +// +// InsightPhotoStatsView.swift +// Presentation +// + +import Domain +import SnapKit +import UIKit + +final class InsightPhotoStatsView: UIView { + + // MARK: - Constants + + private enum Constants { + static let maxBarHeight: CGFloat = 160 + static let minBarHeight: CGFloat = 30 + static let lineCount: Int = 6 + } + + // MARK: - UI Components + + private let descriptionLabel = UILabel() + private let changeRateStackView = UIStackView() + private let headerStackView = UIStackView() + private let chartContainerView = UIView() + private let linesStackView = UIStackView() + private let barsStackView = UIStackView() + private let monthLabelsStackView = UIStackView() + + private struct BarInfo { + let barView: GradientBarView + let label: UIView + let barHeight: CGFloat + } + private var barInfos: [BarInfo] = [] + + // MARK: - Init + + init(photoStats: PhotoStats, month: String) { + super.init(frame: .zero) + setupUI(photoStats: photoStats, month: month) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(photoStats: PhotoStats, month: String) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + let descriptionText = photoStats.changeRate >= 0 + ? "먹기 전에\n카메라부터 찾았네요." + : "이번 달엔 음식에 더 집중하셨네요." + descriptionLabel.setText(descriptionText, style: .hd15, color: .gray050, lineSpacing: 6) + descriptionLabel.numberOfLines = 2 + + setupChangeRateLabel(photoStats: photoStats) + + headerStackView.axis = .vertical + headerStackView.spacing = 10 + headerStackView.addArrangedSubview(descriptionLabel) + headerStackView.addArrangedSubview(changeRateStackView) + addSubview(headerStackView) + + setupChart(photoStats: photoStats, month: month) + } + + private func setupChangeRateLabel(photoStats: PhotoStats) { + let rate = photoStats.changeRate + + let prefixLabel = UILabel() + prefixLabel.setText("지난 달 대비 기록된 사진이 ", style: .p10, color: .gray050) + + let valueLabel = UILabel() + valueLabel.setText("\(abs(rate))%", style: .hd16, color: .primary) + + let suffixLabel = UILabel() + suffixLabel.setText(rate >= 0 ? " 증가했어요." : " 감소했어요.", style: .p10, color: .gray050) + + changeRateStackView.axis = .horizontal + changeRateStackView.alignment = .center + changeRateStackView.spacing = 0 + changeRateStackView.addArrangedSubview(prefixLabel) + changeRateStackView.addArrangedSubview(valueLabel) + changeRateStackView.addArrangedSubview(suffixLabel) + } + + private func setupChart(photoStats: PhotoStats, month: String) { + addSubview(chartContainerView) + + setupLines() + setupBars(photoStats: photoStats, month: month) + setupMonthLabels(photoStats: photoStats, month: month) + } + + private func setupLines() { + linesStackView.axis = .vertical + linesStackView.distribution = .equalSpacing + chartContainerView.addSubview(linesStackView) + + for _ in 0.. 0 ? max((count / total) * Constants.maxBarHeight, Constants.minBarHeight) : 0 + let barView = GradientBarView(colors: gradientColors[i]) + + let label = UILabel() + label.text = "\(countInts[i])" + label.font = .systemFont(ofSize: 11, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + + barView.addSubview(label) + + barInfos.append(BarInfo(barView: barView, label: label, barHeight: barHeight)) + barsStackView.addArrangedSubview(barView) + } + } + + private func setupMonthLabels(photoStats: PhotoStats, month: String) { + monthLabelsStackView.axis = .horizontal + monthLabelsStackView.distribution = .fill + monthLabelsStackView.spacing = 60 + addSubview(monthLabelsStackView) + + let currentMonthNum = month.split(separator: "-").last.flatMap { Int($0) } ?? 1 + let previousMonthNum = currentMonthNum == 1 ? 12 : currentMonthNum - 1 + + for name in ["\(previousMonthNum)월", "\(currentMonthNum)월"] { + let label = UILabel() + label.setText(name, style: .p10, color: .gray200) + label.textAlignment = .center + label.snp.makeConstraints { $0.width.equalTo(60) } + monthLabelsStackView.addArrangedSubview(label) + } + } + + private func setupConstraints() { + headerStackView.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + chartContainerView.snp.makeConstraints { + $0.top.equalTo(headerStackView.snp.bottom).offset(24) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Constants.maxBarHeight) + } + + linesStackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + barsStackView.snp.makeConstraints { + $0.centerX.bottom.equalToSuperview() + $0.top.greaterThanOrEqualToSuperview() + } + + for info in barInfos { + info.barView.snp.makeConstraints { + $0.width.equalTo(60) + $0.height.equalTo(info.barHeight) + } + + info.label.snp.makeConstraints { + $0.top.equalToSuperview().inset(6) + $0.centerX.equalToSuperview() + } + } + + monthLabelsStackView.snp.makeConstraints { + $0.top.equalTo(chartContainerView.snp.bottom).offset(8) + $0.centerX.equalTo(barsStackView) + $0.width.equalTo(barsStackView) + $0.bottom.equalToSuperview().inset(20) + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift new file mode 100644 index 00000000..caae2ed1 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Components/InsightTopMenuView.swift @@ -0,0 +1,172 @@ +// +// InsightTopMenuView.swift +// Presentation +// + +import DesignSystem +import Domain +import SnapKit +import UIKit + +final class InsightTopMenuView: UIView { + + // MARK: - Constants + + private enum Constants { + static let maxBarHeight: CGFloat = 160 + static let lineCount: Int = 6 + static let barSpacing: CGFloat = 8 + static let weekLabelHeight: CGFloat = 20 + } + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let separatorView = UIView() + private let chartContainerView = UIView() + private let linesStackView = UIStackView() + private let barsStackView = UIStackView() + + // MARK: - Init + + init(weeklyStats: WeeklyStats) { + super.init(frame: .zero) + setupUI(weeklyStats: weeklyStats) + setupConstraints() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI(weeklyStats: WeeklyStats) { + backgroundColor = .sd900 + layer.cornerRadius = 16 + clipsToBounds = true + + setupTitle(week: weeklyStats.mostActiveWeek) + setupSeparator() + setupChart(weeklyStats: weeklyStats) + } + + private func setupTitle(week: Int) { + let attributed = NSMutableAttributedString() + attributed.append(Typography.hd15.styled("이번달 가장 자주먹은\n주차는 ", color: .white, lineSpacing: 6)) + attributed.append(Typography.hd15.styled("\(week)주차", color: .primary)) + + titleLabel.attributedText = attributed + titleLabel.numberOfLines = 0 + addSubview(titleLabel) + } + + private func setupSeparator() { + separatorView.backgroundColor = .sd800 + addSubview(separatorView) + } + + private func setupChart(weeklyStats: WeeklyStats) { + addSubview(chartContainerView) + + // Grid lines + linesStackView.axis = .vertical + linesStackView.distribution = .equalSpacing + chartContainerView.addSubview(linesStackView) + + for _ in 0.. UIView { + let column = UIView() + + let barView = GradientBarView(colors: [.primaryGradientStart, .primaryGradientEnd]) + column.addSubview(barView) + + let countLabel = UILabel() + countLabel.setText("\(weekCount.count)회", style: .p12, color: .white) + countLabel.textAlignment = .center + barView.addSubview(countLabel) + + let weekLabel = UILabel() + weekLabel.setText("\(weekCount.week)주차", style: .p10, color: .gray200) + weekLabel.textAlignment = .center + column.addSubview(weekLabel) + + let ratio = weekCount.count > 0 + ? CGFloat(weekCount.count) / CGFloat(maxCount) + : 0 + let barHeight = max(Constants.maxBarHeight * ratio, weekCount.count > 0 ? 24 : 0) + + barView.snp.makeConstraints { + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(weekLabel.snp.top).offset(-8) + $0.height.equalTo(barHeight) + } + + countLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(6) + $0.centerX.equalToSuperview() + } + + weekLabel.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(Constants.weekLabelHeight) + } + + return column + } + + private func setupConstraints() { + titleLabel.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(20) + $0.trailing.lessThanOrEqualToSuperview().inset(20) + } + + separatorView.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(0.5) + } + + chartContainerView.snp.makeConstraints { + $0.top.equalTo(separatorView.snp.bottom).offset(20) + $0.leading.trailing.equalToSuperview().inset(20) + $0.height.equalTo(Constants.maxBarHeight + Constants.weekLabelHeight + 8) + $0.bottom.equalToSuperview().inset(20) + } + + linesStackView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(Constants.maxBarHeight) + } + + barsStackView.snp.makeConstraints { + $0.leading.trailing.bottom.equalToSuperview() + $0.top.equalToSuperview() + } + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift new file mode 100644 index 00000000..95bad911 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightCoordinator.swift @@ -0,0 +1,21 @@ +// +// InsightCoordinator.swift +// Presentation +// + +import UIKit + +public final class InsightCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + + public init(factories: Factories) { + self.factories = factories + } + + public func makeViewController() -> UIViewController { + factories.insight.makeScene() + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift new file mode 100644 index 00000000..5d6fc128 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/Coordinator/InsightSceneFactory.swift @@ -0,0 +1,20 @@ +// +// InsightSceneFactory.swift +// Presentation +// + +import Data +import Domain +import UIKit + +public final class InsightSceneFactory { + private let useCase: FetchInsightUseCase + + public init(useCase: FetchInsightUseCase) { + self.useCase = useCase + } + + public func makeScene() -> InsightViewController { + InsightViewController(viewModel: InsightViewModel(fetchInsightUseCase: useCase)) + } +} diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift index 15f1c2e7..dd1cd6d3 100644 --- a/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewController.swift @@ -3,37 +3,68 @@ // Presentation // +import Combine import DesignSystem +import Domain import SnapKit import UIKit +private enum InsightConstants { + static let imageSize: CGFloat = 240 + static let textTopSpacing: CGFloat = 32 + static let sectionSpacing: CGFloat = 16 + static let contentInset: CGFloat = 20 +} + public final class InsightViewController: UIViewController { - // MARK: - Constants + // MARK: - UI Components - private enum Constants { - static let imageSize: CGFloat = 240 - static let textTopSpacing: CGFloat = 32 - } + private let scrollView: UIScrollView = { + let sv = UIScrollView() + sv.showsVerticalScrollIndicator = false + sv.isHidden = true + return sv + }() - // MARK: - UI Components + private let contentStackView: UIStackView = { + let sv = UIStackView() + sv.axis = .vertical + sv.spacing = InsightConstants.sectionSpacing + return sv + }() private let emptyImageView: UIImageView = { let iv = UIImageView() iv.image = DesignSystemAsset.emptyInsight.image iv.contentMode = .scaleAspectFit + iv.isHidden = true return iv }() private let descriptionLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 + label.isHidden = true return label }() + private let loadingIndicator: UIActivityIndicatorView = { + let indicator = UIActivityIndicatorView(style: .large) + indicator.color = .gray300 + indicator.hidesWhenStopped = true + return indicator + }() + + // MARK: - Properties + + private let viewModel: InsightViewModel + private var cancellables = Set() + // MARK: - Init - public init() { + public init(viewModel: InsightViewModel) { + self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } @@ -48,6 +79,8 @@ public final class InsightViewController: UIViewController { super.viewDidLoad() setupUI() setupConstraints() + setupBindings() + viewModel.input.send(.loadInsight) } // MARK: - Setup @@ -55,8 +88,12 @@ public final class InsightViewController: UIViewController { private func setupUI() { view.backgroundColor = .sdBase + view.addSubview(scrollView) + scrollView.addSubview(contentStackView) + view.addSubview(emptyImageView) view.addSubview(descriptionLabel) + view.addSubview(loadingIndicator) descriptionLabel.setText( "인사이트를 제공하기 위해\n최소 1주일간의 데이터가 필요해요.", @@ -68,15 +105,110 @@ public final class InsightViewController: UIViewController { } private func setupConstraints() { + scrollView.snp.makeConstraints { + $0.edges.equalTo(view.safeAreaLayoutGuide) + } + + contentStackView.snp.makeConstraints { + $0.edges.equalToSuperview().inset(InsightConstants.contentInset) + $0.width.equalToSuperview().offset(-InsightConstants.contentInset * 2) + } + emptyImageView.snp.makeConstraints { $0.centerX.equalToSuperview() $0.centerY.equalToSuperview().offset(-40) - $0.size.equalTo(Constants.imageSize) + $0.size.equalTo(InsightConstants.imageSize) } descriptionLabel.snp.makeConstraints { $0.centerX.equalToSuperview() - $0.top.equalTo(emptyImageView.snp.bottom).offset(Constants.textTopSpacing) + $0.top.equalTo(emptyImageView.snp.bottom).offset(InsightConstants.textTopSpacing) } + + loadingIndicator.snp.makeConstraints { + $0.center.equalToSuperview() + } + } + + private func setupBindings() { + viewModel.statePublisher + .map(\.isLoading) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] (isLoading: Bool) in + if isLoading { + self?.loadingIndicator.startAnimating() + } else { + self?.loadingIndicator.stopAnimating() + } + } + .store(in: &cancellables) + + viewModel.statePublisher + .map(\.hasInsufficientData) + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] (insufficientData: Bool) in + self?.emptyImageView.isHidden = !insufficientData + self?.descriptionLabel.isHidden = !insufficientData + self?.scrollView.isHidden = insufficientData + } + .store(in: &cancellables) + + viewModel.statePublisher + .compactMap(\.insight) + .first() + .receive(on: DispatchQueue.main) + .sink { [weak self] (insight: Insight) in + self?.buildContentSections(with: insight) + } + .store(in: &cancellables) + + viewModel.eventPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .loadFailed: + self?.showLoadFailedAlert() + } + } + .store(in: &cancellables) + } + + private func showLoadFailedAlert() { + let alert = UIAlertController( + title: "오류", + message: "데이터를 불러오는 데 실패했습니다.\n다시 시도해 주세요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "다시 시도", style: .default) { [weak self] _ in + self?.viewModel.input.send(.loadInsight) + }) + alert.addAction(UIAlertAction(title: "닫기", style: .cancel)) + present(alert, animated: true) + } + + // MARK: - Content + + private func buildContentSections(with insight: Insight) { + emptyImageView.isHidden = true + descriptionLabel.isHidden = true + scrollView.isHidden = false + + var sections: [UIView] = [ + InsightHeaderView(), + InsightPhotoStatsView(photoStats: insight.photoStats, month: insight.month), + InsightCategoryStatsView(categoryStats: insight.categoryStats), + InsightTopMenuView(weeklyStats: insight.weeklyStats), + InsightDiaryTimeStatsView(diaryTimeStats: insight.diaryTimeStats) + ] + + if !insight.locationStats.isEmpty { + sections.append(InsightLocationStatsView(locationStats: insight.locationStats)) + } + + sections.append(InsightKeywordsView(keywords: insight.tagStats)) + + sections.forEach { contentStackView.addArrangedSubview($0) } } } diff --git a/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift new file mode 100644 index 00000000..f05e70c1 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Insight/InsightViewModel.swift @@ -0,0 +1,98 @@ +// +// InsightViewModel.swift +// Presentation +// + +import Combine +import Domain +import Foundation + +public final class InsightViewModel { + + // MARK: - Output + + public var statePublisher: AnyPublisher { + stateSubject.eraseToAnyPublisher() + } + + public private(set) var state: State { + get { stateSubject.value } + set { stateSubject.value = newValue } + } + + public var eventPublisher: AnyPublisher { + eventSubject.eraseToAnyPublisher() + } + + // MARK: - Input + + public let input = PassthroughSubject() + + // MARK: - Private + + private let stateSubject: CurrentValueSubject + private let eventSubject = PassthroughSubject() + private var cancellables = Set() + + // MARK: - Dependencies + + private let fetchInsightUseCase: FetchInsightUseCase + + // MARK: - Init + + public init(fetchInsightUseCase: FetchInsightUseCase) { + self.fetchInsightUseCase = fetchInsightUseCase + self.stateSubject = CurrentValueSubject(State()) + setupBindings() + } + + // MARK: - Setup + + private func setupBindings() { + input + .sink { [weak self] action in + guard let self else { return } + Task(priority: .userInitiated) { + await self.handleInput(action) + } + } + .store(in: &cancellables) + } + + private func handleInput(_ action: Input) async { + switch action { + case .loadInsight: + state.isLoading = true + do { + let insight = try await fetchInsightUseCase.execute() + state.insight = insight + state.isLoading = false + } catch InsightError.insufficientData { + state.hasInsufficientData = true + state.isLoading = false + } catch { + state.isLoading = false + eventSubject.send(.loadFailed(error)) + } + } + } +} + +// MARK: - State / Input / Event + +public extension InsightViewModel { + + struct State: Equatable { + public var isLoading: Bool = false + public var insight: Insight? + public var hasInsufficientData: Bool = false + } + + enum Input { + case loadInsight + } + + enum Event { + case loadFailed(Error) + } +} diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift new file mode 100644 index 00000000..60bd3729 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginCoordinator.swift @@ -0,0 +1,23 @@ +// +// LoginCoordinator.swift +// Presentation +// + +import UIKit + +public final class LoginCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + public weak var sceneTransitioner: SceneTransitioning? + + private let factories: Factories + + public init(factories: Factories) { + self.factories = factories + } + + public func start() { + let vc = factories.login.makeScene() + sceneTransitioner?.transition(to: vc) + } +} diff --git a/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift new file mode 100644 index 00000000..97aa625e --- /dev/null +++ b/FoodDiary/Presentation/Sources/Login/Coordinator/LoginSceneFactory.swift @@ -0,0 +1,18 @@ +// +// LoginSceneFactory.swift +// Presentation +// + +import Domain + +public final class LoginSceneFactory { + private let useCase: FinalizeAppleLoginUseCase + + public init(useCase: FinalizeAppleLoginUseCase) { + self.useCase = useCase + } + + public func makeScene() -> LoginViewController { + LoginViewController(viewModel: LoginViewModel(finalizeAppleLoginUseCase: useCase)) + } +} diff --git a/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift b/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift index dfb58bcb..84c37b44 100644 --- a/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift +++ b/FoodDiary/Presentation/Sources/Login/View/LoginViewController.swift @@ -6,19 +6,11 @@ // import AuthenticationServices -import Combine import UIKit import SnapKit import DesignSystem -import Domain final public class LoginViewController: UIViewController { - private let didLoginSubject = PassthroughSubject() - - public var didLoginPublisher: AnyPublisher { - didLoginSubject.eraseToAnyPublisher() - } - private let viewModel: LoginViewModel public init(viewModel: LoginViewModel) { @@ -94,8 +86,7 @@ extension LoginViewController: ASAuthorizationControllerDelegate { let tokenString = String(data: token, encoding: .utf8) { Task { do { - let result = try await viewModel.sendIdentityToken(tokenString) - didLoginSubject.send(result) + try await viewModel.sendIdentityToken(tokenString) } catch { print(error.localizedDescription) } diff --git a/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift b/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift index 69d3ed8a..60cf5bd4 100644 --- a/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift +++ b/FoodDiary/Presentation/Sources/Login/ViewModel/LoginViewModel.swift @@ -15,7 +15,7 @@ final public class LoginViewModel { self.finalizeAppleLoginUseCase = finalizeAppleLoginUseCase } - public func sendIdentityToken(_ token: String) async throws -> LoginResult { - return try await finalizeAppleLoginUseCase.execute(token) + public func sendIdentityToken(_ token: String) async throws { + try await finalizeAppleLoginUseCase.execute(token) } } diff --git a/FoodDiary/Presentation/Sources/Main/MainViewController.swift b/FoodDiary/Presentation/Sources/Main/MainViewController.swift deleted file mode 100644 index ac8609de..00000000 --- a/FoodDiary/Presentation/Sources/Main/MainViewController.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// MainViewController.swift -// Presentation -// -// Created by 강대훈 on 1/23/26. -// - -import Combine -import Domain -import UIKit - -public final class MainViewController: UIViewController { - private let authRepository: AuthRepository - private let didLogoutSubject = PassthroughSubject() - - public var didLogoutPublisher: AnyPublisher { - didLogoutSubject.eraseToAnyPublisher() - } - - public init(authRepository: AuthRepository) { - self.authRepository = authRepository - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func viewDidLoad() { - super.viewDidLoad() - configureUI() - } -} - -private extension MainViewController { - func configureUI() { - view.backgroundColor = .systemBackground - - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textColor = .white - label.text = "메인화면" - - let logoutButton = UIButton(type: .system) - logoutButton.translatesAutoresizingMaskIntoConstraints = false - logoutButton.setTitle("로그아웃", for: .normal) - logoutButton.addTarget(self, action: #selector(logoutButtonTapped), for: .touchUpInside) - - view.addSubview(label) - view.addSubview(logoutButton) - - NSLayoutConstraint.activate([ - label.centerYAnchor.constraint(equalTo: view.centerYAnchor), - label.centerXAnchor.constraint(equalTo: view.centerXAnchor), - - logoutButton.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 24), - logoutButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - ]) - } - - @objc - func logoutButtonTapped() { - do { - try authRepository.logout() - didLogoutSubject.send() - } catch { - // TODO: 에러 처리 전략 확정 후 구체적인 UI 처리 - print("로그아웃 실패: \(error)") - } - } -} - diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift new file mode 100644 index 00000000..c5a26172 --- /dev/null +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageCoordinator.swift @@ -0,0 +1,50 @@ +// +// MyPageCoordinator.swift +// Presentation +// + +import Combine +import UIKit + +public final class MyPageCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var navigationController: UINavigationController? + private var cancellables = Set() + + public init(factories: Factories, navigationController: UINavigationController?) { + self.factories = factories + self.navigationController = navigationController + } + + public func start() { + let vc = factories.myPage.makeScene() + vc.hidesBottomBarWhenPushed = true + flowBind(vc: vc) + navigationController?.pushViewController(vc, animated: true) + } +} + +// MARK: - Screen Routing + +private extension MyPageCoordinator { + func flowBind(vc: MyPageViewController) { + vc.flowPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .finish: + self?.finish() + } + } + .store(in: &cancellables) + } +} + +private extension MyPageCoordinator { + func finish() { + parentCoordinator?.removeChild(self) + } +} diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift new file mode 100644 index 00000000..c5068c62 --- /dev/null +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageFlow.swift @@ -0,0 +1,10 @@ +// +// MyPageFlow.swift +// Presentation +// + +import Foundation + +public enum MyPageFlow { + case finish +} diff --git a/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift new file mode 100644 index 00000000..b58bc2df --- /dev/null +++ b/FoodDiary/Presentation/Sources/MyPage/Coordinator/MyPageSceneFactory.swift @@ -0,0 +1,42 @@ +// +// MyPageSceneFactory.swift +// Presentation +// + +import Domain + +public final class MyPageSceneFactory { + private let updateDeviceUseCase: UpdateDeviceNotificationSettingUseCase + private let notificationAuthProvider: NotificationAuthorizationProviding + private let logoutUseCase: LogoutUseCase + private let withdrawUserUseCase: WithdrawUserUseCase + private let getNicknameUseCase: GetNicknameUseCase + private let getAppVersionUseCase: GetAppVersionUseCase + + public init( + updateDeviceUseCase: UpdateDeviceNotificationSettingUseCase, + notificationAuthProvider: NotificationAuthorizationProviding, + logoutUseCase: LogoutUseCase, + withdrawUserUseCase: WithdrawUserUseCase, + getNicknameUseCase: GetNicknameUseCase, + getAppVersionUseCase: GetAppVersionUseCase + ) { + self.updateDeviceUseCase = updateDeviceUseCase + self.notificationAuthProvider = notificationAuthProvider + self.logoutUseCase = logoutUseCase + self.withdrawUserUseCase = withdrawUserUseCase + self.getNicknameUseCase = getNicknameUseCase + self.getAppVersionUseCase = getAppVersionUseCase + } + + public func makeScene() -> MyPageViewController { + MyPageViewController(viewModel: MyPageViewModel( + updateDeviceNotificationSettingUseCase: updateDeviceUseCase, + notificationAuthorizationProvider: notificationAuthProvider, + logoutUseCase: logoutUseCase, + withdrawUserUseCase: withdrawUserUseCase, + getNicknameUseCase: getNicknameUseCase, + getAppVersionUseCase: getAppVersionUseCase + )) + } +} diff --git a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift index cec927a7..90df47dd 100644 --- a/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift +++ b/FoodDiary/Presentation/Sources/MyPage/MyPageViewController.swift @@ -53,22 +53,19 @@ public final class MyPageViewController: UIViewController { case logout } - // MARK: - Output - public var didLogoutPublisher: AnyPublisher { - viewModel.eventPublisher - .compactMap { event -> Void? in - switch event { - case .didLogout, .didWithdraw: return () - } - } - .eraseToAnyPublisher() - } // MARK: - Dependencies private let viewModel: MyPageViewModel + // MARK: - Flow + + private let flowSubject = PassthroughSubject() + public var flowPublisher: AnyPublisher { + flowSubject.eraseToAnyPublisher() + } + // MARK: - State private var cancellables = Set() @@ -118,6 +115,13 @@ public final class MyPageViewController: UIViewController { NotificationCenter.default.removeObserver(self) } + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent { + flowSubject.send(.finish) + } + } + public override func viewDidLoad() { super.viewDidLoad() setupNavigation() @@ -411,7 +415,11 @@ extension MyPageViewController: UITableViewDelegate { switch row { case .notificationSetting: - openAppSettings() + if viewModel.state.isNotificationEnabled { + showNotificationDisableConfirmAlert() + } else { + openAppSettings() + } case .privacy: openExternalLink("https://www.notion.so/enebin/311c690fca2b80bc8c89c5f7cc5cb35c?source=copy_link") case .customerInquiry: @@ -438,6 +446,19 @@ extension MyPageViewController: UITableViewDelegate { present(alert, animated: true) } + private func showNotificationDisableConfirmAlert() { + let alert = UIAlertController( + title: "알림을 끄시겠어요?", + message: "알림을 끄면 AI 분석결과와 기록 리마인드 알림을 받아 볼 수 없어요.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "취소", style: .cancel)) + alert.addAction(UIAlertAction(title: "설정으로 이동", style: .default) { [weak self] _ in + self?.openAppSettings() + }) + present(alert, animated: true) + } + private func openAppSettings() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) diff --git a/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift new file mode 100644 index 00000000..8ded2773 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionCoordinator.swift @@ -0,0 +1,28 @@ +// +// PermissionCoordinator.swift +// Presentation +// + +import UIKit + +public final class PermissionCoordinator: Coordinator { + public var childCoordinators: [any Coordinator] = [] + public weak var parentCoordinator: (any Coordinator)? + + private let factories: Factories + private weak var presentingViewController: UIViewController? + + public init( + factories: Factories, + presentingViewController: UIViewController? + ) { + self.factories = factories + self.presentingViewController = presentingViewController + } + + public func start() { + let vc = factories.permission.makeScene() + vc.modalPresentationStyle = .fullScreen + presentingViewController?.present(vc, animated: true) + } +} diff --git a/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift new file mode 100644 index 00000000..0291ffb9 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Permission/Coordinator/PermissionSceneFactory.swift @@ -0,0 +1,12 @@ +// +// PermissionSceneFactory.swift +// Presentation +// + +public final class PermissionSceneFactory { + public init() {} + + public func makeScene() -> PermissionViewController { + PermissionViewController() + } +} diff --git a/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift b/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift new file mode 100644 index 00000000..b4f5f529 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Permission/PermissionViewController.swift @@ -0,0 +1,174 @@ +// +// PermissionViewController.swift +// Presentation +// + +import DesignSystem +import SnapKit +import UIKit + +/// 권한 요청 화면 +public final class PermissionViewController: UIViewController { + + // MARK: - UI + + private let titleLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 2 + label.textAlignment = .left + label.setText("앱 사용을 위해\n접근 권한을 허용해주세요", style: .hd18, lineSpacing: 6) + label.textColor = DesignSystemAsset.gray050.color + return label + }() + + private let sectionLabel: UILabel = { + let label = UILabel() + label.textAlignment = .left + label.setText("필수적 접근 권한", style: .p14) + label.textColor = DesignSystemAsset.gray400.color + return label + }() + + private let permissionCardView: UIView = { + let view = UIView() + view.backgroundColor = .white.withAlphaComponent(0.02) + view.layer.cornerRadius = 16 + return view + }() + + private let iconContainerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 10 + return view + }() + + private let permissionIconImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = DesignSystemAsset.iconPermissionPhoto.image + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let permissionNameLabel: UILabel = { + let label = UILabel() + label.textColor = DesignSystemAsset.gray050.color + label.setText("사진", style: .p15) + return label + }() + + private let permissionDescriptionLabel: UILabel = { + let label = UILabel() + label.setText("기록 시 사진 사용", style: .p15) + label.textColor = DesignSystemAsset.gray400.color + return label + }() + + private let warningLabel: UILabel = { + let label = UILabel() + label.textAlignment = .left + label.setText("권한 허용이 되지 않는다면 앱을 사용할 수 없습니다.", style: .p12) + label.textColor = DesignSystemAsset.gray400.color + return label + }() + + private let settingsButton: MumukPrimaryButton = { + let button = MumukPrimaryButton() + button.configure(title: "동의하고 시작하기") + return button + }() + + // MARK: - Init + + public init() { + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + setupUI() + setupAction() + } +} + +// MARK: - Setup + +private extension PermissionViewController { + func setupUI() { + view.backgroundColor = DesignSystemAsset.sdBase.color + + view.addSubview(titleLabel) + view.addSubview(sectionLabel) + view.addSubview(permissionCardView) + view.addSubview(warningLabel) + view.addSubview(settingsButton) + + permissionCardView.addSubview(iconContainerView) + iconContainerView.addSubview(permissionIconImageView) + permissionCardView.addSubview(permissionNameLabel) + permissionCardView.addSubview(permissionDescriptionLabel) + + titleLabel.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).offset(60) + $0.leading.trailing.equalToSuperview().inset(24) + } + + sectionLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(40) + $0.leading.equalToSuperview().inset(24) + } + + permissionCardView.snp.makeConstraints { + $0.top.equalTo(sectionLabel.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(24) + $0.height.equalTo(72) + } + + iconContainerView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.size.equalTo(40) + } + + permissionIconImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.size.equalTo(22) + } + + permissionNameLabel.snp.makeConstraints { + $0.leading.equalTo(iconContainerView.snp.trailing).offset(2) + $0.centerY.equalToSuperview() + } + + permissionDescriptionLabel.snp.makeConstraints { + $0.leading.equalTo(permissionNameLabel.snp.trailing).offset(8) + $0.centerY.equalToSuperview() + } + + warningLabel.snp.makeConstraints { + $0.top.equalTo(permissionCardView.snp.bottom).offset(16) + $0.leading.trailing.equalToSuperview().inset(24) + } + + settingsButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(24) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(16) + $0.height.equalTo(56) + } + } + + func setupAction() { + settingsButton.addTarget(self, action: #selector(didTapSettings), for: .touchUpInside) + } + + @objc func didTapSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift b/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift index 244f5b02..584fcfb4 100644 --- a/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift +++ b/FoodDiary/Presentation/Sources/TabBar/RootTabBarController.swift @@ -12,23 +12,19 @@ import Combine public final class RootTabBarController: UITabBarController { let calendarVC: CalendarViewController let insightVC: UIViewController - private let myPageViewControllerFactory: (@escaping () -> Void) -> UIViewController - private let didLogoutSubject = PassthroughSubject() + private let mypageButtonTapSubject = PassthroughSubject() private var cancellables = Set() - public var didLogoutPublisher: AnyPublisher { - didLogoutSubject.eraseToAnyPublisher() + public var mypageButtonTapPublisher: AnyPublisher { + mypageButtonTapSubject.eraseToAnyPublisher() } public init( - weeklyVC: UIViewController, - monthlyVC: UIViewController, - insightVC: UIViewController, - myPageViewControllerFactory: @escaping (@escaping () -> Void) -> UIViewController + calendarVC: CalendarViewController, + insightVC: UIViewController ) { - self.calendarVC = CalendarViewController(weeklyVC: weeklyVC, monthlyVC: monthlyVC) + self.calendarVC = calendarVC self.insightVC = insightVC - self.myPageViewControllerFactory = myPageViewControllerFactory super.init(nibName: nil, bundle: nil) } @@ -83,20 +79,16 @@ public final class RootTabBarController: UITabBarController { } @objc private func mypageButtonTapped() { - let myPageVC = myPageViewControllerFactory { [weak self] in - self?.didLogoutSubject.send() - } - myPageVC.hidesBottomBarWhenPushed = true - navigationController?.pushViewController(myPageVC, animated: true) + mypageButtonTapSubject.send() } - + private func toggleViewMode() { calendarVC.toggleViewMode() } - + private func updateToggleIcon(for mode: CalendarViewController.ViewMode) { guard let items = tabBar.items, items.count > 2 else { return } - + let newImage = mode == .monthly ? DesignSystemAsset.iconWeekly.image : DesignSystemAsset.iconMonthly.image items[2].image = newImage } @@ -105,15 +97,15 @@ public final class RootTabBarController: UITabBarController { extension RootTabBarController: UITabBarControllerDelegate { public func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { guard let index = viewControllers?.firstIndex(of: viewController) else { return true } - + if index == 2 { toggleViewMode() return false } - + return true } - + public func tabBarController(_ tabBarController: UITabBarController, didSelect viewController: UIViewController) { for subview in self.tabBar.subviews { if subview.frame.origin.x > self.tabBar.bounds.width * 0.6 { diff --git a/README.md b/README.md index 77bc0023..29091c4f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ -# FoodDiary-iOS -내향인들의 푸드 다이어리 iOS 레포 +
+ icon
+ 뭐먹었지 +
+ +## 🍳 뭐먹었지? + +식사를 특별하게. 오늘 뭐 먹었는지 기록하고, 나만의 식사 패턴을 발견해보세요. + +#### 📸 음식 기록하기 + +사진과 함께 먹은 장소, 메모를 남겨 나만의 푸드 다이어리를 완성해보세요. + +#### 📅 캘린더로 보기 + +주간, 월간 캘린더로 한눈에 내 식사 기록을 확인할 수 있어요. + +#### 📊 인사이트 + +주차별 통계와 위치 기반 분석으로 나의 식습관을 되돌아보세요. + +## 💻 Environment + +- iOS 18.0 ~ +- Xcode 16 ~ +- Swift 5.9 ~ + +## ⚒️ Tech Spec + +### Architecture + +- Clean Architecture +- MVVM + +### DI + +- Swinject + +### UI + +- UIKit +- SnapKit +- Combine + +### Async Programming + +- Swift Concurrency + +### Image + +- Kingfisher +- TensorFlowLite + +### Build + +- Tuist +- SPM + +### ETC + +- Firebase (Core, Messaging) +- SwiftLog diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 7b191ec5..97fbdccc 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -18,5 +18,6 @@ let package = Package( .package(url: "https://github.com/onevcat/Kingfisher.git", from: "8.6.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.0.0"), + .package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "9.5.0"), ] ) diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift index 23ae62aa..57ce95d2 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist+Templates.swift @@ -30,8 +30,9 @@ extension InfoPlist { "UIImageName": "", ], "NSPhotoLibraryUsageDescription": "음식 사진을 분류하기 위해 사진 라이브러리 접근 권한이 필요합니다.", - "NSUserNotificationsUsageDescription": "AI 분석 완료 알림을 받기 위해선 알림 권한이 필요합니다.", + "NSUserNotificationsUsageDescription": "AI 분석 완료 및 매일 식단 기록 리마인더 알림을 받기 위해선 알림 권한이 필요합니다.", "BASE_URL": "$(BASE_URL)", + "SENTRY_DSN": "$(SENTRY_DSN)", "NSAppTransportSecurity": [ "NSAllowsArbitraryLoads": true ], diff --git a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift index 13b5f707..6feba42c 100644 --- a/Tuist/ProjectDescriptionHelpers/Project+Templates.swift +++ b/Tuist/ProjectDescriptionHelpers/Project+Templates.swift @@ -9,10 +9,13 @@ import ProjectDescription public extension ProjectDescription.Settings { static func makeSettings() -> Self { - return .settings(configurations: [ - .debug(name: "Debug", xcconfig: "../../Configs/debug.xcconfig"), - .release(name: "Release", xcconfig: "../../Configs/release.xcconfig"), - ]) + return .settings( + base: ["DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym"], + configurations: [ + .debug(name: "Debug", xcconfig: "../../Configs/debug.xcconfig"), + .release(name: "Release", xcconfig: "../../Configs/release.xcconfig"), + ] + ) } }