diff --git a/FoodDiary/App/Sources/AppFlowController.swift b/FoodDiary/App/Sources/AppFlowController.swift index 43e3349c..d6b77a95 100644 --- a/FoodDiary/App/Sources/AppFlowController.swift +++ b/FoodDiary/App/Sources/AppFlowController.swift @@ -24,6 +24,7 @@ final class AppFlowController: UIViewController { private var cancellables = Set() private let container: DIContainer private var pendingDeepLinkDate: String? + private var splashView: SplashView? public init(container: DIContainer) { self.container = container @@ -37,11 +38,19 @@ final class AppFlowController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .sdBase + setupSplashView() setupNetworkMonitoring() setupAnalysisCompletionToast() setupDeepLinkHandling() } + private func setupSplashView() { + let splash = SplashView() + view.addSubview(splash) + splash.snp.makeConstraints { $0.edges.equalToSuperview() } + splashView = splash + } + private func setupAnalysisCompletionToast() { NotificationCenter.default.publisher(for: AppNotification.Push.analysisResult) .receive(on: DispatchQueue.main) @@ -85,9 +94,12 @@ extension AppFlowController { fileprivate func proceedToNextScreen() { Task { - let isLogin = await validateToken() + prefetchFoodImageAssets() - try? await fetchUserProfile() + async let minimumDelay: Void = Task.sleep(for: .seconds(1.5)) + async let loginResult = validateTokenAndFetchProfile() + + let (_, isLogin) = try await (minimumDelay, loginResult) await MainActor.run { [weak self] in guard let self else { return } @@ -98,9 +110,26 @@ extension AppFlowController { } } + private func removeSplashView() { + splashView?.animateRemoval() + splashView = nil + } + + private func prefetchFoodImageAssets() { + guard let useCase = try? container.resolve(FetchUseCase.self) else { return } + useCase.prefetch(forPreviousWeeks: 2, of: Date()) + } + + private func validateTokenAndFetchProfile() async -> Bool { + let isLogin = await validateToken() + try? await fetchUserProfile() + return isLogin + } + fileprivate func routeToAppropriateScreen(isLogin: Bool) { let destinationVC = isLogin ? createMainView() : createLoginView() transition(to: destinationVC) + removeSplashView() if isLogin { registerForRemoteNotificationsAfterLogin() } @@ -209,10 +238,12 @@ extension AppFlowController { return WeeklyCalendarViewController( viewModel: weeklyViewModel, imageProvider: imageProvider, - detailViewControllerFactory: { [weak self] date, records, onDismiss in + detailViewControllerFactory: { [weak self] date, records, scrollToMealType, shouldPopToRoot, onDismiss in self?.makeDetailViewController( date: date, records: records, + scrollToMealType: scrollToMealType, + shouldPopToRoot: shouldPopToRoot, onDismissWithDate: onDismiss ) ?? UIViewController() } @@ -231,10 +262,12 @@ extension AppFlowController { return MonthlyCalendarViewController( viewModel: monthlyViewModel, - detailViewControllerFactory: { [weak self] date, records, onDismiss in + detailViewControllerFactory: { [weak self] date, records, scrollToMealType, shouldPopToRoot, onDismiss in self?.makeDetailViewController( date: date, records: records, + scrollToMealType: scrollToMealType, + shouldPopToRoot: shouldPopToRoot, onDismissWithDate: onDismiss ) ?? UIViewController() } @@ -441,6 +474,8 @@ extension AppFlowController { fileprivate func makeDetailViewController( date: Date, records: [FoodRecord], + scrollToMealType: MealType? = nil, + shouldPopToRoot: Bool = false, onDismissWithDate: ((Date) -> Void)? = nil ) -> UIViewController { guard @@ -451,6 +486,8 @@ extension AppFlowController { return DetailViewController( viewModel: detailVM, + initialScrollTarget: scrollToMealType, + shouldPopToRoot: shouldPopToRoot, onDismissWithDate: onDismissWithDate, editViewControllerFactory: { [weak self] record in self?.makeEditViewController(for: record) ?? UIViewController() diff --git a/FoodDiary/App/Sources/SplashView.swift b/FoodDiary/App/Sources/SplashView.swift new file mode 100644 index 00000000..76ce3f07 --- /dev/null +++ b/FoodDiary/App/Sources/SplashView.swift @@ -0,0 +1,52 @@ +// +// SplashView.swift +// App +// + +import SnapKit +import UIKit + +final class SplashView: UIView { + init() { + super.init(frame: .zero) + + backgroundColor = UIColor( + red: 0.098, green: 0.094, blue: 0.129, alpha: 1 + ) + + let logoImageView = UIImageView(image: UIImage(named: "logo")) + logoImageView.contentMode = .scaleAspectFit + + let characterImageView = UIImageView(image: UIImage(named: "character")) + characterImageView.contentMode = .scaleAspectFit + + addSubview(logoImageView) + addSubview(characterImageView) + + characterImageView.snp.makeConstraints { + $0.centerX.centerY.equalToSuperview() + $0.width.equalTo(175) + $0.height.equalTo(145) + } + + logoImageView.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.bottom.equalTo(characterImageView.snp.top).offset(-27) + $0.width.equalTo(215) + $0.height.equalTo(64) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func animateRemoval(completion: (() -> Void)? = nil) { + UIView.animate(withDuration: 0.3, animations: { + self.alpha = 0 + }, completion: { _ in + self.removeFromSuperview() + completion?() + }) + } +} diff --git a/FoodDiary/Data/Sources/DTO/DiaryResponseDTO.swift b/FoodDiary/Data/Sources/DTO/DiaryResponseDTO.swift index fa37a880..40bb2348 100644 --- a/FoodDiary/Data/Sources/DTO/DiaryResponseDTO.swift +++ b/FoodDiary/Data/Sources/DTO/DiaryResponseDTO.swift @@ -27,7 +27,6 @@ public struct DiaryResponseDTO: Decodable { public let createdAt: String public let photos: [DiaryPhotoDTO] - // 새 API 필드 (도메인에서 미사용) public let note: String? public let photoCount: Int? public let userId: String? @@ -129,6 +128,7 @@ extension DiaryResponseDTO { restaurantUrl: restaurantUrl, address: roadAddress, hashtags: tags ?? [], + note: note, createdAt: createdDate, analysisStatus: status ) diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/Contents.json b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/Contents.json new file mode 100644 index 00000000..dc7cc779 --- /dev/null +++ b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "empty-image.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "empty-image 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "empty-image 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image 1.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image 1.png new file mode 100644 index 00000000..22925863 Binary files /dev/null and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image 1.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image 2.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image 2.png new file mode 100644 index 00000000..472a48a7 Binary files /dev/null and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image 2.png differ diff --git a/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image.png b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image.png new file mode 100644 index 00000000..5841e7d7 Binary files /dev/null and b/FoodDiary/DesignSystem/Resources/Assets.xcassets/empty-image.imageset/empty-image.png differ diff --git a/FoodDiary/Domain/Sources/Entity/FoodRecord.swift b/FoodDiary/Domain/Sources/Entity/FoodRecord.swift index a1e63b31..a2caa5b4 100644 --- a/FoodDiary/Domain/Sources/Entity/FoodRecord.swift +++ b/FoodDiary/Domain/Sources/Entity/FoodRecord.swift @@ -33,6 +33,7 @@ public struct FoodRecord: Identifiable, Equatable, Sendable { public let restaurantUrl: String? public let address: String? public let hashtags: [String] + public let note: String? public let createdAt: Date public let analysisStatus: AnalysisStatus @@ -55,6 +56,7 @@ public struct FoodRecord: Identifiable, Equatable, Sendable { restaurantUrl: String? = nil, address: String? = nil, hashtags: [String] = [], + note: String? = nil, createdAt: Date, analysisStatus: AnalysisStatus = .completed ) { @@ -67,6 +69,7 @@ public struct FoodRecord: Identifiable, Equatable, Sendable { self.restaurantUrl = restaurantUrl self.address = address self.hashtags = hashtags + self.note = note self.createdAt = createdAt self.analysisStatus = analysisStatus } diff --git a/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift b/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift index 18863e8f..e874de84 100644 --- a/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift +++ b/FoodDiary/Domain/Sources/UseCase/SaveFoodRecordUseCase.swift @@ -20,11 +20,12 @@ public struct SaveFoodRecordUseCase< /// 서버 업로드 수행 /// 분석 완료 시 Remote Push로 결과 수신 + @discardableResult public func execute( from assets: [any ImageAssetable], date: Date - ) async throws { + ) async throws -> [UploadResult] { let request = CreateFoodRecordRequest(date: date, assets: assets) - _ = try await repository.uploadRecord(request) + return try await repository.uploadRecord(request) } } diff --git a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift index 16459e0d..4ff81883 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/AddressSearchViewController.swift @@ -127,6 +127,7 @@ public final class AddressSearchViewController< super.viewDidLoad() setupUI() setupConstraints() + setupKeyboardDismissGesture() setupBindings() } @@ -246,6 +247,7 @@ public final class AddressSearchViewController< Task { @MainActor [weak self] in guard let self else { return } + self.containerHeightConstraint?.update(offset: self.view.bounds.height) self.view.layoutIfNeeded() let contentHeight = self.resultsTableView.contentSize.height self.containerHeightConstraint?.update(offset: contentHeight) diff --git a/FoodDiary/Presentation/Sources/AddressSearch/Components/AddressSearchResultCell.swift b/FoodDiary/Presentation/Sources/AddressSearch/Components/AddressSearchResultCell.swift index 2cda999b..6a456b8a 100644 --- a/FoodDiary/Presentation/Sources/AddressSearch/Components/AddressSearchResultCell.swift +++ b/FoodDiary/Presentation/Sources/AddressSearch/Components/AddressSearchResultCell.swift @@ -95,7 +95,7 @@ final class AddressSearchResultCell: UITableViewCell { selectButton.snp.makeConstraints { $0.trailing.equalToSuperview().offset(-Constants.horizontalPadding) - $0.top.equalToSuperview().offset(Constants.verticalPadding) + $0.centerY.equalToSuperview() } selectButton.setContentHuggingPriority(.required, for: .horizontal) selectButton.setContentCompressionResistancePriority(.required, for: .horizontal) diff --git a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift index 599db6dd..5f857d40 100644 --- a/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/CalendarViewController.swift @@ -50,17 +50,31 @@ public final class CalendarViewController: UIViewController { private func showViewController(for mode: ViewMode) { let targetVC = mode == .weekly ? weeklyVC : monthlyVC - if let currentChild { - currentChild.willMove(toParent: nil) - currentChild.view.removeFromSuperview() - currentChild.removeFromParent() + // 초기 로드: 애니메이션 없이 바로 추가 + guard let outgoingVC = currentChild else { + addChild(targetVC) + view.addSubview(targetVC.view) + targetVC.view.snp.makeConstraints { $0.edges.equalToSuperview() } + targetVC.didMove(toParent: self) + currentChild = targetVC + return } addChild(targetVC) + targetVC.view.alpha = 0 view.addSubview(targetVC.view) targetVC.view.snp.makeConstraints { $0.edges.equalToSuperview() } - targetVC.didMove(toParent: self) - currentChild = targetVC + UIView.animate(withDuration: 0.4) { + targetVC.view.alpha = 1 + outgoingVC.view.alpha = 0 + } completion: { _ in + outgoingVC.view.alpha = 1 + outgoingVC.willMove(toParent: nil) + outgoingVC.view.removeFromSuperview() + outgoingVC.removeFromParent() + targetVC.didMove(toParent: self) + self.currentChild = targetVC + } } } diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift index 94ad529b..8930e368 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/MonthlyCalendarViewController.swift @@ -21,7 +21,7 @@ public final class MonthlyCalendarViewController< // MARK: - Dependencies private let viewModel: MonthlyCalendarViewModel - private let detailViewControllerFactory: ((Date, [FoodRecord], ((Date) -> Void)?) -> UIViewController) + private let detailViewControllerFactory: ((Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController) // MARK: - UI Components @@ -69,7 +69,7 @@ public final class MonthlyCalendarViewController< public init( viewModel: MonthlyCalendarViewModel, - detailViewControllerFactory: @escaping (Date, [FoodRecord], ((Date) -> Void)?) -> UIViewController + detailViewControllerFactory: @escaping (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController ) { self.viewModel = viewModel self.detailViewControllerFactory = detailViewControllerFactory @@ -300,7 +300,7 @@ public final class MonthlyCalendarViewController< // MARK: - Navigation private func navigateToDetail(date: Date, records: [FoodRecord]) { - let detailVC = detailViewControllerFactory(date, records) { [weak self] date in + let detailVC = detailViewControllerFactory(date, records, nil, false) { [weak self] date in self?.viewModel.input.send(.updateMonth(date)) } detailVC.hidesBottomBarWhenPushed = true diff --git a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift index 3900a93f..27c60797 100644 --- a/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/MonthlyCalendar/ViewModel/MonthlyCalendarViewModel.swift @@ -59,7 +59,16 @@ public final class MonthlyCalendarViewModel< self.getNicknameUseCase = getNicknameUseCase let today = Date() + let calendar = Calendar.seoul + let period = calendar.monthlyCalendarPeriod(for: today) + let placeholderDays = Self.generatePlaceholderDays( + for: period, currentMonth: today, calendar: calendar + ) + self.stateSubject = CurrentValueSubject(State(currentDisplayDate: today)) + state.monthDays = placeholderDays + state.numberOfWeeks = placeholderDays.count / 7 + state.monthYearText = today.formatMonthText() setupBindings() input.send(.loadNickname) @@ -132,6 +141,36 @@ public final class MonthlyCalendarViewModel< } } + private static func generatePlaceholderDays( + for period: DateInterval, + currentMonth: Date, + calendar: Calendar + ) -> [MonthlyCalendarDay] { + let today = calendar.startOfDay(for: Date()) + let currentMonthComponents = calendar.dateComponents([.year, .month], from: currentMonth) + + var days: [MonthlyCalendarDay] = [] + var currentDate = period.start + + while currentDate < period.end { + let dateComponents = calendar.dateComponents([.year, .month], from: currentDate) + let isCurrentMonth = dateComponents.year == currentMonthComponents.year && + dateComponents.month == currentMonthComponents.month + + days.append(MonthlyCalendarDay( + date: currentDate, + dayNumber: calendar.component(.day, from: currentDate), + isCurrentMonth: isCurrentMonth, + isToday: calendar.isDate(currentDate, inSameDayAs: today), + imageURLs: [] + )) + guard let next = calendar.date(byAdding: .day, value: 1, to: currentDate) else { break } + currentDate = next + } + + return days + } + private func requestPhotoAuthorizationIfNeeded() async { let status = requestPhotoAuthorizationUseCase.currentStatus() if status == .notDetermined { diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift index bbaa8a1b..a6e36b80 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/ViewModel/WeeklyCalendarViewModel.swift @@ -66,11 +66,28 @@ public final class WeeklyCalendarViewModel< self.pushNotificationObserver = pushNotificationObserver self.getNicknameUseCase = getNicknameUseCase - self.calendar = Calendar.current + let cal = Calendar.current + self.calendar = cal - let today = calendar.startOfDay(for: Date()) + let today = cal.startOfDay(for: Date()) self.currentWeekBaseDate = today + + let (weekStart, _) = cal.weekRange(for: today) + let weekDates = cal.weekDates(from: weekStart) + let placeholderWeekDays = weekDates.map { dayDate in + WeeklyCalendarDay( + date: dayDate, + dayOfWeek: dayDate.formatDayOfWeek(), + dayNumber: dayDate.formatDayNumber(calendar: cal), + isToday: cal.isDateInToday(dayDate), + isFuture: cal.startOfDay(for: dayDate) > today, + records: [] + ) + } + self.stateSubject = CurrentValueSubject(State(selectedDate: today)) + state.weekDays = placeholderWeekDays + state.monthText = today.formatMonthText() setupBindings() input.send(.loadNickname) @@ -188,11 +205,12 @@ public final class WeeklyCalendarViewModel< guard !assets.isEmpty else { return } do { - try await saveFoodRecordUseCase.execute( + let results = try await saveFoodRecordUseCase.execute( from: assets, date: state.selectedDate ) - eventSubject.send(.uploadCompleted) + let mealType = results.first?.mealType ?? .breakfast + eventSubject.send(.uploadCompleted(date: state.selectedDate, mealType: mealType)) await loadWeekData(for: currentWeekBaseDate) await updateDateContent(for: state.selectedDate) } catch { @@ -288,7 +306,7 @@ extension WeeklyCalendarViewModel { public enum Event { case photoAuthorizationDenied - case uploadCompleted + case uploadCompleted(date: Date, mealType: MealType) case saveFailed(Error) case loadFailed(Error) } diff --git a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift index d6c8b0c0..13fe78d1 100644 --- a/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift +++ b/FoodDiary/Presentation/Sources/Calendar/WeeklyCalendar/WeeklyCalendarViewController.swift @@ -28,7 +28,7 @@ public final class WeeklyCalendarViewController< RecordRepo, AssetRepo, AuthRepo, PushObserver > private let imageProvider: ImageProvider - private let detailViewControllerFactory: (Date, [FoodRecord], ((Date) -> Void)?) -> UIViewController + private let detailViewControllerFactory: (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController // MARK: - UI Components @@ -71,7 +71,7 @@ public final class WeeklyCalendarViewController< RecordRepo, AssetRepo, AuthRepo, PushObserver >, imageProvider: ImageProvider, - detailViewControllerFactory: @escaping (Date, [FoodRecord], ((Date) -> Void)?) -> UIViewController + detailViewControllerFactory: @escaping (Date, [FoodRecord], MealType?, Bool, ((Date) -> Void)?) -> UIViewController ) { self.viewModel = viewModel self.imageProvider = imageProvider @@ -241,8 +241,8 @@ public final class WeeklyCalendarViewController< switch event { case .photoAuthorizationDenied: self?.showPhotoAuthorizationDeniedAlert() - case .uploadCompleted: - break + case .uploadCompleted(let date, let mealType): + self?.navigateToDetail(for: date, scrollTo: mealType, shouldPopToRoot: true) case .saveFailed(let error): self?.showSaveErrorAlert(error) case .loadFailed(let error): @@ -334,7 +334,6 @@ public final class WeeklyCalendarViewController< private func handleImagePickerResult(_ result: ImagePickerResult) { switch result { case .selected(let assets): - navigationController?.popViewController(animated: true) viewModel.input.send(.saveSelectedPhotos(assets)) case .cancelled: navigationController?.popViewController(animated: true) @@ -361,11 +360,13 @@ public final class WeeklyCalendarViewController< present(alert, animated: true) } - private func navigateToDetail(for date: Date) { + 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) { [weak self] date in + let detailVC = detailViewControllerFactory(date, records, mealType, shouldPopToRoot) { [weak self] date in self?.viewModel.input.send(.refreshData(date)) } navigationController?.pushViewController(detailVC, animated: true) } + + } diff --git a/FoodDiary/Presentation/Sources/Core/Typography.swift b/FoodDiary/Presentation/Sources/Core/Typography.swift index 3ab6f87e..ede4dd66 100644 --- a/FoodDiary/Presentation/Sources/Core/Typography.swift +++ b/FoodDiary/Presentation/Sources/Core/Typography.swift @@ -17,7 +17,7 @@ import DesignSystem /// label.attributedText = Typography.hd24.styled("뭐먹었지") /// ``` public enum Typography { - // MARK: - Headline (Bold, 130% line height) + // MARK: - Headline (Bold, 120% line height) /// 페이지 헤드라인 - 24pt Bold case hd24 /// 페이지 타이틀 - 20pt Bold @@ -27,7 +27,7 @@ public enum Typography { /// 페이지 서브 타이틀 2nd - 16pt Bold case hd16 - // MARK: - Paragraph (Regular, 100% line height) + // MARK: - Paragraph (Regular, 120% line height) /// 본문 - 18pt Regular case p18 /// 캡션 - 15pt Regular @@ -63,13 +63,8 @@ public enum Typography { } } - private var lineHeightMultiple: CGFloat { - switch self { - case .hd24, .hd20, .hd18, .hd16: - return 1.0 - case .p18, .p15, .p14, .p12, .p10: - return 1.0 - } + private var lineHeight: CGFloat { + font.pointSize * 1.15 } private var letterSpacing: CGFloat { @@ -78,15 +73,19 @@ public enum Typography { public func styled(_ text: String, color: UIColor = .white, alignment: NSTextAlignment = .natural, lineSpacing: CGFloat = 0) -> NSAttributedString { let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.lineHeightMultiple = lineHeightMultiple + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight paragraphStyle.alignment = alignment paragraphStyle.lineSpacing = lineSpacing + let baselineOffset = (lineHeight - font.lineHeight) / 4 + return NSAttributedString(string: text, attributes: [ .font: font, .kern: letterSpacing, .foregroundColor: color, - .paragraphStyle: paragraphStyle + .paragraphStyle: paragraphStyle, + .baselineOffset: baselineOffset ]) } } diff --git a/FoodDiary/Presentation/Sources/Core/UIViewController+DismissKeyboard.swift b/FoodDiary/Presentation/Sources/Core/UIViewController+DismissKeyboard.swift new file mode 100644 index 00000000..8e765f56 --- /dev/null +++ b/FoodDiary/Presentation/Sources/Core/UIViewController+DismissKeyboard.swift @@ -0,0 +1,18 @@ +// +// UIViewController+DismissKeyboard.swift +// Presentation +// + +import UIKit + +extension UIViewController { + func setupKeyboardDismissGesture() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboardOnTap)) + tapGesture.cancelsTouchesInView = false + view.addGestureRecognizer(tapGesture) + } + + @objc private func dismissKeyboardOnTap() { + view.endEditing(true) + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift b/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift index 6fb6dda8..4469d11e 100644 --- a/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift +++ b/FoodDiary/Presentation/Sources/Detail/Components/MealRecordedContentView.swift @@ -22,6 +22,7 @@ final class MealRecordedContentView: UIView { static let hashtagTopSpacing: CGFloat = 16 static let buttonImagePadding: CGFloat = 4 static let textHorizontalInset: CGFloat = 10 + static let noteTopSpacing: CGFloat = 16 } // MARK: - Types @@ -47,6 +48,7 @@ final class MealRecordedContentView: UIView { private var labelTrailingWithoutButton: Constraint? private var infoTopWithPageControl: Constraint? private var infoTopWithoutPageControl: Constraint? + private var infoBottomConstraint: Constraint? // MARK: - UI Components @@ -96,7 +98,13 @@ final class MealRecordedContentView: UIView { return button }() - private let hashtagLabel = UILabel() + private let hashtagLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + + private var noteView: NoteContentView? // MARK: - Init @@ -184,7 +192,6 @@ final class MealRecordedContentView: UIView { hashtagLabel.snp.makeConstraints { $0.top.equalTo(restaurantNameLabel.snp.bottom).offset(Constants.hashtagTopSpacing) $0.leading.trailing.equalToSuperview() - $0.bottom.equalToSuperview() } } @@ -270,6 +277,31 @@ final class MealRecordedContentView: UIView { let hashtagText = record.hashtags.map { "#\($0)" }.joined(separator: " ") hashtagLabel.setText(hashtagText, style: .p12, color: .white) hashtagLabel.isHidden = record.hashtags.isEmpty + + configureNoteSection(with: record) + } + + private func configureNoteSection(with record: FoodRecord) { + noteView?.removeFromSuperview() + noteView = nil + infoBottomConstraint?.deactivate() + + let hasNote = !(record.note ?? "").isEmpty + + if hasNote { + let newNoteView = NoteContentView(note: record.note!) + infoContainerView.addSubview(newNoteView) + newNoteView.snp.makeConstraints { + $0.top.equalTo(hashtagLabel.snp.bottom).offset(Constants.noteTopSpacing) + $0.leading.trailing.equalToSuperview() + infoBottomConstraint = $0.bottom.equalToSuperview().constraint + } + noteView = newNoteView + } else { + hashtagLabel.snp.makeConstraints { + infoBottomConstraint = $0.bottom.equalToSuperview().constraint + } + } } private func updateCurrentRecord(for page: Int) { diff --git a/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift b/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift new file mode 100644 index 00000000..697b5e7f --- /dev/null +++ b/FoodDiary/Presentation/Sources/Detail/Components/NoteContentView.swift @@ -0,0 +1,89 @@ +// +// NoteContentView.swift +// Presentation +// + +import DesignSystem +import SnapKit +import UIKit + +/// AI 요약 노트를 표시하는 뷰 +final class NoteContentView: UIView { + + // MARK: - Constants + + private enum Constants { + static let containerPadding: CGFloat = 16 + static let cornerRadius: CGFloat = 10 + static let headerIconSize: CGFloat = 10 + static let headerSpacing: CGFloat = 4 + static let contentTopSpacing: CGFloat = 12 + } + + // MARK: - UI Components + + private let headerIconView: UIImageView = { + let imageView = UIImageView() + imageView.image = DesignSystemAsset.iconAi.image + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private let headerLabel = UILabel() + + private let contentLabel: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + return label + }() + + // MARK: - Init + + init(note: String) { + super.init(frame: .zero) + setupUI() + setupConstraints() + configure(note: note) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Setup + + private func setupUI() { + backgroundColor = .sd900 + layer.cornerRadius = Constants.cornerRadius + clipsToBounds = true + + addSubview(headerIconView) + addSubview(headerLabel) + addSubview(contentLabel) + } + + private func setupConstraints() { + headerIconView.snp.makeConstraints { + $0.top.leading.equalToSuperview().inset(Constants.containerPadding) + $0.size.equalTo(Constants.headerIconSize) + } + + headerLabel.snp.makeConstraints { + $0.centerY.equalTo(headerIconView) + $0.leading.equalTo(headerIconView.snp.trailing).offset(Constants.headerSpacing) + $0.trailing.lessThanOrEqualToSuperview().inset(Constants.containerPadding) + } + + contentLabel.snp.makeConstraints { + $0.top.equalTo(headerIconView.snp.bottom).offset(Constants.contentTopSpacing) + $0.leading.trailing.equalToSuperview().inset(Constants.containerPadding) + $0.bottom.equalToSuperview().inset(Constants.containerPadding) + } + } + + private func configure(note: String) { + headerLabel.setText("AI가 요약했어요", style: .p12, color: .gray050) + contentLabel.setText(note, style: .p12, color: .gray100, lineSpacing: 4) + } +} diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift index aa505c8d..b071560d 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewController.swift @@ -113,11 +113,17 @@ public final class DetailViewController< // MARK: - State private var cancellables = Set() + private var pendingScrollTarget: MealType? + private let scrollToFirstRecord: Bool + private let shouldPopToRoot: Bool // MARK: - Init public init( viewModel: DetailViewModel, + initialScrollTarget: MealType? = nil, + scrollToFirstRecord: Bool = true, + shouldPopToRoot: Bool = false, onDismissWithDate: ((Date) -> Void)? = nil, editViewControllerFactory: ((FoodRecord) -> UIViewController)? = nil, presentImagePickerHandler: ( @@ -125,6 +131,9 @@ public final class DetailViewController< )? = nil ) { self.viewModel = viewModel + self.pendingScrollTarget = initialScrollTarget + self.scrollToFirstRecord = scrollToFirstRecord + self.shouldPopToRoot = shouldPopToRoot self.onDismissWithDate = onDismissWithDate self.editViewControllerFactory = editViewControllerFactory self.presentImagePickerHandler = presentImagePickerHandler @@ -166,6 +175,15 @@ public final class DetailViewController< private func setupNavigation() { title = "상세보기" + if shouldPopToRoot { + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(backButtonTapped) + ) + } + // More button let deleteAllAction = UIAction( title: "전체삭제", @@ -181,6 +199,19 @@ public final class DetailViewController< ) } + @objc private func backButtonTapped() { + dismissDetail() + } + + private func dismissDetail() { + onDismissWithDate?(viewModel.state.currentDate) + if shouldPopToRoot { + navigationController?.popToRootViewController(animated: true) + } else { + navigationController?.popViewController(animated: true) + } + } + private func setupUI() { view.backgroundColor = .sdBase @@ -252,20 +283,6 @@ public final class DetailViewController< .store(in: &cancellables) // Output: ViewModel → View - viewModel.statePublisher - .map(\.currentDate) - .removeDuplicates { Calendar.current.isDate($0, inSameDayAs: $1) } - .dropFirst() - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self else { return } - self.scrollView.setContentOffset( - CGPoint(x: 0, y: -self.scrollView.adjustedContentInset.top), - animated: false - ) - } - .store(in: &cancellables) - viewModel.statePublisher .map(\.dateText) .removeDuplicates() @@ -288,7 +305,15 @@ public final class DetailViewController< } else { self.loadingIndicatorView.stopAnimating() let state = self.viewModel.state - self.updateMealSections(state.recordsByMealType, processingRecords: state.processingRecordsByMealType) + self.updateMealSections( + state.recordsByMealType, + processingRecords: state.processingRecordsByMealType) + + if let mealType = self.resolveScrollTarget(state) { + DispatchQueue.main.async { + self.scrollToMealSection(mealType) + } + } } } .store(in: &cancellables) @@ -323,7 +348,7 @@ public final class DetailViewController< case .saveFailed(let error): self?.showSaveErrorAlert(error) case .deleteAllCompleted: - self?.navigationController?.popViewController(animated: true) + self?.dismissDetail() case .deleteAllFailed(let error): self?.showDeleteErrorAlert(error) } @@ -482,6 +507,7 @@ public final class DetailViewController< private func handleEdit(record: FoodRecord) { guard let editVC = editViewControllerFactory?(record) else { return } + pendingScrollTarget = record.mealType navigationController?.pushViewController(editVC, animated: true) } @@ -531,4 +557,48 @@ public final class DetailViewController< alert.addAction(UIAlertAction(title: "확인", style: .default)) present(alert, animated: true) } + + // MARK: - Scroll to Meal Section + + private func mealSectionView(for mealType: MealType) -> MealSectionView { + switch mealType { + case .breakfast: return breakfastSection + case .lunch: return lunchSection + case .dinner: return dinnerSection + case .snack: return snackSection + } + } + + private func resolveScrollTarget( + _ state: DetailViewModel.State + ) -> MealType? { + if let mealType = pendingScrollTarget { + pendingScrollTarget = nil + return mealType + } + if scrollToFirstRecord { + return firstContentMealType(state) + } + return nil + } + + private func firstContentMealType(_ state: DetailViewModel.State) + -> MealType? + { + let orderedMealTypes: [MealType] = [.breakfast, .lunch, .dinner, .snack] + return orderedMealTypes.first { mealType in + state.recordsByMealType[mealType] != nil + || state.processingRecordsByMealType[mealType] != nil + } + } + + 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 + ) + scrollView.setContentOffset(targetOffset, animated: false) + } } diff --git a/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift b/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift index a5f401df..e4792ed3 100644 --- a/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift +++ b/FoodDiary/Presentation/Sources/Detail/DetailViewModel.swift @@ -143,6 +143,7 @@ public final class DetailViewModel< @MainActor private func loadRecords(for date: Date) async { state.isLoading = true + defer { state.isLoading = false } do { let allRecords = try await fetchRecordsUseCase.execute(for: date) @@ -158,8 +159,6 @@ public final class DetailViewModel< print("Failed to load records: \(error)") } } - - state.isLoading = false } private static func groupRecordsByMealType(_ records: [FoodRecord]) -> [MealType: FoodRecord] { @@ -228,7 +227,6 @@ public final class DetailViewModel< guard !state.recordsByMealType.isEmpty || !state.processingRecordsByMealType.isEmpty else { return } state.isLoading = true - defer { state.isLoading = false } do { for (_, record) in state.recordsByMealType { @@ -243,6 +241,7 @@ public final class DetailViewModel< state.processingRecordsByMealType = [:] eventSubject.send(.deleteAllCompleted) } catch { + state.isLoading = false eventSubject.send(.deleteAllFailed(error)) } } diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift index ad77a5c2..c83fcf5d 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewController.swift @@ -129,6 +129,7 @@ public final class EditFoodRecordViewController< setupNavigation() setupUI() setupConstraints() + setupKeyboardDismissGesture() setupCategoryChips() setupBindings() } diff --git a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift index d89b59cb..8064910f 100644 --- a/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift +++ b/FoodDiary/Presentation/Sources/EditFoodRecord/EditFoodRecordViewModel.swift @@ -132,6 +132,12 @@ public final class EditFoodRecordViewModel { state.isSaving = true defer { state.isSaving = false } + // 모든 사진이 제거된 경우 레코드 삭제 처리 + if state.photos.isEmpty && state.newAssets.isEmpty { + await performDelete() + return + } + let request = UpdateFoodRecordRequest( id: state.originalRecord.id, genre: state.selectedGenre, diff --git a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift index 941e9f85..7ace58e6 100644 --- a/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift +++ b/FoodDiary/Presentation/Sources/ImagePicker/ImagePickerViewController.swift @@ -167,13 +167,23 @@ public final class ImagePickerViewController< let container = UIView() container.isHidden = true + let imageView = UIImageView(image: DesignSystemAsset.emptyImage.image) + imageView.contentMode = .scaleAspectFit + imageView.snp.makeConstraints { + $0.width.height.equalTo(210) + } + let label = UILabel() - label.setText("이 날짜에 찍은 사진이 없어요", style: .hd18, color: .gray400) - label.textColor = .gray400 + label.setText("오늘은 촬영된 사진이 없어요", style: .p12, color: .gray050) label.textAlignment = .center - container.addSubview(label) - label.snp.makeConstraints { + let stackView = UIStackView(arrangedSubviews: [imageView, label]) + stackView.axis = .vertical + stackView.spacing = 20 + stackView.alignment = .center + + container.addSubview(stackView) + stackView.snp.makeConstraints { $0.center.equalToSuperview() }