From d868e3ccecc5965a9c596e7fe81aed9422513250 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:38:05 +0900 Subject: [PATCH 01/15] =?UTF-8?q?[#450]=20=EC=A0=9C=ED=9C=B4=20=EC=B0=9C?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80:=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=20=ED=83=AD=20=EB=82=98=EB=A7=8C=EC=95=84=EB=8B=88?= =?UTF-8?q?=EB=A9=B4=EB=8F=BC=20=E2=86=92=20=EC=B0=9C=20=EA=B5=90=EC=B2=B4?= =?UTF-8?q?,=20=EC=A7=80=EB=8F=84=20=EC=8B=9C=ED=8A=B8=20=ED=95=98?= =?UTF-8?q?=ED=8A=B8=C2=B7=ED=94=8C=EB=A1=9C=ED=8C=85=20=ED=95=98=ED=8A=B8?= =?UTF-8?q?,=20=EC=B0=9C=20=EB=AA=A9=EB=A1=9D=C2=B7=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EB=AA=A8=EB=93=9C,=20=EA=B3=B5=EC=9A=A9=20=ED=83=AD/=EC=B9=A9/?= =?UTF-8?q?=EB=B9=88=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=8A=B9=EA=B2=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/en.lproj/Localizable.strings | 53 +++- .../Resources/ja.lproj/Localizable.strings | 53 +++- .../Resources/ko.lproj/Localizable.strings | 53 +++- .../Resources/vi.lproj/Localizable.strings | 53 +++- .../Data/Firebase/FirebaseScreenID.swift | 3 - .../Data/Like/PartnershipLikeManager.swift | 196 +++++++++++++ .../DTO/Partnership/PartnershipDTO.swift | 12 + .../Data/Network/Router/MyRouter.swift | 8 +- .../Network/Router/PartnershipRouter.swift | 11 +- .../CoffeeWebViewController.swift | 95 ------- .../Presentation/Like/Enum/LikeTab.swift | 21 ++ .../Like/View/LikedPartnershipCell.swift | 160 +++++++++++ .../ViewController/LikeViewController.swift | 158 +++++++++++ .../LikedPartnershipEditViewController.swift | 208 ++++++++++++++ .../LikedPartnershipViewController.swift | 261 ++++++++++++++++++ .../MenuLikePlaceholderViewController.swift | 29 ++ .../Presentation/Map/View/MainMapView.swift | 26 +- .../MainMapViewController+Marker.swift | 18 +- .../MainMapViewController.swift | 73 ++++- ...PartnershipDetailSheetViewController.swift | 71 ++++- .../CustomTabBarContainerController.swift | 160 ++--------- .../Sources/Utility/Literal/TextLiteral.swift | 117 ++++++-- .../Utility/UIComponent/EmptyStateView.swift | 61 ++++ .../UIComponent/FilterChipBar.swift} | 20 +- .../UIComponent/UnderlineTabView.swift} | 5 +- .../coffee.imageset/Contents.json | 21 -- .../coffee.imageset/coffee.png | Bin 1404 -> 0 bytes .../coffee_selected.imageset/Contents.json | 21 -- .../coffee_selected.png | Bin 1608 -> 0 bytes .../ic_delete.imageset/Contents.json | 16 ++ .../ic_delete.imageset/ic_delete.pdf | Bin 0 -> 6181 bytes .../ic_like_filled.imageset/Contents.json | 16 ++ .../ic_like_filled.pdf | Bin 0 -> 5886 bytes .../ic_like_line.imageset/Contents.json | 16 ++ .../ic_like_line.imageset/ic_like_line.pdf | Bin 0 -> 6003 bytes .../icon_event_tooltip.imageset/Contents.json | 12 - .../icon_event_tooltip.svg | 5 - 37 files changed, 1633 insertions(+), 399 deletions(-) create mode 100644 EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift delete mode 100644 EATSSU/App/Sources/Presentation/Coffee/ViewController/CoffeeWebViewController.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/Enum/LikeTab.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikePlaceholderViewController.swift create mode 100644 EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift rename EATSSU/App/Sources/{Presentation/Map/View/MapFilterChipBar.swift => Utility/UIComponent/FilterChipBar.swift} (84%) rename EATSSU/App/Sources/{Presentation/Map/View/MapTopTabView.swift => Utility/UIComponent/UnderlineTabView.swift} (95%) delete mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/Contents.json delete mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/coffee.png delete mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/Contents.json delete mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/coffee_selected.png create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/ic_delete.pdf create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/ic_like_filled.pdf create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/ic_like_line.pdf delete mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/Contents.json delete mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/icon_event_tooltip.svg diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 6d85cb8e..8041f58d 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -50,8 +50,8 @@ "tabBar.meal" = "Cafeteria"; /// "지도" "tabBar.map" = "Map"; -/// "나만아니면돼~" -"tabBar.coffee" = "Lucky Game"; +/// "찜" +"tabBar.like" = "Likes"; /// "마이" "tabBar.my" = "My Page"; @@ -426,18 +426,6 @@ "review.characterCount" = "%d / %d"; -// MARK: - Coffee - -/// "나가시겠어요?" -"coffee.askLeave" = "Leave this page?"; -/// "지금 나가면 진행 상황이\n저장되지 않습니다." -"coffee.leaveWarning" = "Your progress will not be saved\nif you leave now."; -/// "나가기" -"coffee.leave" = "Leave"; -/// "계속하기" -"coffee.continueEvent" = "Continue"; - - // MARK: - Splash /// NoticeSplashVC - "긴급 서버 점검 안내" @@ -681,3 +669,40 @@ "restaurantInfo.snackCorner.time" = "11:00~15:30(Lunch)"; /// "분식류, 옛날도시락, 컵밥 등\n주말 휴무" "restaurantInfo.snackCorner.etc" = "Bunsik(Korean Snacks), Old-school Lunch Box, Cup Rice, etc.\nClosed on weekends"; + +// MARK: - Like + +/// "찜" +"like.title" = "Likes"; +/// "메뉴 찜" +"like.menuTab" = "Liked Menus"; +/// "제휴 찜" +"like.partnershipTab" = "Liked Partners"; +/// "편집" +"like.edit" = "Edit"; +/// "전체 선택" +"like.selectAll" = "Select All"; +/// "삭제" +"like.delete" = "Delete"; +/// "삭제 %d" +"like.deleteCount" = "Delete %d"; +/// "취소하기" +"like.undo" = "Undo"; +/// "찜한 제휴에 추가했어요." +"like.addedToast" = "Added to liked partners."; +/// "찜한 제휴에서 삭제했어요." +"like.removedToast" = "Removed from liked partners."; +/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." +"like.limitReached" = "You can like up to %d partners (your partner count)."; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"like.updateFailed" = "Couldn't update. Please try again."; +/// "찜한 제휴를 불러오지 못했어요." +"like.loadFailed" = "Couldn't load liked partners."; +/// "아직 찜한 제휴가 없어요." +"like.emptyTitle" = "No liked partners yet."; +/// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" +"like.emptySubtitle" = "Like the partners you're interested in for quick access!"; +/// "메뉴 찜은 준비 중이에요." +"like.menuComingSoonTitle" = "Liked menus are coming soon."; +/// "곧 만나볼 수 있어요!" +"like.menuComingSoonSubtitle" = "Stay tuned!"; diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index 549ec2a6..a8d75b03 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -50,8 +50,8 @@ "tabBar.meal" = "学食"; /// "지도" "tabBar.map" = "マップ"; -/// "나만아니면돼~" -"tabBar.coffee" = "ラッキーゲーム"; +/// "찜" +"tabBar.like" = "お気に入り"; /// "마이" "tabBar.my" = "マイページ"; @@ -427,18 +427,6 @@ "review.characterCount" = "%d / %d"; -// MARK: - Coffee - -/// "나가시겠어요?" -"coffee.askLeave" = "退出しますか?"; -/// "지금 나가면 진행 상황이\n저장되지 않습니다." -"coffee.leaveWarning" = "今退出すると進行状況が\n保存されません。"; -/// "나가기" -"coffee.leave" = "退出"; -/// "계속하기" -"coffee.continueEvent" = "続ける"; - - // MARK: - Splash /// NoticeSplashVC - "긴급 서버 점검 안내" @@ -682,3 +670,40 @@ "restaurantInfo.snackCorner.time" = "11:00~15:30(昼食)"; /// "분식류, 옛날도시락, 컵밥 등\n주말 휴무" "restaurantInfo.snackCorner.etc" = "粉食類、昔ながらのお弁当、カップご飯など\n週末休業"; + +// MARK: - Like + +/// "찜" +"like.title" = "お気に入り"; +/// "메뉴 찜" +"like.menuTab" = "メニュー"; +/// "제휴 찜" +"like.partnershipTab" = "提携店"; +/// "편집" +"like.edit" = "編集"; +/// "전체 선택" +"like.selectAll" = "すべて選択"; +/// "삭제" +"like.delete" = "削除"; +/// "삭제 %d" +"like.deleteCount" = "削除 %d"; +/// "취소하기" +"like.undo" = "取り消す"; +/// "찜한 제휴에 추가했어요." +"like.addedToast" = "お気に入りの提携店に追加しました。"; +/// "찜한 제휴에서 삭제했어요." +"like.removedToast" = "お気に入りの提携店から削除しました。"; +/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." +"like.limitReached" = "お気に入りは自分の提携店数(%d件)まで追加できます。"; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"like.updateFailed" = "処理に失敗しました。もう一度お試しください。"; +/// "찜한 제휴를 불러오지 못했어요." +"like.loadFailed" = "お気に入りの提携店を読み込めませんでした。"; +/// "아직 찜한 제휴가 없어요." +"like.emptyTitle" = "まだお気に入りの提携店がありません。"; +/// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" +"like.emptySubtitle" = "気になる提携店をお気に入りに追加して、すぐに確認しましょう!"; +/// "메뉴 찜은 준비 중이에요." +"like.menuComingSoonTitle" = "メニューのお気に入りは準備中です。"; +/// "곧 만나볼 수 있어요!" +"like.menuComingSoonSubtitle" = "もうすぐ利用できます!"; diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 5b67593e..6391acf7 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -50,8 +50,8 @@ "tabBar.meal" = "학식"; /// "지도" "tabBar.map" = "지도"; -/// "나만아니면돼~" -"tabBar.coffee" = "나만아니면돼~"; +/// "찜" +"tabBar.like" = "찜"; /// "마이" "tabBar.my" = "마이"; @@ -427,18 +427,6 @@ "review.characterCount" = "%d / %d"; -// MARK: - Coffee - -/// "나가시겠어요?" -"coffee.askLeave" = "나가시겠어요?"; -/// "지금 나가면 진행 상황이\n저장되지 않습니다." -"coffee.leaveWarning" = "지금 나가면 진행 상황이\n저장되지 않습니다."; -/// "나가기" -"coffee.leave" = "나가기"; -/// "계속하기" -"coffee.continueEvent" = "계속하기"; - - // MARK: - Splash /// NoticeSplashVC - "긴급 서버 점검 안내" @@ -682,3 +670,40 @@ "restaurantInfo.snackCorner.time" = "11:00~15:30(점심)"; /// "분식류, 옛날도시락, 컵밥 등\n주말 휴무" "restaurantInfo.snackCorner.etc" = "분식류, 옛날도시락, 컵밥 등\n주말 휴무"; + +// MARK: - Like + +/// "찜" +"like.title" = "찜"; +/// "메뉴 찜" +"like.menuTab" = "메뉴 찜"; +/// "제휴 찜" +"like.partnershipTab" = "제휴 찜"; +/// "편집" +"like.edit" = "편집"; +/// "전체 선택" +"like.selectAll" = "전체 선택"; +/// "삭제" +"like.delete" = "삭제"; +/// "삭제 %d" +"like.deleteCount" = "삭제 %d"; +/// "취소하기" +"like.undo" = "취소하기"; +/// "찜한 제휴에 추가했어요." +"like.addedToast" = "찜한 제휴에 추가했어요."; +/// "찜한 제휴에서 삭제했어요." +"like.removedToast" = "찜한 제휴에서 삭제했어요."; +/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." +"like.limitReached" = "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요."; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"like.updateFailed" = "찜 처리에 실패했어요. 다시 시도해 주세요."; +/// "찜한 제휴를 불러오지 못했어요." +"like.loadFailed" = "찜한 제휴를 불러오지 못했어요."; +/// "아직 찜한 제휴가 없어요." +"like.emptyTitle" = "아직 찜한 제휴가 없어요."; +/// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" +"like.emptySubtitle" = "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!"; +/// "메뉴 찜은 준비 중이에요." +"like.menuComingSoonTitle" = "메뉴 찜은 준비 중이에요."; +/// "곧 만나볼 수 있어요!" +"like.menuComingSoonSubtitle" = "곧 만나볼 수 있어요!"; diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index edb04424..bf7ed3d7 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -50,8 +50,8 @@ "tabBar.meal" = "Nhà ăn"; /// "지도" "tabBar.map" = "Bản đồ"; -/// "나만아니면돼~" -"tabBar.coffee" = "Trò chơi may mắn"; +/// "찜" +"tabBar.like" = "Yêu thích"; /// "마이" "tabBar.my" = "Trang của tôi"; @@ -427,18 +427,6 @@ "review.characterCount" = "%d / %d"; -// MARK: - Coffee - -/// "나가시겠어요?" -"coffee.askLeave" = "Bạn có muốn rời đi không?"; -/// "지금 나가면 진행 상황이\n저장되지 않습니다." -"coffee.leaveWarning" = "Nếu rời đi bây giờ, tiến trình sẽ\nkhông được lưu."; -/// "나가기" -"coffee.leave" = "Rời đi"; -/// "계속하기" -"coffee.continueEvent" = "Tiếp tục"; - - // MARK: - Splash /// NoticeSplashVC - "긴급 서버 점검 안내" @@ -682,3 +670,40 @@ "restaurantInfo.snackCorner.time" = "11:00~15:30(Bữa trưa)"; /// "분식류, 옛날도시락, 컵밥 등\n주말 휴무" "restaurantInfo.snackCorner.etc" = "Bunsik(đồ ăn vặt Hàn Quốc), cơm hộp kiểu xưa, cơm ly, v.v.\nĐóng cửa cuối tuần"; + +// MARK: - Like + +/// "찜" +"like.title" = "Yêu thích"; +/// "메뉴 찜" +"like.menuTab" = "Món ăn"; +/// "제휴 찜" +"like.partnershipTab" = "Ưu đãi"; +/// "편집" +"like.edit" = "Chỉnh sửa"; +/// "전체 선택" +"like.selectAll" = "Chọn tất cả"; +/// "삭제" +"like.delete" = "Xóa"; +/// "삭제 %d" +"like.deleteCount" = "Xóa %d"; +/// "취소하기" +"like.undo" = "Hoàn tác"; +/// "찜한 제휴에 추가했어요." +"like.addedToast" = "Đã thêm vào ưu đãi yêu thích."; +/// "찜한 제휴에서 삭제했어요." +"like.removedToast" = "Đã xóa khỏi ưu đãi yêu thích."; +/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." +"like.limitReached" = "Chỉ có thể thêm tối đa %d ưu đãi (số ưu đãi của bạn)."; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"like.updateFailed" = "Xử lý thất bại. Vui lòng thử lại."; +/// "찜한 제휴를 불러오지 못했어요." +"like.loadFailed" = "Không thể tải ưu đãi yêu thích."; +/// "아직 찜한 제휴가 없어요." +"like.emptyTitle" = "Chưa có ưu đãi yêu thích."; +/// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" +"like.emptySubtitle" = "Hãy thêm ưu đãi bạn quan tâm để xem nhanh!"; +/// "메뉴 찜은 준비 중이에요." +"like.menuComingSoonTitle" = "Món ăn yêu thích đang được chuẩn bị."; +/// "곧 만나볼 수 있어요!" +"like.menuComingSoonSubtitle" = "Sắp ra mắt!"; diff --git a/EATSSU/App/Sources/Data/Firebase/FirebaseScreenID.swift b/EATSSU/App/Sources/Data/Firebase/FirebaseScreenID.swift index d8b78e66..0ba4323e 100644 --- a/EATSSU/App/Sources/Data/Firebase/FirebaseScreenID.swift +++ b/EATSSU/App/Sources/Data/Firebase/FirebaseScreenID.swift @@ -52,9 +52,6 @@ enum FirebaseScreenID { } /// 나만아니면돼~ (커피 이벤트) - enum AnyoneButMeActivity { - static let abm1 = "ABM_001000" - } /// 마이페이지 enum MyPage { diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift new file mode 100644 index 00000000..122b5f98 --- /dev/null +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -0,0 +1,196 @@ +// +// PartnershipLikeManager.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import Foundation + +/// 제휴 찜 상태의 앱 내 단일 소스 +/// +/// - 서버 API는 제휴 **항목** 단위(`POST /partnerships/{id}/like`, 토글)지만 화면의 하트는 **업체** 단위이므로 +/// 업체의 모든 제휴 항목을 함께 켜고 끈다. 상태가 다른 항목만 호출해 토글 API와 어긋나지 않게 한다. +/// - 찜한 순서는 서버 응답에 없어 로컬(UserDefaults)에 기록하고 "최근 추가순" 정렬에 사용한다. +/// - 지도 시트와 찜 목록이 같은 인스턴스를 보므로 어느 화면에서 바꿔도 즉시 일치한다. +final class PartnershipLikeManager { + + // MARK: - Singleton + + static let shared = PartnershipLikeManager() + + private init() { + likedAtByStoreKey = (UserDefaults.standard.dictionary(forKey: Constant.likedOrderKey) as? [String: Double]) ?? [:] + } + + private enum Constant { + static let likedOrderKey = "partnershipLikedOrder" + } + + // MARK: - State + + /// 찜한 제휴 항목 id. 업체 찜 여부는 소속 항목이 모두 포함됐는지로 판정 + private(set) var likedPartnershipIds: Set = [] + + /// 마지막으로 받아온 찜 업체 목록 (최근 추가순) + private(set) var likedStores: [PartnershipDTO] = [] + + /// 찜 목록을 한 번이라도 서버에서 받아왔는지 + private(set) var hasLoaded = false + + /// 업체별 찜한 시각 (최근 추가순 정렬용, 로컬 보관) + private var likedAtByStoreKey: [String: Double] + + // MARK: - Query + + func isLiked(_ store: PartnershipDTO) -> Bool { + let ids = store.partnershipIds + return !ids.isEmpty && ids.allSatisfy { likedPartnershipIds.contains($0) } + } + + /// 찜한 업체 수 (최대 개수 제한 판정용) + var likedStoreCount: Int { + likedStores.count + } + + // MARK: - Fetch + + /// 찜 목록을 서버에서 받아 상태를 갱신하고 최근 추가순으로 정렬해 돌려준다 + func refresh(completion: @escaping (Result<[PartnershipDTO], Error>) -> Void) { + NetworkService.shared.request( + MyRouter.getLikedPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { [weak self] result in + guard let self else { return } + switch result { + case .success(let stores): + self.likedPartnershipIds = Set(stores.flatMap(\.partnershipIds)) + self.likedStores = self.sortedByRecent(stores) + self.hasLoaded = true + self.pruneOrder(keeping: stores) + completion(.success(self.likedStores)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// 찜 가능 최대 개수 = 내 제휴 업체 수 + func fetchLikeLimit(completion: @escaping (Int?) -> Void) { + NetworkService.shared.request( + MyRouter.getMyPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { result in + switch result { + case .success(let stores): + completion(stores.count) + case .failure: + completion(nil) + } + } + } + + // MARK: - Update + + /// 업체 단위 찜 설정. 완료 시 로컬 상태(항목 id, 목록, 순서)까지 반영한다 + func setLiked(_ liked: Bool, store: PartnershipDTO, completion: @escaping (Result) -> Void) { + let targets = store.partnershipIds.filter { likedPartnershipIds.contains($0) != liked } + guard !targets.isEmpty else { + applyLocalState(liked: liked, store: store) + completion(.success(())) + return + } + + let group = DispatchGroup() + var toggledIds: [Int] = [] + var firstError: Error? + + for id in targets { + group.enter() + NetworkService.shared.request( + PartnershipRouter.toggleLike(partnershipId: id), + responseType: Bool.self, + useAuth: true + ) { result in + switch result { + case .success: + toggledIds.append(id) + case .failure(let error): + if firstError == nil { firstError = error } + } + group.leave() + } + } + + group.notify(queue: .main) { [weak self] in + guard let self else { return } + // 성공한 항목만 로컬에 반영해 서버 상태와 어긋나지 않게 한다 + for id in toggledIds { + if liked { self.likedPartnershipIds.insert(id) } else { self.likedPartnershipIds.remove(id) } + } + if let firstError { + completion(.failure(firstError)) + return + } + self.applyLocalState(liked: liked, store: store) + completion(.success(())) + } + } + + /// 여러 업체를 한 번에 찜 해제 (편집 모드 삭제). 하나라도 실패하면 failure + func removeLikes(stores: [PartnershipDTO], completion: @escaping (Result) -> Void) { + let group = DispatchGroup() + var firstError: Error? + for store in stores { + group.enter() + setLiked(false, store: store) { result in + if case .failure(let error) = result, firstError == nil { firstError = error } + group.leave() + } + } + group.notify(queue: .main) { + if let firstError { completion(.failure(firstError)) } else { completion(.success(())) } + } + } + + // MARK: - Private + + private func applyLocalState(liked: Bool, store: PartnershipDTO) { + if liked { + likedAtByStoreKey[store.storeKey] = Date().timeIntervalSince1970 + likedStores.removeAll { $0.storeKey == store.storeKey } + likedStores.insert(store, at: 0) + } else { + likedAtByStoreKey.removeValue(forKey: store.storeKey) + likedStores.removeAll { $0.storeKey == store.storeKey } + } + persistOrder() + } + + /// 로컬에 기록된 찜 시각 기준 최근순. 기록이 없는 항목(다른 기기에서 찜 등)은 서버 순서대로 뒤에 둔다 + private func sortedByRecent(_ stores: [PartnershipDTO]) -> [PartnershipDTO] { + let indexed = stores.enumerated().map { ($0.offset, $0.element) } + return indexed.sorted { lhs, rhs in + let lhsTime = likedAtByStoreKey[lhs.1.storeKey] + let rhsTime = likedAtByStoreKey[rhs.1.storeKey] + switch (lhsTime, rhsTime) { + case let (l?, r?): return l > r + case (_?, nil): return true + case (nil, _?): return false + case (nil, nil): return lhs.0 < rhs.0 + } + }.map { $0.1 } + } + + private func pruneOrder(keeping stores: [PartnershipDTO]) { + let keys = Set(stores.map(\.storeKey)) + likedAtByStoreKey = likedAtByStoreKey.filter { keys.contains($0.key) } + persistOrder() + } + + private func persistOrder() { + UserDefaults.standard.set(likedAtByStoreKey, forKey: Constant.likedOrderKey) + } +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/Partnership/PartnershipDTO.swift b/EATSSU/App/Sources/Data/Network/DTO/Partnership/PartnershipDTO.swift index abd7f6df..bc79b29b 100644 --- a/EATSSU/App/Sources/Data/Network/DTO/Partnership/PartnershipDTO.swift +++ b/EATSSU/App/Sources/Data/Network/DTO/Partnership/PartnershipDTO.swift @@ -29,6 +29,18 @@ struct PartnershipInfoDTO: Codable { let periodType: PartnershipPeriodType } +extension PartnershipDTO { + /// 업체 식별 키. 서버 응답에 업체 id가 없어 이름 + 좌표로 구성한다 + var storeKey: String { + "\(storeName)|\(latitude)|\(longitude)" + } + + /// 업체에 속한 제휴 항목 id 목록 (찜은 항목 단위 API라 업체 찜 = 모든 항목 찜) + var partnershipIds: [Int] { + partnershipInfos.map(\.id) + } +} + enum PartnershipPeriodType: String, Codable { case normal = "NORMAL" case festival = "FESTIVAL" diff --git a/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift b/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift index 1acc3b9b..73f287da 100644 --- a/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift @@ -19,6 +19,8 @@ enum MyRouter { case inquiry(param: InquiryRequest) case getDepartment case getMyPartnerships + /// 유저가 찜한 제휴 조회 + case getLikedPartnerships case colleges case departments(collegeId: Int) } @@ -42,6 +44,8 @@ extension MyRouter: TargetType { "/users/department" case .getMyPartnerships: "/users/department/partnerships" + case .getLikedPartnerships: + "/users/partnerships" case .colleges: "/users/lookup/colleges" case .departments: @@ -53,7 +57,7 @@ extension MyRouter: TargetType { switch self { case .getMyReviewList, .departments, .colleges: .get - case .myInfo, .getDepartment, .getMyPartnerships: + case .myInfo, .getDepartment, .getMyPartnerships, .getLikedPartnerships: .get case .signOut: .delete @@ -91,6 +95,8 @@ extension MyRouter: TargetType { .requestPlain case .getMyPartnerships: .requestPlain + case .getLikedPartnerships: + .requestPlain case .colleges: .requestPlain case let .departments(collegeId): diff --git a/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift b/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift index 932909f1..b1528398 100644 --- a/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/PartnershipRouter.swift @@ -11,6 +11,8 @@ import Moya enum PartnershipRouter { case getAllPartnerships + /// 제휴 항목 단위 찜 토글 (등록 ↔ 취소) + case toggleLike(partnershipId: Int) } extension PartnershipRouter: TargetType { @@ -22,11 +24,18 @@ extension PartnershipRouter: TargetType { switch self { case .getAllPartnerships: return "/partnerships" + case let .toggleLike(partnershipId): + return "/partnerships/\(partnershipId)/like" } } var method: Moya.Method { - return .get + switch self { + case .getAllPartnerships: + return .get + case .toggleLike: + return .post + } } var task: Task { diff --git a/EATSSU/App/Sources/Presentation/Coffee/ViewController/CoffeeWebViewController.swift b/EATSSU/App/Sources/Presentation/Coffee/ViewController/CoffeeWebViewController.swift deleted file mode 100644 index 6b2bf724..00000000 --- a/EATSSU/App/Sources/Presentation/Coffee/ViewController/CoffeeWebViewController.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// CoffeeWebViewController.swift -// EATSSU-DEV -// -// Created by 황상환 on 2/23/26. -// - -import UIKit -import WebKit - -import EATSSUDesign - -import SnapKit - -final class CoffeeWebViewController: BaseViewController { - - // MARK: - UI Components - - private let webView = WKWebView() - - private let closeButton: UIButton = { - let button = UIButton(type: .system) - let image = UIImage(systemName: "xmark")? - .withConfiguration(UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) - button.setImage(image, for: .normal) - button.tintColor = .gray700 - button.backgroundColor = .white - button.layer.cornerRadius = 18 - button.layer.shadowColor = UIColor.black.cgColor - button.layer.shadowOpacity = 0.15 - button.layer.shadowOffset = CGSize(width: 0, height: 2) - button.layer.shadowRadius = 4 - return button - }() - - // MARK: - Life Cycle - - override func viewDidLoad() { - super.viewDidLoad() - - webView.scrollView.contentInsetAdjustmentBehavior = .never - logScreenView(screenID: FirebaseScreenID.AnyoneButMeActivity.abm1) - loadWebPage() - } - - override func configureUI() { - view.addSubview(webView) - view.addSubview(closeButton) - } - - override func setLayout() { - webView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - closeButton.snp.makeConstraints { - $0.top.equalTo(view.safeAreaLayoutGuide).offset(8) - $0.leading.equalToSuperview().offset(16) - $0.size.equalTo(36) - } - } - - override func setButtonEvent() { - closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) - } - - // MARK: - Private - - @objc private func closeTapped() { - let dialogView = EATSSUDialogView() - dialogView.configure( - title: TextLiteral.Coffee.askLeave, - message: TextLiteral.Coffee.leaveWarning - ) - dialogView.setButtonTitles(cancel: TextLiteral.Coffee.leave, confirm: TextLiteral.Coffee.continueEvent) - view.addSubview(dialogView) - dialogView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - dialogView.cancelButton.addAction(UIAction { [weak self] _ in - dialogView.removeFromSuperview() - self?.dismiss(animated: true) - }, for: .touchUpInside) - - dialogView.confirmButton.addAction(UIAction { _ in - dialogView.removeFromSuperview() - }, for: .touchUpInside) - } - - private func loadWebPage() { - guard let url = URL(string: "https://eatssu-coffee.figma.site/") else { return } - webView.load(URLRequest(url: url)) - } -} diff --git a/EATSSU/App/Sources/Presentation/Like/Enum/LikeTab.swift b/EATSSU/App/Sources/Presentation/Like/Enum/LikeTab.swift new file mode 100644 index 00000000..819fe314 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/Enum/LikeTab.swift @@ -0,0 +1,21 @@ +// +// LikeTab.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import Foundation + +/// 찜 탭 상단 구분 (메뉴 찜 / 제휴 찜) +enum LikeTab: Int, CaseIterable { + case menu + case partnership + + var title: String { + switch self { + case .menu: TextLiteral.Like.menuTab + case .partnership: TextLiteral.Like.partnershipTab + } + } +} diff --git a/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift new file mode 100644 index 00000000..adc32534 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift @@ -0,0 +1,160 @@ +// +// LikedPartnershipCell.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 찜한 제휴 업체 한 줄: [체크(편집 모드)] 업종 아이콘 · 업체명 · 업종 · 제휴 내용 · 화살표 +final class LikedPartnershipCell: UITableViewCell { + + // MARK: - Types + + enum Mode { + case normal + case editing(isSelected: Bool) + } + + // MARK: - Constants + + static let identifier = "LikedPartnershipCell" + + private enum Layout { + static let horizontalInset: CGFloat = 20 + static let iconSize: CGFloat = 48 + static let checkSize: CGFloat = 24 + static let verticalInset: CGFloat = 16 + } + + // MARK: - UI Components + + private let checkImageView = UIImageView() + private let iconImageView = UIImageView() + private let nameLabel = UILabel() + private let typeLabel = UILabel() + private let descriptionLabel = UILabel() + private let chevronImageView = UIImageView(image: UIImage(systemName: "chevron.right")) + private let titleStackView = UIStackView() + private let textStackView = UIStackView() + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureUI() + setLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Setup + + private func configureUI() { + selectionStyle = .none + backgroundColor = .white + + checkImageView.contentMode = .scaleAspectFit + checkImageView.isHidden = true + + iconImageView.contentMode = .scaleAspectFit + + nameLabel.font = .subtitle1 + nameLabel.textColor = .label + nameLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + typeLabel.font = .caption2 + typeLabel.textColor = .gray500 + typeLabel.setContentHuggingPriority(.required, for: .horizontal) + typeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + descriptionLabel.font = .body3 + descriptionLabel.textColor = .gray700 + descriptionLabel.numberOfLines = 1 + descriptionLabel.lineBreakMode = .byTruncatingTail + + chevronImageView.tintColor = .gray300 + chevronImageView.contentMode = .scaleAspectFit + + titleStackView.axis = .horizontal + titleStackView.alignment = .lastBaseline + titleStackView.spacing = 6 + titleStackView.addArrangedSubview(nameLabel) + titleStackView.addArrangedSubview(typeLabel) + + textStackView.axis = .vertical + textStackView.alignment = .leading + textStackView.spacing = 6 + textStackView.addArrangedSubview(titleStackView) + textStackView.addArrangedSubview(descriptionLabel) + + contentView.addSubviews(checkImageView, iconImageView, textStackView, chevronImageView) + } + + private func setLayout() { + checkImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(Layout.horizontalInset) + $0.centerY.equalToSuperview() + $0.width.height.equalTo(Layout.checkSize) + } + + iconImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(Layout.horizontalInset) + $0.centerY.equalToSuperview() + $0.width.height.equalTo(Layout.iconSize) + } + + chevronImageView.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.centerY.equalToSuperview() + $0.width.equalTo(10) + $0.height.equalTo(16) + } + + textStackView.snp.makeConstraints { + $0.leading.equalTo(iconImageView.snp.trailing).offset(16) + $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-12) + $0.top.bottom.equalToSuperview().inset(Layout.verticalInset) + } + } + + // MARK: - Configuration + + func configure(store: PartnershipDTO, mode: Mode) { + nameLabel.text = store.storeName + typeLabel.text = PartnershipFilter.allCases + .first { $0.restaurantType == store.restaurantType }? + .title ?? store.restaurantType + descriptionLabel.text = store.partnershipInfos.map(\.description).joined(separator: ", ") + iconImageView.image = MainMapViewController.partnershipIcon(for: store.restaurantType, isFestival: false) + + let leadingAnchor: ConstraintItem + switch mode { + case .normal: + checkImageView.isHidden = true + iconImageView.isHidden = false + leadingAnchor = iconImageView.snp.trailing + case .editing(let isSelected): + // 편집 모드에서는 업종 아이콘 대신 체크박스를 보여준다 + checkImageView.isHidden = false + iconImageView.isHidden = true + checkImageView.image = isSelected + ? EATSSUDesignAsset.Images.icCheck.image + : EATSSUDesignAsset.Images.icUncheck.image + leadingAnchor = checkImageView.snp.trailing + } + textStackView.snp.remakeConstraints { + $0.leading.equalTo(leadingAnchor).offset(16) + $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-12) + $0.top.bottom.equalToSuperview().inset(Layout.verticalInset) + } + } +} diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift new file mode 100644 index 00000000..cfd2f38f --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift @@ -0,0 +1,158 @@ +// +// LikeViewController.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 찜 탭 루트. 상단 언더라인 탭(메뉴 찜 / 제휴 찜)과 가로 스와이프 페이지로 구성 +final class LikeViewController: BaseViewController { + + // MARK: - Properties + + private(set) var currentTab: LikeTab = .partnership + + /// 지도의 플로팅 하트로 진입한 경우 true. 뒤로가기가 지도 탭 복귀로 동작한다 + private var showsBackToMap = false + + private let menuViewController = MenuLikePlaceholderViewController() + private let partnershipViewController = LikedPartnershipViewController() + private var pages: [UIViewController] { [menuViewController, partnershipViewController] } + + // MARK: - UI Components + + private let tabView = UnderlineTabView(titles: LikeTab.allCases.map { $0.title }) + private let pageViewController = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + + // MARK: - View Setup + + override func configureUI() { + view.backgroundColor = .white + + addChild(pageViewController) + view.addSubviews(tabView, pageViewController.view) + pageViewController.didMove(toParent: self) + pageViewController.dataSource = self + pageViewController.delegate = self + } + + override func setLayout() { + tabView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + } + pageViewController.view.snp.makeConstraints { + $0.top.equalTo(tabView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + } + } + + override func setButtonEvent() { + tabView.onSelect = { [weak self] index in + guard let tab = LikeTab(rawValue: index) else { return } + self?.showPage(for: tab, animated: true) + } + } + + override func setCustomNavigationBar() { + super.setCustomNavigationBar() + navigationItem.title = TextLiteral.Like.title + updateBackButton() + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + showPage(for: currentTab, animated: false) + tabView.select(index: currentTab.rawValue, animated: false) + } + + // MARK: - Public + + /// 탭 전환 전에 호출해 표시할 하위 탭과 뒤로가기 동작을 정한다 + func prepare(tab: LikeTab, showsBackToMap: Bool) { + self.showsBackToMap = showsBackToMap + currentTab = tab + if isViewLoaded { + showPage(for: tab, animated: false) + tabView.select(index: tab.rawValue, animated: false) + updateBackButton() + } + } + + /// 탭바에서 찜 탭을 다시 눌렀을 때 현재 페이지 갱신 + func reloadContent() { + if currentTab == .partnership { + partnershipViewController.reload() + } + } + + // MARK: - Private + + private func showPage(for tab: LikeTab, animated: Bool) { + let target = pages[tab.rawValue] + let direction: UIPageViewController.NavigationDirection = tab.rawValue >= currentTab.rawValue ? .forward : .reverse + currentTab = tab + pageViewController.setViewControllers([target], direction: direction, animated: animated) + } + + private func updateBackButton() { + guard showsBackToMap else { + navigationItem.leftBarButtonItem = nil + return + } + let backItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(didTapBackToMap) + ) + backItem.tintColor = .gray500 + navigationItem.leftBarButtonItem = backItem + } + + @objc private func didTapBackToMap() { + showsBackToMap = false + updateBackButton() + (tabBarController as? CustomTabBarContainerController)?.returnToMapTab() + } +} + +// MARK: - UIPageViewControllerDataSource / Delegate + +extension LikeViewController: UIPageViewControllerDataSource, UIPageViewControllerDelegate { + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController), index > 0 else { return nil } + return pages[index - 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController) -> UIViewController? { + guard let index = pages.firstIndex(of: viewController), index < pages.count - 1 else { return nil } + return pages[index + 1] + } + + func pageViewController(_ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool) { + guard completed, + let visible = pageViewController.viewControllers?.first, + let index = pages.firstIndex(of: visible), + let tab = LikeTab(rawValue: index) else { return } + currentTab = tab + tabView.select(index: index, animated: true) + } +} diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift new file mode 100644 index 00000000..ce59f017 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift @@ -0,0 +1,208 @@ +// +// LikedPartnershipEditViewController.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 제휴 찜 편집 모드: 전체/개별 선택 후 일괄 삭제. 필터는 제공하지 않는다 +final class LikedPartnershipEditViewController: BaseViewController { + + // MARK: - Constants + + private enum Layout { + static let horizontalInset: CGFloat = 20 + static let headerHeight: CGFloat = 56 + } + + // MARK: - Properties + + override var shouldHideTabBar: Bool { true } + + private let stores: [PartnershipDTO] + private var selectedKeys: Set = [] + + /// 삭제가 서버에 반영된 뒤 호출. 호출 시점에 이미 이전 화면으로 돌아가 있다 + var onDidDelete: (() -> Void)? + + private var isAllSelected: Bool { + !stores.isEmpty && selectedKeys.count == stores.count + } + + // MARK: - UI Components + + private let selectAllButton = UIButton(type: .system) + private let headerSeparator = UIView() + private let tableView = UITableView(frame: .zero, style: .plain) + private let deleteButton = MainButton() + + // MARK: - Init + + init(stores: [PartnershipDTO]) { + self.stores = stores + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Setup + + override func configureUI() { + view.backgroundColor = .white + + var config = UIButton.Configuration.plain() + config.image = EATSSUDesignAsset.Images.icUncheck.image + config.imagePadding = 12 + config.baseForegroundColor = .label + config.contentInsets = .zero + config.attributedTitle = AttributedString( + TextLiteral.Like.selectAll, + attributes: AttributeContainer([.font: UIFont.subtitle1]) + ) + selectAllButton.configuration = config + selectAllButton.contentHorizontalAlignment = .leading + + headerSeparator.backgroundColor = .gray200 + + tableView.register(LikedPartnershipCell.self, forCellReuseIdentifier: LikedPartnershipCell.identifier) + tableView.dataSource = self + tableView.delegate = self + tableView.separatorInset = UIEdgeInsets(top: 0, left: Layout.horizontalInset, bottom: 0, right: Layout.horizontalInset) + tableView.separatorColor = .gray200 + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 84 + tableView.tableFooterView = UIView() + tableView.showsVerticalScrollIndicator = false + + deleteButton.title = TextLiteral.Like.delete + updateDeleteButton() + + view.addSubviews(selectAllButton, headerSeparator, tableView, deleteButton) + } + + override func setLayout() { + selectAllButton.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.height.equalTo(Layout.headerHeight) + } + + headerSeparator.snp.makeConstraints { + $0.top.equalTo(selectAllButton.snp.bottom) + $0.leading.trailing.equalToSuperview() + $0.height.equalTo(1) + } + + deleteButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(12) + } + + tableView.snp.makeConstraints { + $0.top.equalTo(headerSeparator.snp.bottom) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(deleteButton.snp.top).offset(-12) + } + } + + override func setButtonEvent() { + selectAllButton.addTarget(self, action: #selector(didTapSelectAll), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(didTapDelete), for: .touchUpInside) + } + + override func setCustomNavigationBar() { + super.setCustomNavigationBar() + navigationItem.title = TextLiteral.Like.title + navigationItem.hidesBackButton = true + // 선택 중이어도 확인 없이 즉시 닫는다 (기획) + let closeItem = UIBarButtonItem( + image: EATSSUDesignAsset.Images.icClose.image.resize(newWidth: 24), + style: .plain, + target: self, + action: #selector(didTapClose) + ) + closeItem.tintColor = .gray500 + navigationItem.rightBarButtonItem = closeItem + } + + // MARK: - Private + + private func updateDeleteButton() { + let count = selectedKeys.count + deleteButton.title = count == 0 ? TextLiteral.Like.delete : TextLiteral.Like.deleteCount(count) + deleteButton.isEnabled = count > 0 + deleteButton.backgroundColor = count > 0 ? .danger : .gray300 + } + + private func updateSelectAllButton() { + selectAllButton.configuration?.image = isAllSelected + ? EATSSUDesignAsset.Images.icCheck.image + : EATSSUDesignAsset.Images.icUncheck.image + } + + // MARK: - Actions + + @objc private func didTapSelectAll() { + selectedKeys = isAllSelected ? [] : Set(stores.map(\.storeKey)) + tableView.reloadData() + updateSelectAllButton() + updateDeleteButton() + } + + @objc private func didTapClose() { + navigationController?.popViewController(animated: true) + } + + @objc private func didTapDelete() { + let targets = stores.filter { selectedKeys.contains($0.storeKey) } + guard !targets.isEmpty else { return } + deleteButton.isEnabled = false + + PartnershipLikeManager.shared.removeLikes(stores: targets) { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.navigationController?.popViewController(animated: true) + self.onDidDelete?() + case .failure: + self.deleteButton.isEnabled = true + self.showToast(message: TextLiteral.Like.updateFailed, type: .danger) + } + } + } +} + +// MARK: - UITableViewDataSource / Delegate + +extension LikedPartnershipEditViewController: UITableViewDataSource, UITableViewDelegate { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + stores.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: LikedPartnershipCell.identifier, + for: indexPath + ) as? LikedPartnershipCell else { return UITableViewCell() } + let store = stores[indexPath.row] + cell.configure(store: store, mode: .editing(isSelected: selectedKeys.contains(store.storeKey))) + return cell + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let key = stores[indexPath.row].storeKey + if selectedKeys.contains(key) { selectedKeys.remove(key) } else { selectedKeys.insert(key) } + tableView.reloadRows(at: [indexPath], with: .none) + updateSelectAllButton() + updateDeleteButton() + } +} diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift new file mode 100644 index 00000000..9c14d86c --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -0,0 +1,261 @@ +// +// LikedPartnershipViewController.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 제휴 찜 목록: 업종 필터 · 편집 진입 · 스와이프 삭제(취소하기) · 상세 진입 +final class LikedPartnershipViewController: BaseViewController { + + // MARK: - Constants + + private enum Layout { + static let chipBarTop: CGFloat = 16 + static let horizontalInset: CGFloat = 20 + } + + /// 찜 목록 필터 (축제 제외) + private static let filters: [PartnershipFilter] = PartnershipFilter.allCases.filter { $0 != .festival } + + // MARK: - Properties + + private var stores: [PartnershipDTO] = [] + private var filter: PartnershipFilter = .all + + private var displayedStores: [PartnershipDTO] { + guard let type = filter.restaurantType else { return stores } + return stores.filter { $0.restaurantType == type } + } + + // MARK: - UI Components + + private let filterChipBar = FilterChipBar() + private let editButton = UIButton(type: .system) + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyView = EmptyStateView() + + // MARK: - View Setup + + override func configureUI() { + view.backgroundColor = .white + + filterChipBar.configure(titles: Self.filters.map { $0.title }) + + editButton.setTitle(TextLiteral.Like.edit, for: .normal) + editButton.titleLabel?.font = .body2 + editButton.setTitleColor(.gray700, for: .normal) + + tableView.register(LikedPartnershipCell.self, forCellReuseIdentifier: LikedPartnershipCell.identifier) + tableView.dataSource = self + tableView.delegate = self + tableView.separatorInset = UIEdgeInsets(top: 0, left: Layout.horizontalInset, bottom: 0, right: Layout.horizontalInset) + tableView.separatorColor = .gray200 + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 84 + tableView.tableFooterView = UIView() + tableView.showsVerticalScrollIndicator = false + + emptyView.configure(title: TextLiteral.Like.emptyTitle, subtitle: TextLiteral.Like.emptySubtitle) + emptyView.isHidden = true + + view.addSubviews(filterChipBar, editButton, tableView, emptyView) + } + + override func setLayout() { + filterChipBar.snp.makeConstraints { + $0.top.equalToSuperview().offset(Layout.chipBarTop) + $0.leading.equalToSuperview() + $0.trailing.equalTo(editButton.snp.leading).offset(-8) + } + + editButton.snp.makeConstraints { + $0.centerY.equalTo(filterChipBar) + $0.trailing.equalToSuperview().inset(Layout.horizontalInset) + } + + tableView.snp.makeConstraints { + $0.top.equalTo(filterChipBar.snp.bottom).offset(12) + $0.leading.trailing.bottom.equalToSuperview() + } + + emptyView.snp.makeConstraints { + $0.edges.equalTo(tableView) + } + } + + override func setButtonEvent() { + filterChipBar.onSelect = { [weak self] index in + guard let self, Self.filters.indices.contains(index) else { return } + self.filter = Self.filters[index] + self.tableView.reloadData() + self.updateEmptyState() + } + editButton.addTarget(self, action: #selector(didTapEdit), for: .touchUpInside) + } + + // MARK: - Life Cycle + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reload() + } + + // MARK: - Public + + /// 서버에서 찜 목록을 다시 받아 표시. 실패 시 마지막으로 받아둔 목록을 유지한다 + func reload() { + applyStores(PartnershipLikeManager.shared.likedStores) + PartnershipLikeManager.shared.refresh { [weak self] result in + guard let self else { return } + switch result { + case .success(let stores): + self.applyStores(stores) + case .failure(let error): + print("찜 목록 조회 실패: \(error.localizedDescription)") + if !PartnershipLikeManager.shared.hasLoaded { + self.showToast(message: TextLiteral.Like.loadFailed, type: .danger) + } + } + } + } + + /// 편집 모드 삭제 후 복귀: 어떤 필터에 있었든 '전체'로 돌아온다 + func resetFilterToAll() { + filter = .all + filterChipBar.select(index: 0, animated: false) + } + + // MARK: - Private + + private func applyStores(_ stores: [PartnershipDTO]) { + self.stores = stores + tableView.reloadData() + updateEmptyState() + } + + private func updateEmptyState() { + let isEmpty = displayedStores.isEmpty + emptyView.isHidden = !isEmpty + tableView.isHidden = isEmpty + editButton.isEnabled = !stores.isEmpty + editButton.alpha = stores.isEmpty ? 0.4 : 1 + } + + /// 스와이프 삭제: 목록에서 즉시 제거하고 서버 반영, 토스트의 '취소하기'로 되돌릴 수 있다 + private func removeStore(_ store: PartnershipDTO) { + stores.removeAll { $0.storeKey == store.storeKey } + tableView.reloadData() + updateEmptyState() + + PartnershipLikeManager.shared.setLiked(false, store: store) { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.showToast( + message: TextLiteral.Like.removedToast, + type: .success, + actionTitle: TextLiteral.Like.undo + ) { [weak self] in + self?.restoreStore(store) + } + case .failure: + self.applyStores(PartnershipLikeManager.shared.likedStores) + self.showToast(message: TextLiteral.Like.updateFailed, type: .danger) + } + } + } + + /// '취소하기': 찜을 다시 등록하고 목록 맨 위로 되돌린다 + private func restoreStore(_ store: PartnershipDTO) { + PartnershipLikeManager.shared.setLiked(true, store: store) { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.applyStores(PartnershipLikeManager.shared.likedStores) + case .failure: + self.showToast(message: TextLiteral.Like.updateFailed, type: .danger) + } + } + } + + private func showDetail(for store: PartnershipDTO) { + let mapVC = MainMapViewController(mode: .partnershipDetail(store)) + navigationController?.pushViewController(mapVC, animated: true) + } + + // MARK: - Actions + + @objc private func didTapEdit() { + let editVC = LikedPartnershipEditViewController(stores: stores) + editVC.onDidDelete = { [weak self] in + guard let self else { return } + self.resetFilterToAll() + self.applyStores(PartnershipLikeManager.shared.likedStores) + self.showToast(message: TextLiteral.Like.removedToast, type: .success) + } + navigationController?.pushViewController(editVC, animated: true) + } +} + +// MARK: - UITableViewDataSource + +extension LikedPartnershipViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + displayedStores.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: LikedPartnershipCell.identifier, + for: indexPath + ) as? LikedPartnershipCell else { return UITableViewCell() } + cell.configure(store: displayedStores[indexPath.row], mode: .normal) + return cell + } +} + +// MARK: - UITableViewDelegate + +extension LikedPartnershipViewController: UITableViewDelegate { + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + showDetail(for: displayedStores[indexPath.row]) + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let store = displayedStores[indexPath.row] + let delete = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in + self?.removeStore(store) + completion(true) + } + delete.backgroundColor = .white + delete.image = Self.deleteActionImage + let configuration = UISwipeActionsConfiguration(actions: [delete]) + configuration.performsFirstActionWithFullSwipe = false + return configuration + } + + /// 빨간 원 안에 흰 휴지통 (디자인의 스와이프 삭제 아이콘) + private static let deleteActionImage: UIImage = { + let size = CGSize(width: 48, height: 48) + let icon = EATSSUDesignAsset.Images.icDelete.image.withTintColor(.white, renderingMode: .alwaysOriginal) + return UIGraphicsImageRenderer(size: size).image { context in + UIColor.danger.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) + let iconSize = CGSize(width: 24, height: 24) + icon.draw(in: CGRect( + origin: CGPoint(x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2), + size: iconSize + )) + }.withRenderingMode(.alwaysOriginal) + }() +} diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikePlaceholderViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikePlaceholderViewController.swift new file mode 100644 index 00000000..6151a8eb --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikePlaceholderViewController.swift @@ -0,0 +1,29 @@ +// +// MenuLikePlaceholderViewController.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +/// 메뉴 찜 탭. 서버 API 준비 전까지 안내 문구만 표시 +final class MenuLikePlaceholderViewController: BaseViewController { + + private let emptyView = EmptyStateView() + + override func configureUI() { + view.backgroundColor = .white + emptyView.configure( + title: TextLiteral.Like.menuComingSoonTitle, + subtitle: TextLiteral.Like.menuComingSoonSubtitle + ) + view.addSubview(emptyView) + } + + override func setLayout() { + emptyView.snp.makeConstraints { $0.edges.equalToSuperview() } + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index 6803e962..ad3ac598 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -59,8 +59,22 @@ final class MainMapView: BaseUIView { // MARK: - UI Components let mapView = NMFNaverMapView() - let topTabView = MapTopTabView(titles: MapTab.allCases.map { $0.title }) - let filterChipBar = MapFilterChipBar() + let topTabView = UnderlineTabView(titles: MapTab.allCases.map { $0.title }) + let filterChipBar = FilterChipBar() + + /// 찜 탭으로 이동하는 플로팅 하트 버튼 (탭바 지도 탭에서만 노출) + let likeButton: UIButton = { + let button = UIButton(type: .custom) + button.backgroundColor = .white + button.layer.cornerRadius = 24 + button.layer.shadowColor = UIColor.black.cgColor + button.layer.shadowOpacity = 0.12 + button.layer.shadowOffset = CGSize(width: 0, height: 2) + button.layer.shadowRadius = 6 + button.setImage(EATSSUDesignAsset.Images.icLikeLine.image, for: .normal) + button.tintColor = .label + return button + }() // MARK: - UI Setup @@ -71,7 +85,7 @@ final class MainMapView: BaseUIView { mapView.showLocationButton = true mapView.mapView.positionMode = .disabled - addSubviews(mapView, topTabView, filterChipBar) + addSubviews(mapView, topTabView, filterChipBar, likeButton) } // MARK: - Layout Setup @@ -91,6 +105,12 @@ final class MainMapView: BaseUIView { $0.top.equalTo(mapView).offset(12) $0.leading.trailing.equalToSuperview() } + + likeButton.snp.makeConstraints { + $0.top.equalTo(filterChipBar.snp.bottom).offset(12) + $0.trailing.equalToSuperview().inset(16) + $0.width.height.equalTo(48) + } } // MARK: - Configuration diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index fa5a7ca2..b9267c59 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -147,7 +147,8 @@ extension MainMapViewController { } /// 제휴점 상세 바텀시트 표시 - func showPartnershipDetail(for partnership: PartnershipDTO) { + /// - Parameter expanded: true면 펼친(large) 상태로 시작 (찜 목록에서 진입 시) + func showPartnershipDetail(for partnership: PartnershipDTO, expanded: Bool = false) { MapAnalyticsManager.shared.logClickPartnerRestaurant( collegeId: currentCollegeId, majorId: currentDepartmentId, @@ -156,7 +157,11 @@ extension MainMapViewController { let detailVC = PartnershipDetailSheetViewController(partnership: partnership) detailVC.loadViewIfNeeded() - presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) + presentSheet( + detailVC, + heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }, + expanded: expanded + ) } /// 착한가격업소 상세 바텀시트 표시 @@ -169,7 +174,11 @@ extension MainMapViewController { } /// 커스텀 detent 시트 공통 표시. 표시 후 safe area 확정 시 invalidateDetents()로 높이가 보정됨 - private func presentSheet(_ viewController: UIViewController, heightProvider: @escaping () -> CGFloat?) { + private func presentSheet( + _ viewController: UIViewController, + heightProvider: @escaping () -> CGFloat?, + expanded: Bool = false + ) { if let sheet = viewController.sheetPresentationController { if #available(iOS 16.0, *) { let customDetent = UISheetPresentationController.Detent.custom { _ in heightProvider() } @@ -178,6 +187,9 @@ extension MainMapViewController { sheet.detents = [.medium(), .large()] } sheet.prefersGrabberVisible = true + if expanded { + sheet.selectedDetentIdentifier = .large + } } present(viewController, animated: true) } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index fe9e1037..084f4f15 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -27,11 +27,21 @@ final class MainMapViewController: BaseViewController { } /// 화면 진입 형태 - enum Mode { + enum Mode: Equatable { /// 탭바 안의 지도 탭: 상단에 학교 제휴 / 착한 가격 탭 노출 case tabbed /// 로그인 화면에서 바로 진입하는 착한가격업소 지도 (탭 없음) case standaloneGoodPrice + /// 찜 목록에서 진입: 해당 업체 마커만 표시하고 상세 시트를 펼친 상태로 시작 (탭·필터 없음) + case partnershipDetail(PartnershipDTO) + + static func == (lhs: Mode, rhs: Mode) -> Bool { + switch (lhs, rhs) { + case (.tabbed, .tabbed), (.standaloneGoodPrice, .standaloneGoodPrice): return true + case let (.partnershipDetail(l), .partnershipDetail(r)): return l.storeKey == r.storeKey + default: return false + } + } } // MARK: - Properties @@ -99,6 +109,14 @@ final class MainMapViewController: BaseViewController { super.init(nibName: nil, bundle: nil) } + /// 찜 목록에서 진입한 경우의 대상 업체 + private var detailPartnership: PartnershipDTO? { + if case let .partnershipDetail(store) = mode { return store } + return nil + } + + override var shouldHideTabBar: Bool { detailPartnership != nil } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -108,6 +126,8 @@ final class MainMapViewController: BaseViewController { override func configureUI() { view.addSubview(root) root.setTopTabVisible(mode == .tabbed) + root.filterChipBar.isHidden = detailPartnership != nil + root.likeButton.isHidden = mode != .tabbed } override func setLayout() { @@ -122,6 +142,7 @@ final class MainMapViewController: BaseViewController { root.filterChipBar.onSelect = { [weak self] index in self?.didSelectFilter(at: index) } + root.likeButton.addTarget(self, action: #selector(didTapLikeButton), for: .touchUpInside) } // MARK: - Life Cycle @@ -138,14 +159,14 @@ final class MainMapViewController: BaseViewController { applyTabUI() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - logScreenView(screenID: FirebaseScreenID.Map.map1) - } - override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + if let store = detailPartnership { + showSingleStore(store) + return + } + switch currentTab { case .partnership: refreshPartnershipTab() @@ -154,6 +175,30 @@ final class MainMapViewController: BaseViewController { } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + logScreenView(screenID: FirebaseScreenID.Map.map1) + + // 찜 목록에서 진입: 시트를 펼친 상태로 바로 보여준다 (한 번만) + if let store = detailPartnership, !didPresentDetailSheet { + didPresentDetailSheet = true + showPartnershipDetail(for: store, expanded: true) + } + } + + private var didPresentDetailSheet = false + + /// 찜 상세 진입: 해당 업체 마커만 올리고 카메라를 그 위치로 + private func showSingleStore(_ store: PartnershipDTO) { + _ = beginLoad() + displayMarkers([makeMarkerItem(for: store)]) + moveCamera(to: NMGLatLng(lat: store.latitude, lng: store.longitude), animated: false) + } + + @objc private func didTapLikeButton() { + (tabBarController as? CustomTabBarContainerController)?.showLikedPartnerships(fromMap: true) + } + /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드. 축제 노출 여부가 바뀌었을 수 있어 칩도 재구성 private func refreshPartnershipTab() { applyTabUI() @@ -205,6 +250,8 @@ final class MainMapViewController: BaseViewController { /// 현재 탭에 맞춰 필터 칩과 선택 상태를 갱신 private func applyTabUI() { root.topTabView.select(index: currentTab.rawValue, animated: false) + // 찜은 학교 제휴 전용이라 착한 가격 탭에서는 플로팅 하트를 숨긴다 + root.likeButton.isHidden = mode != .tabbed || currentTab != .partnership switch currentTab { case .partnership: @@ -337,13 +384,15 @@ final class MainMapViewController: BaseViewController { } func setInitialCameraPosition(animated: Bool) { - let cameraUpdate = NMFCameraUpdate( - scrollTo: NMGLatLng( - lat: CameraConstants.initialLatitude, - lng: CameraConstants.initialLongitude - ), - zoomTo: CameraConstants.initialZoom + moveCamera( + to: NMGLatLng(lat: CameraConstants.initialLatitude, lng: CameraConstants.initialLongitude), + animated: animated ) + } + + /// 지정 좌표로 카메라 이동 (줌은 초기값 고정) + func moveCamera(to position: NMGLatLng, animated: Bool) { + let cameraUpdate = NMFCameraUpdate(scrollTo: position, zoomTo: CameraConstants.initialZoom) if animated { cameraUpdate.animation = .easeIn diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index fa08df70..fbe6aeb3 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -21,6 +21,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - UI Components private let storeNameLabel = UILabel() + private let likeButton = UIButton(type: .custom) private let typeStackView = UIStackView() private let typeIconImageView = UIImageView() private let typeTextLabel = UILabel() @@ -55,6 +56,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { storeNameLabel.font = .header2 storeNameLabel.textColor = .label + likeButton.tintColor = .label + likeButton.addTarget(self, action: #selector(didTapLike), for: .touchUpInside) + updateLikeButton() + typeIconImageView.contentMode = .scaleAspectFit typeIconImageView.snp.makeConstraints { $0.width.height.equalTo(18) } @@ -77,7 +82,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { mapAppButtonBar.onKakaoMapTap = { [weak self] in self?.mapAppLauncher.openKakaoMap() } mapAppButtonBar.onNaverMapTap = { [weak self] in self?.mapAppLauncher.openNaverMap() } - [storeNameLabel, typeStackView, infoListStackView, mapAppButtonBar].forEach { + [storeNameLabel, likeButton, typeStackView, infoListStackView, mapAppButtonBar].forEach { view.addSubview($0) } } @@ -85,7 +90,14 @@ final class PartnershipDetailSheetViewController: BaseViewController { override func setLayout() { storeNameLabel.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide).offset(20) - $0.leading.trailing.equalToSuperview().inset(24) + $0.leading.equalToSuperview().inset(24) + $0.trailing.lessThanOrEqualTo(likeButton.snp.leading).offset(-12) + } + + likeButton.snp.makeConstraints { + $0.centerY.equalTo(storeNameLabel) + $0.trailing.equalToSuperview().inset(24) + $0.width.height.equalTo(32) } typeStackView.snp.makeConstraints { @@ -147,6 +159,61 @@ final class PartnershipDetailSheetViewController: BaseViewController { } } + // MARK: - Like + + private var isLiked: Bool { + PartnershipLikeManager.shared.isLiked(partnership) + } + + private func updateLikeButton() { + let image = isLiked ? EATSSUDesignAsset.Images.icLikeFilled.image : EATSSUDesignAsset.Images.icLikeLine.image + likeButton.setImage(image, for: .normal) + } + + /// 하트 탭: 찜 추가 시 최대 개수(내 제휴 업체 수)를 확인하고, 삭제 시엔 '취소하기'로 되돌릴 수 있는 토스트를 띄운다 + @objc private func didTapLike() { + likeButton.isEnabled = false + if isLiked { + setLiked(false) + return + } + + PartnershipLikeManager.shared.fetchLikeLimit { [weak self] limit in + guard let self else { return } + if let limit, PartnershipLikeManager.shared.likedStoreCount >= limit { + self.likeButton.isEnabled = true + self.showToast(message: TextLiteral.Like.limitReached(limit), type: .warning) + return + } + self.setLiked(true) + } + } + + private func setLiked(_ liked: Bool) { + PartnershipLikeManager.shared.setLiked(liked, store: partnership) { [weak self] result in + guard let self else { return } + self.likeButton.isEnabled = true + self.updateLikeButton() + + switch result { + case .success: + if liked { + self.showToast(message: TextLiteral.Like.addedToast, type: .success) + } else { + self.showToast( + message: TextLiteral.Like.removedToast, + type: .success, + actionTitle: TextLiteral.Like.undo + ) { [weak self] in + self?.setLiked(true) + } + } + case .failure: + self.showToast(message: TextLiteral.Like.updateFailed, type: .danger) + } + } + } + // MARK: - UI Helpers /// 제휴 content 갯수에 따라 유동적으로 Height 측정 diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index a70a603a..aa5cff05 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -18,7 +18,7 @@ final class CustomTabBarContainerController: UITabBarController { private enum Tab: Int { case home = 0 case map = 1 - case coffee = 2 + case like = 2 case myPage = 3 } @@ -27,21 +27,10 @@ final class CustomTabBarContainerController: UITabBarController { private lazy var tabViewControllers: [UIViewController] = [ UINavigationController(rootViewController: HomeViewController()), UINavigationController(rootViewController: MainMapViewController()), - UIViewController(), + UINavigationController(rootViewController: LikeViewController()), UINavigationController(rootViewController: MyPageViewController()) ] - - private let eventBadgeImageView: UIImageView = { - let imageView = UIImageView() - imageView.image = EATSSUDesignAsset.Images.iconEventTooltip.image - imageView.contentMode = .scaleAspectFit - imageView.isUserInteractionEnabled = false - return imageView - }() - - /// 이벤트 배지 뷰를 이미 화면에 추가했는지 여부 - private var didSetupEventBadge = false - + // MARK: - Life Cycle override func viewDidLoad() { @@ -52,19 +41,6 @@ final class CustomTabBarContainerController: UITabBarController { delegate = self } - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - // setupEventBadgeIfNeeded() - // updateEventBadgePosition() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // updateEventBadgePosition() - } - // MARK: - Setup private func setupTabBar() { @@ -84,7 +60,7 @@ final class CustomTabBarContainerController: UITabBarController { let tabConfigurations: [(title: String, normal: UIImage, selected: UIImage, size: CGSize)] = [ (TextLiteral.TabBar.meal, EATSSUDesignAsset.Images.tabMeal.image, EATSSUDesignAsset.Images.tabMealSelected.image, CGSize(width: 23, height: 23)), (TextLiteral.TabBar.map, EATSSUDesignAsset.Images.tabMap.image, EATSSUDesignAsset.Images.tabMapSelected.image, CGSize(width: 23, height: 23)), - (TextLiteral.TabBar.coffee, EATSSUDesignAsset.Images.coffee.image, EATSSUDesignAsset.Images.coffeeSelected.image, CGSize(width: 23, height: 23)), + (TextLiteral.TabBar.like, Self.likeTabIcon(selected: false), Self.likeTabIcon(selected: true), CGSize(width: 23, height: 23)), (TextLiteral.TabBar.my, EATSSUDesignAsset.Images.tabMypage.image, EATSSUDesignAsset.Images.tabMypageSelected.image, CGSize(width: 44, height: 23)) ] @@ -123,14 +99,20 @@ final class CustomTabBarContainerController: UITabBarController { /// 외부에서 탭 전환 요청 시 사용 public func setTab(index: Int) { guard index < tabViewControllers.count else { return } - - if Tab(rawValue: index) == .coffee { - presentCoffeeWebView() - return - } - selectedIndex = index } + + /// 지도 탭 → 찜 탭(제휴 찜)으로 전환. 찜 화면의 뒤로가기로 지도 탭에 복귀할 수 있게 표시한다 + public func showLikedPartnerships(fromMap: Bool) { + guard let likeVC = getNavController(at: Tab.like.rawValue)?.viewControllers.first as? LikeViewController else { return } + likeVC.prepare(tab: .partnership, showsBackToMap: fromMap) + setTab(index: Tab.like.rawValue) + } + + /// 찜 탭의 뒤로가기 → 지도 탭 복귀 + public func returnToMapTab() { + setTab(index: Tab.map.rawValue) + } /// 특정 인덱스의 네비게이션 컨트롤러를 반환 public func getNavController(at index: Int) -> UINavigationController? { @@ -224,14 +206,13 @@ final class CustomTabBarContainerController: UITabBarController { present(alert, animated: true) } - /// 커피 웹뷰를 전체화면 모달로 표시 - private func presentCoffeeWebView() { - guard presentedViewController == nil else { return } - let coffeeVC = CoffeeWebViewController() - coffeeVC.modalPresentationStyle = .overFullScreen - present(coffeeVC, animated: true) + /// 찜 탭 아이콘: 템플릿 하트를 탭바 색으로 칠해 다른 탭과 같은 원본 렌더링 방식으로 맞춘다 + private static func likeTabIcon(selected: Bool) -> UIImage { + let image = selected ? EATSSUDesignAsset.Images.icLikeFilled.image : EATSSUDesignAsset.Images.icLikeLine.image + let color: UIColor = selected ? EATSSUDesignAsset.Color.Main.primary.color : .gray500 + return image.withTintColor(color, renderingMode: .alwaysOriginal) } - + /// 로그인 화면으로 전환 private func navigateToLogin() { let loginVC = LoginViewController() @@ -253,19 +234,9 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { return true } - // 커피 탭: 전체화면 모달로 웹뷰 표시 - if selectedTab == .coffee { - let userInfo = UserInfoManager.shared.getCurrentUserInfo() - var params: [String: Any] = [:] - if let collegeId = userInfo?.collegeId { params["college"] = collegeId } - if let majorId = userInfo?.departmentId { params["major"] = majorId } - AnalyticsService.logEvent("click_plz_not_me", parameters: params) - presentCoffeeWebView() - return false - } - - // 마이페이지와 지도는 로그인 필요 - if (selectedTab == .map || selectedTab == .myPage), RealmService.shared.isAccessTokenPresent() == false { + // 지도·찜·마이페이지는 로그인 필요 + if (selectedTab == .map || selectedTab == .like || selectedTab == .myPage), + RealmService.shared.isAccessTokenPresent() == false { presentLoginAlert() return false } @@ -290,7 +261,11 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { if let mapVC = navController.viewControllers.first as? MainMapViewController { mapVC.reloadContent() } - case .coffee, .myPage: + case .like: + if let likeVC = navController.viewControllers.first as? LikeViewController { + likeVC.reloadContent() + } + case .myPage: break } } @@ -299,81 +274,6 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { } } -// MARK: - Event Badge - -extension CustomTabBarContainerController { - private func setupEventBadgeIfNeeded() { - guard !didSetupEventBadge else { return } - didSetupEventBadge = true - - tabBar.addSubview(eventBadgeImageView) - tabBar.bringSubviewToFront(eventBadgeImageView) - - eventBadgeImageView.frame = CGRect(x: 0, y: 0, width: 66, height: 32) - } - - private func allSubviews(of view: UIView) -> [UIView] { - view.subviews + view.subviews.flatMap { allSubviews(of: $0) } - } - - private func tabBarButtons() -> [UIView] { - let topLevelButtons = tabBar.subviews.filter { - let className = String(describing: type(of: $0)) - return className == "UITabBarButton" || className == "_UITabButton" - } - - if !topLevelButtons.isEmpty { - return topLevelButtons - } - - return allSubviews(of: tabBar).filter { - let className = String(describing: type(of: $0)) - return className == "UITabBarButton" || className == "_UITabButton" - } - } - - private func coffeeTabButton() -> UIView? { - tabBarButtons().first { button in - allSubviews(of: button).contains { - guard let label = $0 as? UILabel else { return false } - return label.text == TextLiteral.TabBar.coffee - } - } - } - - /// coffee 탭 위치 위에 말풍선 배치 - private func updateEventBadgePosition() { - guard let coffeeButton = coffeeTabButton() else { return } - - let badgeSize = CGSize(width: 66, height: 32) - - let iconView = allSubviews(of: coffeeButton).first { - let className = String(describing: type(of: $0)) - return className == "UITabBarSwappableImageView" || $0 is UIImageView - } - - if let iconView { - let iconFrameInTabBar = iconView.superview?.convert(iconView.frame, to: tabBar) ?? iconView.frame - - eventBadgeImageView.frame = CGRect( - x: iconFrameInTabBar.midX - badgeSize.width / 2, - y: iconFrameInTabBar.minY - badgeSize.height - 1, - width: badgeSize.width, - height: badgeSize.height - ) - } else { - let coffeeButtonFrameInTabBar = coffeeButton.superview?.convert(coffeeButton.frame, to: tabBar) ?? coffeeButton.frame - - eventBadgeImageView.frame = CGRect( - x: coffeeButtonFrameInTabBar.midX - badgeSize.width / 2, - y: coffeeButtonFrameInTabBar.minY - badgeSize.height - 1, - width: badgeSize.width, - height: badgeSize.height - ) - } - } -} - extension UIImage { func resized(to size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index b4cbbe0f..5925b22d 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -146,9 +146,9 @@ enum TextLiteral { Localization.localized("tabBar.map", fallback: "지도") } - /// "나만아니면돼~" - static var coffee: String { - Localization.localized("tabBar.coffee", fallback: "나만아니면돼~") + /// "찜" + static var like: String { + Localization.localized("tabBar.like", fallback: "찜") } /// "마이" @@ -1132,29 +1132,6 @@ enum TextLiteral { } } - // MARK: - Coffee - - enum Coffee { - /// "나가시겠어요?" - static var askLeave: String { - Localization.localized("coffee.askLeave", fallback: "나가시겠어요?") - } - - /// "지금 나가면 진행 상황이\n저장되지 않습니다." - static var leaveWarning: String { - Localization.localized("coffee.leaveWarning", fallback: "지금 나가면 진행 상황이\n저장되지 않습니다.") - } - - /// "나가기" - static var leave: String { - Localization.localized("coffee.leave", fallback: "나가기") - } - - /// "계속하기" - static var continueEvent: String { - Localization.localized("coffee.continueEvent", fallback: "계속하기") - } - } // MARK: - Splash @@ -1366,4 +1343,92 @@ enum TextLiteral { "snackCorner": "분식류, 옛날도시락, 컵밥 등\n주말 휴무", ] } + // MARK: - Like + + enum Like { + /// "찜" + static var title: String { + Localization.localized("like.title", fallback: "찜") + } + + /// "메뉴 찜" + static var menuTab: String { + Localization.localized("like.menuTab", fallback: "메뉴 찜") + } + + /// "제휴 찜" + static var partnershipTab: String { + Localization.localized("like.partnershipTab", fallback: "제휴 찜") + } + + /// "편집" + static var edit: String { + Localization.localized("like.edit", fallback: "편집") + } + + /// "전체 선택" + static var selectAll: String { + Localization.localized("like.selectAll", fallback: "전체 선택") + } + + /// "삭제" + static var delete: String { + Localization.localized("like.delete", fallback: "삭제") + } + + /// "삭제 2" + static func deleteCount(_ count: Int) -> String { + Localization.formatted("like.deleteCount", fallback: "삭제 %d", count) + } + + /// "취소하기" + static var undo: String { + Localization.localized("like.undo", fallback: "취소하기") + } + + /// "찜한 제휴에 추가했어요." + static var addedToast: String { + Localization.localized("like.addedToast", fallback: "찜한 제휴에 추가했어요.") + } + + /// "찜한 제휴에서 삭제했어요." + static var removedToast: String { + Localization.localized("like.removedToast", fallback: "찜한 제휴에서 삭제했어요.") + } + + /// "찜은 내 제휴 개수(3개)까지만 담을 수 있어요." + static func limitReached(_ limit: Int) -> String { + Localization.formatted("like.limitReached", fallback: "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요.", limit) + } + + /// "찜 처리에 실패했어요. 다시 시도해 주세요." + static var updateFailed: String { + Localization.localized("like.updateFailed", fallback: "찜 처리에 실패했어요. 다시 시도해 주세요.") + } + + /// "찜한 제휴를 불러오지 못했어요." + static var loadFailed: String { + Localization.localized("like.loadFailed", fallback: "찜한 제휴를 불러오지 못했어요.") + } + + /// "아직 찜한 제휴가 없어요." + static var emptyTitle: String { + Localization.localized("like.emptyTitle", fallback: "아직 찜한 제휴가 없어요.") + } + + /// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" + static var emptySubtitle: String { + Localization.localized("like.emptySubtitle", fallback: "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!") + } + + /// "메뉴 찜은 준비 중이에요." + static var menuComingSoonTitle: String { + Localization.localized("like.menuComingSoonTitle", fallback: "메뉴 찜은 준비 중이에요.") + } + + /// "곧 만나볼 수 있어요!" + static var menuComingSoonSubtitle: String { + Localization.localized("like.menuComingSoonSubtitle", fallback: "곧 만나볼 수 있어요!") + } + } } diff --git a/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift new file mode 100644 index 00000000..69b1aed4 --- /dev/null +++ b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift @@ -0,0 +1,61 @@ +// +// EmptyStateView.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 목록이 비었을 때 가운데에 메인/서브 카피를 보여주는 공용 뷰 +final class EmptyStateView: BaseUIView { + + // MARK: - UI Components + + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let stackView = UIStackView() + + // MARK: - View Setup + + override func configureUI() { + backgroundColor = .clear + + titleLabel.font = .subtitle1 + titleLabel.textColor = .gray700 + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + + subtitleLabel.font = .body3 + subtitleLabel.textColor = .gray500 + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 8 + stackView.addArrangedSubview(titleLabel) + stackView.addArrangedSubview(subtitleLabel) + + addSubview(stackView) + } + + override func setLayout() { + stackView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.leading.trailing.equalToSuperview().inset(24) + } + } + + // MARK: - Configuration + + func configure(title: String, subtitle: String?) { + titleLabel.text = title + subtitleLabel.text = subtitle + subtitleLabel.isHidden = (subtitle ?? "").isEmpty + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift b/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift similarity index 84% rename from EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift rename to EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift index c0ef9f97..0cca96d2 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift @@ -1,5 +1,5 @@ // -// MapFilterChipBar.swift +// FilterChipBar.swift // EATSSU // // Created by 황상환 on 8/23/26. @@ -12,7 +12,8 @@ import SnapKit import EATSSUDesign /// 지도 위에 떠 있는 가로 스크롤 필터 칩 바 (단일 선택) -final class MapFilterChipBar: BaseUIView { +/// 가로 스크롤 필터 칩 바 (단일 선택). 지도 필터와 찜 목록 필터 공용 +final class FilterChipBar: BaseUIView { // MARK: - Constants @@ -34,6 +35,7 @@ final class MapFilterChipBar: BaseUIView { private(set) var selectedIndex: Int = 0 private var chips: [UIButton] = [] + private var titles: [String] = [] // MARK: - UI Components @@ -80,6 +82,7 @@ final class MapFilterChipBar: BaseUIView { /// 칩 목록을 교체하고 첫 칩을 선택 상태로 둔다 func configure(titles: [String], selectedIndex: Int = 0) { chips.forEach { $0.removeFromSuperview() } + self.titles = titles chips = titles.enumerated().map { index, title in let chip = makeChip(title: title, index: index) stackView.addArrangedSubview(chip) @@ -115,10 +118,7 @@ final class MapFilterChipBar: BaseUIView { bottom: 0, trailing: Layout.chipHorizontalPadding ) - config.attributedTitle = AttributedString( - title, - attributes: AttributeContainer([.font: UIFont.button2]) - ) + config.attributedTitle = Self.chipTitle(title, isSelected: false) chip.configuration = config chip.snp.makeConstraints { $0.height.equalTo(Layout.chipHeight) } @@ -131,9 +131,17 @@ final class MapFilterChipBar: BaseUIView { chip.backgroundColor = isSelected ? highlightColor : .white chip.layer.borderColor = isSelected ? highlightColor.cgColor : UIColor.gray300.cgColor chip.configuration?.baseForegroundColor = isSelected ? .white : .gray500 + if titles.indices.contains(index) { + chip.configuration?.attributedTitle = Self.chipTitle(titles[index], isSelected: isSelected) + } } } + /// 선택 시 볼드(button2), 미선택 시 미디엄(body2) — 선택 칩은 배경과 같은 색 보더라 보더가 사라진 것처럼 보인다 + private static func chipTitle(_ title: String, isSelected: Bool) -> AttributedString { + AttributedString(title, attributes: AttributeContainer([.font: isSelected ? UIFont.button2 : UIFont.body2])) + } + /// 선택된 칩이 화면 밖에 있으면 보이도록 스크롤 private func scrollToSelectedChip(animated: Bool) { guard chips.indices.contains(selectedIndex) else { return } diff --git a/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift b/EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift similarity index 95% rename from EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift rename to EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift index bd9741df..d7b3e7d7 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift @@ -1,5 +1,5 @@ // -// MapTopTabView.swift +// UnderlineTabView.swift // EATSSU // // Created by 황상환 on 8/23/26. @@ -12,7 +12,8 @@ import SnapKit import EATSSUDesign /// 지도 상단 "학교 제휴 / 착한 가격" 언더라인 탭 -final class MapTopTabView: BaseUIView { +/// 밑줄 인디케이터가 있는 상단 탭. 지도(학교 제휴/착한 가격)와 찜(메뉴/제휴) 공용 +final class UnderlineTabView: BaseUIView { // MARK: - Properties diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/Contents.json deleted file mode 100644 index 0409a2cd..00000000 --- a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "coffee.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/coffee.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/coffee.png deleted file mode 100644 index e3453770a2ddbc6a932b6925dd74e625f16339d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1404 zcmV-?1%vvDP)@~0drDELIAGL9O(c600d`2O+f$vv5yPoYVitXC}HP6Tc%0C^!6vw?Pxzb`W-vq%K>P6Qk)kX$A6}Oz0`$p;*CkdX+#4TW zmn3^ZeL@6?j0g}J5g;-mKx9OK$cO-u5dk720z^gxh)k;M0lA~tRn|FDtpOLd0*Ld4 z5+X?fuxOLR(C_h5?VRTI;P)u|MhOvRQGUH%-?_?Xvzhnj!UE0`Pa7EiiV`Hsk|g_Z zEW9i&#X*MT+CT{t%LVnuhu0;F0NRJ2(Vh=StvEy3iUvgVz%1j#@lY`Sh6F(v5C?Zb za4Fk3AM?1qy*017fz8ZyqtWOo)n9<$6h-mJ9H0$R=KOd(W_PxIbU9YEk^nIB0)}0% zL1D;e_~IGe$N4-MyMq`(6h1FM|75`+l#1Dv4H@vTVL#wQpHlxqM-*hbE06m0v zG*D0r&Zz~QZ9v(yanIrPg@4u-Fy{Eh=h5}%8QZHvOr2N-;J&`z2q)OdA(aj9c8<3Ik zsDU|liPsFs6I^TE{V)3iD#Ed-xJkX1Fn5Cy_7MSUfHooby!5&CJxHj7qMFD^*v+AA zV0|AEz>U)e0Sot)1Q1$41c;0X5E&65G9o|?eCGSh5CQ7pD}b^UhyeAF8W0oSBjI_e zvK7d5bVYR3xNrF4A6tRcfC_|$?C*y-?jkjy1hb}V_PUM7p|A+%;$9azV8te}%34xpb9_01d*wiBOh$K^o!a;C%#VWfzG64WW}qsj}-tfCdQ3 z+~w*8NgEIjZ*hGalD*3n7o8Lt{34+YPBL|g#`g1UHd9mqs{nZ8 zD=10>N9`WK9!j74N%y(!u3-3DuG%U9hTM6DQD3hJ_2-rThpTYCUjL@yhNmdp-BW59 zXE$^=G<#^2q(?p&P65!kc=cs_N-Md8oC4_dk~s~Jyff))sHHohOX!@Ytxzg}t_6c} zMLKm^qdlKpeCLApGnMxhMJ?BOF9CRinNAnqYkX%g4~h3|=uW=pUZV3>=hQO6a}8Le zKGiz}djlJPKA(T42vkkny3~p7GD@3`o^Hq~5|tKYv^(N$n#qooxo3yRG^X*lpMz?m z<7UnszlhN__~yBYZFs}M=9@WMtSE|8e7Tp@y`LpldfqVF*F!Dt`eVvhOQFcZ+oZ0000< KMNUMnLSTYb5{{n$ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/Contents.json deleted file mode 100644 index f6f1fc80..00000000 --- a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "coffee_selected.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/coffee_selected.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/coffee_selected.png deleted file mode 100644 index 87447de41c6924a6767c691b1d3cca34e0b4a5a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1608 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 AmjD0& literal 0 HcmV?d00001 diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/Contents.json new file mode 100644 index 00000000..273a28c9 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_like_filled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/ic_like_filled.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/ic_like_filled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ac02576a86c304fe5cf873d4eba5fbc12cca0a54 GIT binary patch literal 5886 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 AssI20 literal 0 HcmV?d00001 diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/Contents.json new file mode 100644 index 00000000..09571ae2 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_like_line.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/ic_like_line.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/ic_like_line.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7faab53e60e90d8182eccb785479e39afb04718d GIT binary patch literal 6003 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 NXbenRTK~ww{{c@_O|Ad{ literal 0 HcmV?d00001 diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/Contents.json deleted file mode 100644 index 12e65aac..00000000 --- a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "images" : [ - { - "filename" : "icon_event_tooltip.svg", - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/icon_event_tooltip.svg b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/icon_event_tooltip.svg deleted file mode 100644 index b08f7c90..00000000 --- a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/icon_event_tooltip.imageset/icon_event_tooltip.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - From 1ff3b4777222a7e1718b4d8e7cf801f224049306 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:46:04 +0900 Subject: [PATCH 02/15] =?UTF-8?q?[#450]=20=ED=94=8C=EB=A1=9C=ED=8C=85=20?= =?UTF-8?q?=ED=95=98=ED=8A=B8=EB=A5=BC=20=ED=95=84=ED=84=B0=20=EC=B9=A9=20?= =?UTF-8?q?=EC=A4=84=20=EC=98=A4=EB=A5=B8=EC=AA=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20DEBUG=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=B0=9C=20=EB=AA=A9=EB=A1=9D=20=EB=AA=A9=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Like/PartnershipLikeManager.swift | 45 +++++++++++++ .../Like/LikedPartnershipMockData.swift | 67 +++++++++++++++++++ .../Presentation/Map/View/MainMapView.swift | 29 ++++++-- .../MainMapViewController.swift | 4 +- 4 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 122b5f98..309292b3 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -41,6 +41,11 @@ final class PartnershipLikeManager { /// 업체별 찜한 시각 (최근 추가순 정렬용, 로컬 보관) private var likedAtByStoreKey: [String: Double] + #if DEBUG + /// 서버에 찜 데이터가 없는 동안 목 데이터로 대체된 상태. 이때 찜 토글은 서버를 호출하지 않고 로컬에서만 처리한다 (DEBUG 전용) + private(set) var usesMockData = false + #endif + // MARK: - Query func isLiked(_ store: PartnershipDTO) -> Bool { @@ -65,6 +70,18 @@ final class PartnershipLikeManager { guard let self else { return } switch result { case .success(let stores): + #if DEBUG + if stores.isEmpty, !self.hasLoaded { + self.applyMockData() + completion(.success(self.likedStores)) + return + } + if self.usesMockData { + // 목 상태에서는 서버 목록을 덮어쓰지 않는다 + completion(.success(self.likedStores)) + return + } + #endif self.likedPartnershipIds = Set(stores.flatMap(\.partnershipIds)) self.likedStores = self.sortedByRecent(stores) self.hasLoaded = true @@ -96,6 +113,18 @@ final class PartnershipLikeManager { /// 업체 단위 찜 설정. 완료 시 로컬 상태(항목 id, 목록, 순서)까지 반영한다 func setLiked(_ liked: Bool, store: PartnershipDTO, completion: @escaping (Result) -> Void) { + #if DEBUG + // 목 데이터(음수 id)는 서버에 없으므로 로컬에서만 토글 + if usesMockData || store.partnershipIds.contains(where: { $0 < 0 }) { + for id in store.partnershipIds { + if liked { likedPartnershipIds.insert(id) } else { likedPartnershipIds.remove(id) } + } + applyLocalState(liked: liked, store: store) + DispatchQueue.main.async { completion(.success(())) } + return + } + #endif + let targets = store.partnershipIds.filter { likedPartnershipIds.contains($0) != liked } guard !targets.isEmpty else { applyLocalState(liked: liked, store: store) @@ -157,6 +186,22 @@ final class PartnershipLikeManager { // MARK: - Private + #if DEBUG + /// 찜 목록 목 데이터 적용 (DEBUG 전용). 최근 추가순 확인을 위해 순서대로 시각을 부여한다 + private func applyMockData() { + usesMockData = true + hasLoaded = true + let stores = LikedPartnershipMockData.samples + likedPartnershipIds = Set(stores.flatMap(\.partnershipIds)) + let now = Date().timeIntervalSince1970 + for (index, store) in stores.enumerated() where likedAtByStoreKey[store.storeKey] == nil { + likedAtByStoreKey[store.storeKey] = now - Double(index) + } + likedStores = sortedByRecent(stores) + persistOrder() + } + #endif + private func applyLocalState(liked: Bool, store: PartnershipDTO) { if liked { likedAtByStoreKey[store.storeKey] = Date().timeIntervalSince1970 diff --git a/EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift b/EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift new file mode 100644 index 00000000..0e9b2c14 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift @@ -0,0 +1,67 @@ +// +// LikedPartnershipMockData.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import Foundation + +#if DEBUG +/// 서버에 찜 데이터가 없는 동안 찜 목록 UI 확인용 목 데이터 (DEBUG 전용, id는 음수) +enum LikedPartnershipMockData { + + static let samples: [PartnershipDTO] = [ + store("맘스터치 숭실대점", type: "RESTAURANT", lat: 37.4963, lng: 126.9572, infos: [ + info(-101, college: "IT대학", desc: "학생증 인증하면 음료수 1개 증정"), + info(-102, department: "컴퓨터학부", desc: "세트 메뉴 10% 할인") + ]), + store("스타벅스 숭실대입구역점", type: "CAFE", lat: 37.4959, lng: 126.9538, infos: [ + info(-103, college: "경영대학", desc: "아메리카노 사이즈업 무료") + ]), + store("역전할머니맥주 숭실대점", type: "PUB", lat: 37.4945, lng: 126.9581, infos: [ + info(-104, college: "인문대학", desc: "4인 이상 방문 시 안주 1개 서비스"), + info(-105, department: "영어영문학과", desc: "생맥주 500cc 1+1") + ]), + store("이레김밥", type: "RESTAURANT", lat: 37.4952, lng: 126.9549, infos: [ + info(-106, college: "자연과학대학", desc: "김밥 한 줄 500원 할인") + ]), + store("카페봄봄 숭실대점", type: "CAFE", lat: 37.4967, lng: 126.9555, infos: [ + info(-107, college: "공과대학", desc: "학생증 인증 시 모든 음료 10% 할인 및 텀블러 지참 시 추가 할인") + ]), + store("파동추야", type: "PUB", lat: 37.4941, lng: 126.9560, infos: [ + info(-108, college: "사회과학대학", desc: "소주·맥주 1병 무료") + ]) + ] + + private static func store( + _ name: String, type: String, lat: Double, lng: Double, infos: [PartnershipInfoDTO] + ) -> PartnershipDTO { + PartnershipDTO( + storeName: name, + longitude: lng, + latitude: lat, + restaurantType: type, + naverMapUrl: nil, + kakaoMapUrl: nil, + partnershipInfos: infos + ) + } + + private static func info( + _ id: Int, college: String? = nil, department: String? = nil, desc: String + ) -> PartnershipInfoDTO { + PartnershipInfoDTO( + id: id, + collegeName: college, + departmentName: department, + likeCount: 1, + isLiked: true, + description: desc, + startDate: "2026-09-01", + endDate: "2026-12-31", + periodType: .normal + ) + } +} +#endif diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index ad3ac598..35f200a7 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -62,11 +62,11 @@ final class MainMapView: BaseUIView { let topTabView = UnderlineTabView(titles: MapTab.allCases.map { $0.title }) let filterChipBar = FilterChipBar() - /// 찜 탭으로 이동하는 플로팅 하트 버튼 (탭바 지도 탭에서만 노출) + /// 찜 탭으로 이동하는 플로팅 하트 버튼. 필터 칩과 같은 줄 오른쪽에 두고, 칩은 그 왼쪽 영역에서 스크롤된다 let likeButton: UIButton = { let button = UIButton(type: .custom) button.backgroundColor = .white - button.layer.cornerRadius = 24 + button.layer.cornerRadius = 18 button.layer.shadowColor = UIColor.black.cgColor button.layer.shadowOpacity = 0.12 button.layer.shadowOffset = CGSize(width: 0, height: 2) @@ -101,15 +101,30 @@ final class MainMapView: BaseUIView { $0.leading.trailing.bottom.equalToSuperview() } + likeButton.snp.makeConstraints { + $0.top.equalTo(mapView).offset(12) + $0.trailing.equalToSuperview().inset(16) + $0.width.height.equalTo(36) + } + filterChipBar.snp.makeConstraints { $0.top.equalTo(mapView).offset(12) - $0.leading.trailing.equalToSuperview() + $0.leading.equalToSuperview() + $0.trailing.equalTo(likeButton.snp.leading).offset(-8) } + } - likeButton.snp.makeConstraints { - $0.top.equalTo(filterChipBar.snp.bottom).offset(12) - $0.trailing.equalToSuperview().inset(16) - $0.width.height.equalTo(48) + /// 하트 노출 여부에 따라 칩 바 폭을 조정한다 (숨기면 칩이 오른쪽 끝까지 스크롤) + func setLikeButtonVisible(_ visible: Bool) { + likeButton.isHidden = !visible + filterChipBar.snp.remakeConstraints { + $0.top.equalTo(mapView).offset(12) + $0.leading.equalToSuperview() + if visible { + $0.trailing.equalTo(likeButton.snp.leading).offset(-8) + } else { + $0.trailing.equalToSuperview() + } } } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 084f4f15..e32afcb1 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -127,7 +127,7 @@ final class MainMapViewController: BaseViewController { view.addSubview(root) root.setTopTabVisible(mode == .tabbed) root.filterChipBar.isHidden = detailPartnership != nil - root.likeButton.isHidden = mode != .tabbed + root.setLikeButtonVisible(mode == .tabbed) } override func setLayout() { @@ -251,7 +251,7 @@ final class MainMapViewController: BaseViewController { private func applyTabUI() { root.topTabView.select(index: currentTab.rawValue, animated: false) // 찜은 학교 제휴 전용이라 착한 가격 탭에서는 플로팅 하트를 숨긴다 - root.likeButton.isHidden = mode != .tabbed || currentTab != .partnership + root.setLikeButtonVisible(mode == .tabbed && currentTab == .partnership) switch currentTab { case .partnership: From 9e3f42bec2cb4c7d71b34c331205579db4f08668 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:51:33 +0900 Subject: [PATCH 03/15] =?UTF-8?q?[#450]=20=EC=B0=9C=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A7=84=EC=9E=85=20=EC=8B=9C=ED=8A=B8=EB=A5=BC=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=ED=83=AD=EA=B3=BC=20=EB=8F=99=EC=9D=BC=ED=95=9C=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=86=92=EC=9D=B4=20detent=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MainMapViewController+Marker.swift | 18 +++--------------- .../ViewController/MainMapViewController.swift | 4 ++-- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index b9267c59..fa5a7ca2 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -147,8 +147,7 @@ extension MainMapViewController { } /// 제휴점 상세 바텀시트 표시 - /// - Parameter expanded: true면 펼친(large) 상태로 시작 (찜 목록에서 진입 시) - func showPartnershipDetail(for partnership: PartnershipDTO, expanded: Bool = false) { + func showPartnershipDetail(for partnership: PartnershipDTO) { MapAnalyticsManager.shared.logClickPartnerRestaurant( collegeId: currentCollegeId, majorId: currentDepartmentId, @@ -157,11 +156,7 @@ extension MainMapViewController { let detailVC = PartnershipDetailSheetViewController(partnership: partnership) detailVC.loadViewIfNeeded() - presentSheet( - detailVC, - heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }, - expanded: expanded - ) + presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) } /// 착한가격업소 상세 바텀시트 표시 @@ -174,11 +169,7 @@ extension MainMapViewController { } /// 커스텀 detent 시트 공통 표시. 표시 후 safe area 확정 시 invalidateDetents()로 높이가 보정됨 - private func presentSheet( - _ viewController: UIViewController, - heightProvider: @escaping () -> CGFloat?, - expanded: Bool = false - ) { + private func presentSheet(_ viewController: UIViewController, heightProvider: @escaping () -> CGFloat?) { if let sheet = viewController.sheetPresentationController { if #available(iOS 16.0, *) { let customDetent = UISheetPresentationController.Detent.custom { _ in heightProvider() } @@ -187,9 +178,6 @@ extension MainMapViewController { sheet.detents = [.medium(), .large()] } sheet.prefersGrabberVisible = true - if expanded { - sheet.selectedDetentIdentifier = .large - } } present(viewController, animated: true) } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index e32afcb1..714841f3 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -179,10 +179,10 @@ final class MainMapViewController: BaseViewController { super.viewDidAppear(animated) logScreenView(screenID: FirebaseScreenID.Map.map1) - // 찜 목록에서 진입: 시트를 펼친 상태로 바로 보여준다 (한 번만) + // 찜 목록에서 진입: 마커 탭과 같은 내용 높이 시트를 바로 띄운다 (한 번만) if let store = detailPartnership, !didPresentDetailSheet { didPresentDetailSheet = true - showPartnershipDetail(for: store, expanded: true) + showPartnershipDetail(for: store) } } From 66893c20296293fe3a752f176b0c4168140af346 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:52:05 +0900 Subject: [PATCH 04/15] =?UTF-8?q?[#450]=20=EC=B0=9C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=C2=B7=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84?= =?UTF-8?q?=20=ED=9C=B4=EC=A7=80=ED=86=B5=20=EC=83=89=EC=9D=84=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EB=B9=A8=EA=B0=95(error)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewController/LikedPartnershipEditViewController.swift | 2 +- .../Like/ViewController/LikedPartnershipViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift index ce59f017..5a3f4cd7 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift @@ -139,7 +139,7 @@ final class LikedPartnershipEditViewController: BaseViewController { let count = selectedKeys.count deleteButton.title = count == 0 ? TextLiteral.Like.delete : TextLiteral.Like.deleteCount(count) deleteButton.isEnabled = count > 0 - deleteButton.backgroundColor = count > 0 ? .danger : .gray300 + deleteButton.backgroundColor = count > 0 ? .error : .gray300 } private func updateSelectAllButton() { diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift index 9c14d86c..535336e7 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -249,7 +249,7 @@ extension LikedPartnershipViewController: UITableViewDelegate { let size = CGSize(width: 48, height: 48) let icon = EATSSUDesignAsset.Images.icDelete.image.withTintColor(.white, renderingMode: .alwaysOriginal) return UIGraphicsImageRenderer(size: size).image { context in - UIColor.danger.setFill() + UIColor.error.setFill() context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) let iconSize = CGSize(width: 24, height: 24) icon.draw(in: CGRect( From 21a383ba8e504ebfe5aa5ea853d6104bbe73e4b2 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:56:23 +0900 Subject: [PATCH 05/15] =?UTF-8?q?[#450]=20=EC=B0=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A7=84=EC=9E=85=EC=9D=84=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=20=ED=83=AD=20=EC=A0=84=ED=99=98=20+=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=91=9C=EC=8B=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD,?= =?UTF-8?q?=20=EB=AA=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B0=9C=20=EA=B0=9C=EC=88=98=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=20=EC=83=9D=EB=9E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Like/PartnershipLikeManager.swift | 8 +- .../LikedPartnershipViewController.swift | 4 +- .../MainMapViewController.swift | 92 ++++++++++++------- .../CustomTabBarContainerController.swift | 8 ++ 4 files changed, 77 insertions(+), 35 deletions(-) diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 309292b3..53e59f0f 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -93,8 +93,14 @@ final class PartnershipLikeManager { } } - /// 찜 가능 최대 개수 = 내 제휴 업체 수 + /// 찜 가능 최대 개수 = 내 제휴 업체 수. nil이면 제한 없이 진행 (조회 실패 또는 DEBUG 목 데이터) func fetchLikeLimit(completion: @escaping (Int?) -> Void) { + #if DEBUG + if usesMockData { + completion(nil) + return + } + #endif NetworkService.shared.request( MyRouter.getMyPartnerships, responseType: [PartnershipDTO].self, diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift index 535336e7..541c3d68 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -185,9 +185,9 @@ final class LikedPartnershipViewController: BaseViewController { } } + /// 지도 탭으로 전환해 해당 업체 시트를 띄운다. 지도의 뒤로가기로 이 화면에 복귀 private func showDetail(for store: PartnershipDTO) { - let mapVC = MainMapViewController(mode: .partnershipDetail(store)) - navigationController?.pushViewController(mapVC, animated: true) + (tabBarController as? CustomTabBarContainerController)?.showPartnershipDetailOnMap(store) } // MARK: - Actions diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 714841f3..c2a6438f 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -27,21 +27,11 @@ final class MainMapViewController: BaseViewController { } /// 화면 진입 형태 - enum Mode: Equatable { + enum Mode { /// 탭바 안의 지도 탭: 상단에 학교 제휴 / 착한 가격 탭 노출 case tabbed /// 로그인 화면에서 바로 진입하는 착한가격업소 지도 (탭 없음) case standaloneGoodPrice - /// 찜 목록에서 진입: 해당 업체 마커만 표시하고 상세 시트를 펼친 상태로 시작 (탭·필터 없음) - case partnershipDetail(PartnershipDTO) - - static func == (lhs: Mode, rhs: Mode) -> Bool { - switch (lhs, rhs) { - case (.tabbed, .tabbed), (.standaloneGoodPrice, .standaloneGoodPrice): return true - case let (.partnershipDetail(l), .partnershipDetail(r)): return l.storeKey == r.storeKey - default: return false - } - } } // MARK: - Properties @@ -109,13 +99,11 @@ final class MainMapViewController: BaseViewController { super.init(nibName: nil, bundle: nil) } - /// 찜 목록에서 진입한 경우의 대상 업체 - private var detailPartnership: PartnershipDTO? { - if case let .partnershipDetail(store) = mode { return store } - return nil - } + /// 찜 목록에서 넘어온 업체. 지도 탭이 화면에 나타난 뒤 시트로 띄우고 비운다 + private var pendingDetailStore: PartnershipDTO? - override var shouldHideTabBar: Bool { detailPartnership != nil } + /// 찜 목록에서 넘어온 상태. 네비게이션 바 뒤로가기가 찜 탭 복귀로 동작한다 + private var returnsToLikeTab = false required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") @@ -126,7 +114,6 @@ final class MainMapViewController: BaseViewController { override func configureUI() { view.addSubview(root) root.setTopTabVisible(mode == .tabbed) - root.filterChipBar.isHidden = detailPartnership != nil root.setLikeButtonVisible(mode == .tabbed) } @@ -162,11 +149,6 @@ final class MainMapViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - if let store = detailPartnership { - showSingleStore(store) - return - } - switch currentTab { case .partnership: refreshPartnershipTab() @@ -179,20 +161,66 @@ final class MainMapViewController: BaseViewController { super.viewDidAppear(animated) logScreenView(screenID: FirebaseScreenID.Map.map1) - // 찜 목록에서 진입: 마커 탭과 같은 내용 높이 시트를 바로 띄운다 (한 번만) - if let store = detailPartnership, !didPresentDetailSheet { - didPresentDetailSheet = true - showPartnershipDetail(for: store) + // 찜 목록에서 넘어온 업체가 있으면 화면이 붙은 뒤 시트를 띄운다 + presentPendingDetailIfNeeded() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // 다른 탭으로 옮겨가면 찜 복귀 상태를 해제 + if returnsToLikeTab { + returnsToLikeTab = false + updateLikeReturnButton() } } - private var didPresentDetailSheet = false + // MARK: - Like - /// 찜 상세 진입: 해당 업체 마커만 올리고 카메라를 그 위치로 - private func showSingleStore(_ store: PartnershipDTO) { - _ = beginLoad() - displayMarkers([makeMarkerItem(for: store)]) + /// 찜 목록에서 업체를 선택했을 때: 학교 제휴 탭으로 맞추고, 지도가 나타나면 해당 업체 시트를 띄운다 + func showDetailFromLikes(_ store: PartnershipDTO) { + if currentTab != .partnership { + switchTab(to: .partnership) + } + pendingDetailStore = store + returnsToLikeTab = true + updateLikeReturnButton() + presentPendingDetailIfNeeded() + } + + private func presentPendingDetailIfNeeded() { + guard let store = pendingDetailStore, + viewIfLoaded?.window != nil, + presentedViewController == nil else { return } + pendingDetailStore = nil moveCamera(to: NMGLatLng(lat: store.latitude, lng: store.longitude), animated: false) + showPartnershipDetail(for: store) + } + + /// 찜 목록에서 넘어온 경우에만 뒤로가기(찜 탭 복귀) 버튼을 보여준다 + private func updateLikeReturnButton() { + guard returnsToLikeTab else { + navigationItem.leftBarButtonItem = nil + return + } + let backItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(didTapReturnToLikes) + ) + backItem.tintColor = .gray500 + navigationItem.leftBarButtonItem = backItem + } + + @objc private func didTapReturnToLikes() { + returnsToLikeTab = false + updateLikeReturnButton() + let container = tabBarController as? CustomTabBarContainerController + if let presented = presentedViewController { + presented.dismiss(animated: true) { container?.showLikedPartnerships(fromMap: false) } + } else { + container?.showLikedPartnerships(fromMap: false) + } } @objc private func didTapLikeButton() { diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index aa5cff05..485b0910 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -113,6 +113,14 @@ final class CustomTabBarContainerController: UITabBarController { public func returnToMapTab() { setTab(index: Tab.map.rawValue) } + + /// 찜 목록에서 업체 선택 → 지도 탭으로 전환해 해당 업체 상세 시트를 띄운다 + public func showPartnershipDetailOnMap(_ store: PartnershipDTO) { + guard let mapVC = getNavController(at: Tab.map.rawValue)?.viewControllers.first as? MainMapViewController else { return } + getNavController(at: Tab.map.rawValue)?.popToRootViewController(animated: false) + mapVC.showDetailFromLikes(store) + setTab(index: Tab.map.rawValue) + } /// 특정 인덱스의 네비게이션 컨트롤러를 반환 public func getNavController(at index: Int) -> UINavigationController? { From 0c531f2cd05703215fe5aadc0860f80ddb5527bb Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:01:16 +0900 Subject: [PATCH 06/15] =?UTF-8?q?[#450]=20=EB=B9=88=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=ED=9A=8C=EC=83=89=C2=B7=EC=83=81=EB=8B=A8?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=EB=A1=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Utility/UIComponent/EmptyStateView.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift index 69b1aed4..5443245f 100644 --- a/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift @@ -11,7 +11,8 @@ import SnapKit import EATSSUDesign -/// 목록이 비었을 때 가운데에 메인/서브 카피를 보여주는 공용 뷰 +/// 목록이 비었을 때 메인/서브 카피를 보여주는 공용 뷰 +/// 디자인 기준 문구 위치는 정중앙보다 약간 위(컨테이너 높이의 약 45%)이며 두 줄 모두 회색이다 final class EmptyStateView: BaseUIView { // MARK: - UI Components @@ -25,13 +26,13 @@ final class EmptyStateView: BaseUIView { override func configureUI() { backgroundColor = .clear - titleLabel.font = .subtitle1 - titleLabel.textColor = .gray700 + titleLabel.font = .subtitle2 + titleLabel.textColor = .gray600 titleLabel.textAlignment = .center titleLabel.numberOfLines = 0 - subtitleLabel.font = .body3 - subtitleLabel.textColor = .gray500 + subtitleLabel.font = .caption2 + subtitleLabel.textColor = .gray600 subtitleLabel.textAlignment = .center subtitleLabel.numberOfLines = 0 @@ -46,7 +47,9 @@ final class EmptyStateView: BaseUIView { override func setLayout() { stackView.snp.makeConstraints { - $0.center.equalToSuperview() + $0.centerX.equalToSuperview() + // 정중앙(1.0)보다 위로: 디자인의 문구 위치(화면 약 53%)에 맞춤 + $0.centerY.equalTo(snp.centerY).multipliedBy(0.9) $0.leading.trailing.equalToSuperview().inset(24) } } From 300dc25b31608e740b9f2d1ae266575a1ab246a3 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:07:06 +0900 Subject: [PATCH 07/15] =?UTF-8?q?[#450]=20=EC=B0=9C=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=97=AC=EB=B0=B1=C2=B7=ED=81=AC=EA=B8=B0=C2=B7=EC=83=89?= =?UTF-8?q?=EC=9D=84=20=EB=94=94=EC=9E=90=EC=9D=B8=20SVG=20=EC=88=98?= =?UTF-8?q?=EC=B9=98=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=A1=B0=EC=A0=95=20?= =?UTF-8?q?(=ED=83=AD=C2=B7=EC=B9=A9=C2=B7=EC=85=80=C2=B7=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=C2=B7=EC=8A=A4=EC=99=80=EC=9D=B4=ED=94=84=C2=B7?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=ED=8C=85=20=ED=95=98=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Like/View/LikedPartnershipCell.swift | 47 ++++++++++++------- .../LikedPartnershipEditViewController.swift | 36 +++++++++----- .../LikedPartnershipViewController.swift | 23 +++++---- .../Presentation/Map/View/MainMapView.swift | 8 ++-- ...PartnershipDetailSheetViewController.swift | 2 +- .../Utility/UIComponent/FilterChipBar.swift | 17 +++---- .../UIComponent/UnderlineTabView.swift | 20 +++++--- 7 files changed, 92 insertions(+), 61 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift index adc32534..f3e92a6c 100644 --- a/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift +++ b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift @@ -25,11 +25,17 @@ final class LikedPartnershipCell: UITableViewCell { static let identifier = "LikedPartnershipCell" + /// 디자인(390pt 기준): 좌우 24, 아이콘 36, 텍스트 간격 12, 행 높이 77, 화살표 8×12 trailing 28 private enum Layout { - static let horizontalInset: CGFloat = 20 - static let iconSize: CGFloat = 48 - static let checkSize: CGFloat = 24 - static let verticalInset: CGFloat = 16 + static let horizontalInset: CGFloat = 24 + static let iconSize: CGFloat = 36 + static let checkSize: CGFloat = 18 + static let checkLeading: CGFloat = 27 + static let textSpacing: CGFloat = 12 + static let checkTextSpacing: CGFloat = 15 + static let topInset: CGFloat = 18 + static let bottomInset: CGFloat = 16 + static let chevronTrailing: CGFloat = 28 } // MARK: - UI Components @@ -72,12 +78,12 @@ final class LikedPartnershipCell: UITableViewCell { nameLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) typeLabel.font = .caption2 - typeLabel.textColor = .gray500 + typeLabel.textColor = .gray600 typeLabel.setContentHuggingPriority(.required, for: .horizontal) typeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) descriptionLabel.font = .body3 - descriptionLabel.textColor = .gray700 + descriptionLabel.textColor = .gray600 descriptionLabel.numberOfLines = 1 descriptionLabel.lineBreakMode = .byTruncatingTail @@ -86,13 +92,13 @@ final class LikedPartnershipCell: UITableViewCell { titleStackView.axis = .horizontal titleStackView.alignment = .lastBaseline - titleStackView.spacing = 6 + titleStackView.spacing = 8 titleStackView.addArrangedSubview(nameLabel) titleStackView.addArrangedSubview(typeLabel) textStackView.axis = .vertical textStackView.alignment = .leading - textStackView.spacing = 6 + textStackView.spacing = 8 textStackView.addArrangedSubview(titleStackView) textStackView.addArrangedSubview(descriptionLabel) @@ -101,7 +107,7 @@ final class LikedPartnershipCell: UITableViewCell { private func setLayout() { checkImageView.snp.makeConstraints { - $0.leading.equalToSuperview().inset(Layout.horizontalInset) + $0.leading.equalToSuperview().inset(Layout.checkLeading) $0.centerY.equalToSuperview() $0.width.height.equalTo(Layout.checkSize) } @@ -113,16 +119,17 @@ final class LikedPartnershipCell: UITableViewCell { } chevronImageView.snp.makeConstraints { - $0.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.trailing.equalToSuperview().inset(Layout.chevronTrailing) $0.centerY.equalToSuperview() - $0.width.equalTo(10) - $0.height.equalTo(16) + $0.width.equalTo(8) + $0.height.equalTo(12) } textStackView.snp.makeConstraints { - $0.leading.equalTo(iconImageView.snp.trailing).offset(16) - $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-12) - $0.top.bottom.equalToSuperview().inset(Layout.verticalInset) + $0.leading.equalTo(iconImageView.snp.trailing).offset(Layout.textSpacing) + $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-16) + $0.top.equalToSuperview().inset(Layout.topInset) + $0.bottom.equalToSuperview().inset(Layout.bottomInset) } } @@ -137,11 +144,13 @@ final class LikedPartnershipCell: UITableViewCell { iconImageView.image = MainMapViewController.partnershipIcon(for: store.restaurantType, isFestival: false) let leadingAnchor: ConstraintItem + let spacing: CGFloat switch mode { case .normal: checkImageView.isHidden = true iconImageView.isHidden = false leadingAnchor = iconImageView.snp.trailing + spacing = Layout.textSpacing case .editing(let isSelected): // 편집 모드에서는 업종 아이콘 대신 체크박스를 보여준다 checkImageView.isHidden = false @@ -150,11 +159,13 @@ final class LikedPartnershipCell: UITableViewCell { ? EATSSUDesignAsset.Images.icCheck.image : EATSSUDesignAsset.Images.icUncheck.image leadingAnchor = checkImageView.snp.trailing + spacing = Layout.checkTextSpacing } textStackView.snp.remakeConstraints { - $0.leading.equalTo(leadingAnchor).offset(16) - $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-12) - $0.top.bottom.equalToSuperview().inset(Layout.verticalInset) + $0.leading.equalTo(leadingAnchor).offset(spacing) + $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-16) + $0.top.equalToSuperview().inset(Layout.topInset) + $0.bottom.equalToSuperview().inset(Layout.bottomInset) } } } diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift index 5a3f4cd7..ec123230 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift @@ -16,9 +16,14 @@ final class LikedPartnershipEditViewController: BaseViewController { // MARK: - Constants + /// 디자인: 전체 선택 행 52, 체크 18 (leading 35), 구분선 2pt, 삭제 버튼 52/r8, 하단 safe area + 6 private enum Layout { - static let horizontalInset: CGFloat = 20 - static let headerHeight: CGFloat = 56 + static let horizontalInset: CGFloat = 24 + static let headerHeight: CGFloat = 52 + static let headerLeading: CGFloat = 35 + static let buttonHeight: CGFloat = 52 + static let buttonCornerRadius: CGFloat = 8 + static let buttonBottomInset: CGFloat = 6 } // MARK: - Properties @@ -59,8 +64,8 @@ final class LikedPartnershipEditViewController: BaseViewController { view.backgroundColor = .white var config = UIButton.Configuration.plain() - config.image = EATSSUDesignAsset.Images.icUncheck.image - config.imagePadding = 12 + config.image = EATSSUDesignAsset.Images.icUncheck.image.resize(newWidth: 18) + config.imagePadding = 15 config.baseForegroundColor = .label config.contentInsets = .zero config.attributedTitle = AttributedString( @@ -70,19 +75,20 @@ final class LikedPartnershipEditViewController: BaseViewController { selectAllButton.configuration = config selectAllButton.contentHorizontalAlignment = .leading - headerSeparator.backgroundColor = .gray200 + headerSeparator.backgroundColor = .gray100 tableView.register(LikedPartnershipCell.self, forCellReuseIdentifier: LikedPartnershipCell.identifier) tableView.dataSource = self tableView.delegate = self tableView.separatorInset = UIEdgeInsets(top: 0, left: Layout.horizontalInset, bottom: 0, right: Layout.horizontalInset) - tableView.separatorColor = .gray200 + tableView.separatorColor = .gray100 tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 84 + tableView.estimatedRowHeight = 77 tableView.tableFooterView = UIView() tableView.showsVerticalScrollIndicator = false deleteButton.title = TextLiteral.Like.delete + deleteButton.layer.cornerRadius = Layout.buttonCornerRadius updateDeleteButton() view.addSubviews(selectAllButton, headerSeparator, tableView, deleteButton) @@ -91,19 +97,23 @@ final class LikedPartnershipEditViewController: BaseViewController { override func setLayout() { selectAllButton.snp.makeConstraints { $0.top.equalTo(view.safeAreaLayoutGuide) - $0.leading.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.leading.equalToSuperview().inset(Layout.headerLeading) + $0.trailing.equalToSuperview().inset(Layout.horizontalInset) $0.height.equalTo(Layout.headerHeight) } headerSeparator.snp.makeConstraints { $0.top.equalTo(selectAllButton.snp.bottom) $0.leading.trailing.equalToSuperview() - $0.height.equalTo(1) + $0.height.equalTo(2) } deleteButton.snp.makeConstraints { $0.leading.trailing.equalToSuperview().inset(Layout.horizontalInset) - $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(12) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(Layout.buttonBottomInset) + } + deleteButton.snp.updateConstraints { + $0.height.equalTo(Layout.buttonHeight) } tableView.snp.makeConstraints { @@ -139,13 +149,13 @@ final class LikedPartnershipEditViewController: BaseViewController { let count = selectedKeys.count deleteButton.title = count == 0 ? TextLiteral.Like.delete : TextLiteral.Like.deleteCount(count) deleteButton.isEnabled = count > 0 - deleteButton.backgroundColor = count > 0 ? .error : .gray300 + deleteButton.backgroundColor = count > 0 ? .error : .gray200 } private func updateSelectAllButton() { - selectAllButton.configuration?.image = isAllSelected + selectAllButton.configuration?.image = (isAllSelected ? EATSSUDesignAsset.Images.icCheck.image - : EATSSUDesignAsset.Images.icUncheck.image + : EATSSUDesignAsset.Images.icUncheck.image).resize(newWidth: 18) } // MARK: - Actions diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift index 541c3d68..d1558ff7 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -16,9 +16,11 @@ final class LikedPartnershipViewController: BaseViewController { // MARK: - Constants + /// 디자인: 탭 밑줄과 칩 사이 12, 좌우 24, 칩과 첫 행 사이 7 private enum Layout { - static let chipBarTop: CGFloat = 16 - static let horizontalInset: CGFloat = 20 + static let chipBarTop: CGFloat = 12 + static let horizontalInset: CGFloat = 24 + static let listTop: CGFloat = 7 } /// 찜 목록 필터 (축제 제외) @@ -46,19 +48,20 @@ final class LikedPartnershipViewController: BaseViewController { override func configureUI() { view.backgroundColor = .white + filterChipBar.horizontalInset = Layout.horizontalInset filterChipBar.configure(titles: Self.filters.map { $0.title }) editButton.setTitle(TextLiteral.Like.edit, for: .normal) editButton.titleLabel?.font = .body2 - editButton.setTitleColor(.gray700, for: .normal) + editButton.setTitleColor(.gray500, for: .normal) tableView.register(LikedPartnershipCell.self, forCellReuseIdentifier: LikedPartnershipCell.identifier) tableView.dataSource = self tableView.delegate = self tableView.separatorInset = UIEdgeInsets(top: 0, left: Layout.horizontalInset, bottom: 0, right: Layout.horizontalInset) - tableView.separatorColor = .gray200 + tableView.separatorColor = .gray100 tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 84 + tableView.estimatedRowHeight = 77 tableView.tableFooterView = UIView() tableView.showsVerticalScrollIndicator = false @@ -81,7 +84,7 @@ final class LikedPartnershipViewController: BaseViewController { } tableView.snp.makeConstraints { - $0.top.equalTo(filterChipBar.snp.bottom).offset(12) + $0.top.equalTo(filterChipBar.snp.bottom).offset(Layout.listTop) $0.leading.trailing.bottom.equalToSuperview() } @@ -244,14 +247,14 @@ extension LikedPartnershipViewController: UITableViewDelegate { return configuration } - /// 빨간 원 안에 흰 휴지통 (디자인의 스와이프 삭제 아이콘) + /// 빨간 원(36, danger) 안에 흰 휴지통 (디자인의 스와이프 삭제 아이콘) private static let deleteActionImage: UIImage = { - let size = CGSize(width: 48, height: 48) + let size = CGSize(width: 36, height: 36) let icon = EATSSUDesignAsset.Images.icDelete.image.withTintColor(.white, renderingMode: .alwaysOriginal) return UIGraphicsImageRenderer(size: size).image { context in - UIColor.error.setFill() + UIColor.danger.setFill() context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) - let iconSize = CGSize(width: 24, height: 24) + let iconSize = CGSize(width: 20, height: 20) icon.draw(in: CGRect( origin: CGPoint(x: (size.width - iconSize.width) / 2, y: (size.height - iconSize.height) / 2), size: iconSize diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index 35f200a7..b18afbde 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -67,12 +67,10 @@ final class MainMapView: BaseUIView { let button = UIButton(type: .custom) button.backgroundColor = .white button.layer.cornerRadius = 18 - button.layer.shadowColor = UIColor.black.cgColor - button.layer.shadowOpacity = 0.12 - button.layer.shadowOffset = CGSize(width: 0, height: 2) - button.layer.shadowRadius = 6 + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor.gray300.cgColor button.setImage(EATSSUDesignAsset.Images.icLikeLine.image, for: .normal) - button.tintColor = .label + button.tintColor = .gray700 return button }() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index fbe6aeb3..f62a55ea 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -56,7 +56,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { storeNameLabel.font = .header2 storeNameLabel.textColor = .label - likeButton.tintColor = .label + likeButton.tintColor = .gray700 likeButton.addTarget(self, action: #selector(didTapLike), for: .touchUpInside) updateLikeButton() diff --git a/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift b/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift index 0cca96d2..9604b9ec 100644 --- a/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift @@ -20,14 +20,20 @@ final class FilterChipBar: BaseUIView { private enum Layout { static let chipHeight: CGFloat = 36 static let chipSpacing: CGFloat = 8 - static let horizontalInset: CGFloat = 16 - static let chipHorizontalPadding: CGFloat = 16 + static let chipHorizontalPadding: CGFloat = 14 } // MARK: - Properties var onSelect: ((Int) -> Void)? + /// 첫/마지막 칩과 화면 가장자리 사이 여백 (지도 16, 찜 목록 24) + var horizontalInset: CGFloat = 16 { + didSet { + scrollView.contentInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) + } + } + /// 선택 칩 배경색 (축제 필터 등 모드에 따라 변경 가능) var highlightColor: UIColor = .primary { didSet { applySelection() } @@ -50,12 +56,7 @@ final class FilterChipBar: BaseUIView { scrollView.showsHorizontalScrollIndicator = false scrollView.alwaysBounceHorizontal = true scrollView.clipsToBounds = false - scrollView.contentInset = UIEdgeInsets( - top: 0, - left: Layout.horizontalInset, - bottom: 0, - right: Layout.horizontalInset - ) + scrollView.contentInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) stackView.axis = .horizontal stackView.spacing = Layout.chipSpacing diff --git a/EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift b/EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift index d7b3e7d7..412dfc89 100644 --- a/EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift @@ -15,6 +15,14 @@ import EATSSUDesign /// 밑줄 인디케이터가 있는 상단 탭. 지도(학교 제휴/착한 가격)와 찜(메뉴/제휴) 공용 final class UnderlineTabView: BaseUIView { + // MARK: - Constants + + /// 디자인: 탭 높이 52, 밑줄 3pt (비활성 gray400 / 활성 primary) + private enum Layout { + static let tabHeight: CGFloat = 52 + static let lineHeight: CGFloat = 3 + } + // MARK: - Properties var onSelect: ((Int) -> Void)? @@ -44,7 +52,7 @@ final class UnderlineTabView: BaseUIView { // setLayout()은 버튼 추가 전에 호출되므로 여기서 탭 개수 기준으로 다시 잡는다 indicatorView.snp.remakeConstraints { $0.bottom.leading.equalToSuperview() - $0.height.equalTo(2) + $0.height.equalTo(Layout.lineHeight) $0.width.equalToSuperview().dividedBy(max(buttons.count, 1)) } select(index: 0, animated: false) @@ -63,7 +71,7 @@ final class UnderlineTabView: BaseUIView { stackView.distribution = .fillEqually stackView.alignment = .fill - bottomLine.backgroundColor = .gray300 + bottomLine.backgroundColor = .gray400 indicatorView.backgroundColor = .primary addSubviews(stackView, bottomLine, indicatorView) @@ -72,18 +80,18 @@ final class UnderlineTabView: BaseUIView { override func setLayout() { stackView.snp.makeConstraints { $0.top.leading.trailing.equalToSuperview() - $0.height.equalTo(44) + $0.height.equalTo(Layout.tabHeight) } bottomLine.snp.makeConstraints { $0.top.equalTo(stackView.snp.bottom) $0.leading.trailing.bottom.equalToSuperview() - $0.height.equalTo(1) + $0.height.equalTo(Layout.lineHeight) } indicatorView.snp.makeConstraints { $0.bottom.leading.equalToSuperview() - $0.height.equalTo(2) + $0.height.equalTo(Layout.lineHeight) $0.width.equalToSuperview() } } @@ -95,7 +103,7 @@ final class UnderlineTabView: BaseUIView { selectedIndex = index for (buttonIndex, button) in buttons.enumerated() { - button.setTitleColor(buttonIndex == index ? .primary : .gray500, for: .normal) + button.setTitleColor(buttonIndex == index ? .primary : .gray400, for: .normal) } if animated { From 50e21df721c7206205c01632ec7e30a73c0caa72 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:10:51 +0900 Subject: [PATCH 08/15] =?UTF-8?q?[#450]=20=EC=A7=80=EB=8F=84=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=ED=95=98=ED=8A=B8=EA=B0=80=20=EC=B0=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=B0=9C=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Like/PartnershipLikeManager.swift | 14 ++++++++++++- ...PartnershipDetailSheetViewController.swift | 21 ++++++++++++------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 53e59f0f..16e8924f 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -50,7 +50,19 @@ final class PartnershipLikeManager { func isLiked(_ store: PartnershipDTO) -> Bool { let ids = store.partnershipIds - return !ids.isEmpty && ids.allSatisfy { likedPartnershipIds.contains($0) } + guard !ids.isEmpty else { return false } + // 서버 찜 목록을 아직 받기 전이면 지도 응답에 실린 항목별 isLiked로 판정 + guard hasLoaded else { return store.partnershipInfos.allSatisfy(\.isLiked) } + return ids.allSatisfy { likedPartnershipIds.contains($0) } + } + + /// 찜 상태를 한 번은 서버에서 받아온 뒤 이어서 실행. 이미 받아왔으면 즉시 실행 + func ensureLoaded(completion: @escaping () -> Void) { + guard !hasLoaded else { + completion() + return + } + refresh { _ in completion() } } /// 찜한 업체 수 (최대 개수 제한 판정용) diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index f62a55ea..2e3554e1 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -123,6 +123,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() configureData() + // 찜 탭을 열기 전에 지도에서 시트를 열어도 하트가 서버 찜 상태를 반영하도록 + PartnershipLikeManager.shared.ensureLoaded { [weak self] in + self?.updateLikeButton() + } } override func viewDidAppear(_ animated: Bool) { @@ -178,14 +182,17 @@ final class PartnershipDetailSheetViewController: BaseViewController { return } - PartnershipLikeManager.shared.fetchLikeLimit { [weak self] limit in - guard let self else { return } - if let limit, PartnershipLikeManager.shared.likedStoreCount >= limit { - self.likeButton.isEnabled = true - self.showToast(message: TextLiteral.Like.limitReached(limit), type: .warning) - return + // 찜 목록과 최대 개수를 확보한 뒤 추가 + PartnershipLikeManager.shared.ensureLoaded { [weak self] in + PartnershipLikeManager.shared.fetchLikeLimit { [weak self] limit in + guard let self else { return } + if let limit, PartnershipLikeManager.shared.likedStoreCount >= limit { + self.likeButton.isEnabled = true + self.showToast(message: TextLiteral.Like.limitReached(limit), type: .warning) + return + } + self.setLiked(true) } - self.setLiked(true) } } From f39137e794aa186b8d3443bd87a137561a48493c Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:26:55 +0900 Subject: [PATCH 09/15] =?UTF-8?q?[#450]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=EC=B0=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EC=A0=84=20=EB=AA=A9=EB=A1=9D=20=ED=99=95=EB=B3=B4?= =?UTF-8?q?,=20=EC=97=85=EC=B2=B4=20=EC=A0=84=EC=B2=B4=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EA=B8=B0=EC=A4=80=20=EC=B0=9C,=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=8B=9C=20=EC=B0=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=B4=88=EA=B8=B0=ED=99=94,=20=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=ED=95=98=EA=B8=B0=201=ED=9A=8C=20=EC=A0=9C=ED=95=9C,=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=A6=AC,?= =?UTF-8?q?=20setTab=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Like/PartnershipLikeManager.swift | 44 +++++++++++++++++-- .../Sources/Data/LocalDB/RealmService.swift | 2 + .../ViewController/LikeViewController.swift | 10 +++++ .../LikedPartnershipViewController.swift | 4 ++ .../MainMapViewController+Marker.swift | 10 +++-- .../MainMapViewController+Network.swift | 4 +- .../MainMapViewController.swift | 3 +- ...PartnershipDetailSheetViewController.swift | 27 ++++++++---- .../PromotionPopupViewController.swift | 6 +-- .../CustomTabBarContainerController.swift | 15 ++++--- 10 files changed, 97 insertions(+), 28 deletions(-) diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 16e8924f..9fdc9ebf 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -42,10 +42,25 @@ final class PartnershipLikeManager { private var likedAtByStoreKey: [String: Double] #if DEBUG - /// 서버에 찜 데이터가 없는 동안 목 데이터로 대체된 상태. 이때 찜 토글은 서버를 호출하지 않고 로컬에서만 처리한다 (DEBUG 전용) + /// 서버 찜 목록이 비어 있을 때 목 데이터로 대체할지 (DEBUG 전용). 실제 API 경로를 검증할 땐 false로 + static let isMockEnabled = true + + /// 목 데이터로 대체된 상태. 이때 찜 토글은 서버를 호출하지 않고 로컬에서만 처리한다 private(set) var usesMockData = false #endif + /// 로그아웃·탈퇴·세션 만료 시 호출. 다른 계정의 찜 상태가 남지 않도록 메모리와 로컬 순서 기록을 모두 비운다 + func reset() { + likedPartnershipIds = [] + likedStores = [] + hasLoaded = false + likedAtByStoreKey = [:] + UserDefaults.standard.removeObject(forKey: Constant.likedOrderKey) + #if DEBUG + usesMockData = false + #endif + } + // MARK: - Query func isLiked(_ store: PartnershipDTO) -> Bool { @@ -83,7 +98,7 @@ final class PartnershipLikeManager { switch result { case .success(let stores): #if DEBUG - if stores.isEmpty, !self.hasLoaded { + if Self.isMockEnabled, stores.isEmpty, !self.hasLoaded { self.applyMockData() completion(.success(self.likedStores)) return @@ -94,7 +109,7 @@ final class PartnershipLikeManager { return } #endif - self.likedPartnershipIds = Set(stores.flatMap(\.partnershipIds)) + self.likedPartnershipIds = Set(stores.flatMap(Self.likedIds(in:))) self.likedStores = self.sortedByRecent(stores) self.hasLoaded = true self.pruneOrder(keeping: stores) @@ -143,7 +158,18 @@ final class PartnershipLikeManager { } #endif - let targets = store.partnershipIds.filter { likedPartnershipIds.contains($0) != liked } + // 찜 목록을 받기 전에 토글하면 빈 집합 기준으로 잘못 판단하므로 먼저 확보한다 + ensureLoaded { [weak self] in + guard let self else { return } + self.toggle(liked: liked, store: store, completion: completion) + } + } + + /// 항목별 현재 찜 상태와 다른 것만 서버 토글. 목록 확보에 실패한 경우엔 지도 응답의 isLiked를 기준으로 삼는다 + private func toggle(liked: Bool, store: PartnershipDTO, completion: @escaping (Result) -> Void) { + let targets = store.partnershipInfos + .filter { currentlyLiked($0) != liked } + .map(\.id) guard !targets.isEmpty else { applyLocalState(liked: liked, store: store) completion(.success(())) @@ -204,6 +230,16 @@ final class PartnershipLikeManager { // MARK: - Private + private func currentlyLiked(_ info: PartnershipInfoDTO) -> Bool { + hasLoaded ? likedPartnershipIds.contains(info.id) : info.isLiked + } + + /// 찜 목록 응답에서 실제 찜된 항목 id. 플래그가 하나도 없으면(구버전 응답) 소속 항목 전부로 간주 + private static func likedIds(in store: PartnershipDTO) -> [Int] { + let flagged = store.partnershipInfos.filter(\.isLiked).map(\.id) + return flagged.isEmpty ? store.partnershipIds : flagged + } + #if DEBUG /// 찜 목록 목 데이터 적용 (DEBUG 전용). 최근 추가순 확인을 위해 순서대로 시각을 부여한다 private func applyMockData() { diff --git a/EATSSU/App/Sources/Data/LocalDB/RealmService.swift b/EATSSU/App/Sources/Data/LocalDB/RealmService.swift index 18b288a2..75047606 100644 --- a/EATSSU/App/Sources/Data/LocalDB/RealmService.swift +++ b/EATSSU/App/Sources/Data/LocalDB/RealmService.swift @@ -48,6 +48,8 @@ class RealmService { try! realm.write { realm.deleteAll() } + // 계정 단위 상태도 함께 초기화 (찜 목록·순서) + PartnershipLikeManager.shared.reset() } func deleteAll(_ objectType: (some Object).Type) { diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift index cfd2f38f..49cf22d8 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift @@ -77,6 +77,16 @@ final class LikeViewController: BaseViewController { tabView.select(index: currentTab.rawValue, animated: false) } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // 탭바로 다른 탭에 갔다 오면 지도 복귀 뒤로가기를 없앤다 (편집 화면 push는 같은 탭이므로 유지) + let leftTab = tabBarController?.selectedViewController !== navigationController + if showsBackToMap, leftTab { + showsBackToMap = false + updateBackButton() + } + } + // MARK: - Public /// 탭 전환 전에 호출해 표시할 하위 탭과 뒤로가기 동작을 정한다 diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift index d1558ff7..6f9f9c41 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -161,11 +161,15 @@ final class LikedPartnershipViewController: BaseViewController { guard let self else { return } switch result { case .success: + // 취소하기는 한 번만 동작 (토글 API라 두 번 누르면 원복이 뒤집힘) + var didUndo = false self.showToast( message: TextLiteral.Like.removedToast, type: .success, actionTitle: TextLiteral.Like.undo ) { [weak self] in + guard !didUndo else { return } + didUndo = true self?.restoreStore(store) } case .failure: diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index fa5a7ca2..2780cfa0 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -51,14 +51,15 @@ extension MainMapViewController { // MARK: - Marker Items - func makeMarkerItem(for partnership: PartnershipDTO) -> MapMarkerItem { + /// - Parameter likeTarget: 찜 토글 대상 원본 업체 (필터로 항목이 걸러진 `partnership`과 구분). nil이면 partnership 그대로 + func makeMarkerItem(for partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil) -> MapMarkerItem { let isFestival = partnershipFilter == .festival return MapMarkerItem( title: partnership.storeName, latitude: partnership.latitude, longitude: partnership.longitude, icon: Self.partnershipIcon(for: partnership.restaurantType, isFestival: isFestival), - onTap: { [weak self] in self?.showPartnershipDetail(for: partnership) } + onTap: { [weak self] in self?.showPartnershipDetail(for: partnership, likeTarget: likeTarget) } ) } @@ -147,14 +148,15 @@ extension MainMapViewController { } /// 제휴점 상세 바텀시트 표시 - func showPartnershipDetail(for partnership: PartnershipDTO) { + /// - Parameter likeTarget: 찜 토글 대상 원본 업체. nil이면 partnership 자체 + func showPartnershipDetail(for partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil) { MapAnalyticsManager.shared.logClickPartnerRestaurant( collegeId: currentCollegeId, majorId: currentDepartmentId, partnerId: partnership.partnershipInfos.first?.id ?? -1 ) - let detailVC = PartnershipDetailSheetViewController(partnership: partnership) + let detailVC = PartnershipDetailSheetViewController(partnership: partnership, likeTarget: likeTarget) detailVC.loadViewIfNeeded() presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index 8ec27bec..41053601 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -83,7 +83,9 @@ extension MainMapViewController { if let type = partnershipFilter.restaurantType { filtered = filtered.filter { $0.restaurantType == type } } - displayMarkers(filtered.map { makeMarkerItem(for: $0) }) + // 시트는 필터된 항목만 보여주되, 찜은 업체의 전체 항목을 대상으로 해야 하므로 원본을 함께 넘긴다 + let fullByKey = Dictionary(partnerships.map { ($0.storeKey, $0) }, uniquingKeysWith: { first, _ in first }) + displayMarkers(filtered.map { makeMarkerItem(for: $0, likeTarget: fullByKey[$0.storeKey]) }) } private static func filterPartnerships( diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index c2a6438f..c61055ab 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -167,7 +167,8 @@ final class MainMapViewController: BaseViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - // 다른 탭으로 옮겨가면 찜 복귀 상태를 해제 + // 다른 탭으로 옮겨가면 찜 복귀 상태와 아직 못 띄운 상세를 함께 버린다 (나중에 엉뚱하게 뜨지 않도록) + pendingDetailStore = nil if returnsToLikeTab { returnsToLikeTab = false updateLikeReturnButton() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index 2e3554e1..22a31e95 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -15,7 +15,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Properties + /// 시트에 표시할 업체 (지도 필터에 맞춰 제휴 항목이 걸러진 DTO) private let partnership: PartnershipDTO + /// 찜 토글 대상 업체 (필터와 무관한 전체 제휴 항목). 업체 찜은 항상 모든 항목을 함께 다룬다 + private let likeTarget: PartnershipDTO private let mapAppLauncher: MapAppLauncher // MARK: - UI Components @@ -30,8 +33,9 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Init - init(partnership: PartnershipDTO) { + init(partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil) { self.partnership = partnership + self.likeTarget = likeTarget ?? partnership self.mapAppLauncher = MapAppLauncher(destination: .init( name: partnership.storeName, latitude: partnership.latitude, @@ -166,7 +170,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Like private var isLiked: Bool { - PartnershipLikeManager.shared.isLiked(partnership) + PartnershipLikeManager.shared.isLiked(likeTarget) } private func updateLikeButton() { @@ -177,13 +181,13 @@ final class PartnershipDetailSheetViewController: BaseViewController { /// 하트 탭: 찜 추가 시 최대 개수(내 제휴 업체 수)를 확인하고, 삭제 시엔 '취소하기'로 되돌릴 수 있는 토스트를 띄운다 @objc private func didTapLike() { likeButton.isEnabled = false - if isLiked { - setLiked(false) - return - } - - // 찜 목록과 최대 개수를 확보한 뒤 추가 + // 찜 목록을 확보한 뒤 현재 상태를 판정한다 (받기 전엔 하트가 지도 응답 기준이라 어긋날 수 있음) PartnershipLikeManager.shared.ensureLoaded { [weak self] in + guard let self else { return } + if self.isLiked { + self.setLiked(false) + return + } PartnershipLikeManager.shared.fetchLikeLimit { [weak self] limit in guard let self else { return } if let limit, PartnershipLikeManager.shared.likedStoreCount >= limit { @@ -197,7 +201,8 @@ final class PartnershipDetailSheetViewController: BaseViewController { } private func setLiked(_ liked: Bool) { - PartnershipLikeManager.shared.setLiked(liked, store: partnership) { [weak self] result in + likeButton.isEnabled = false + PartnershipLikeManager.shared.setLiked(liked, store: likeTarget) { [weak self] result in guard let self else { return } self.likeButton.isEnabled = true self.updateLikeButton() @@ -207,11 +212,15 @@ final class PartnershipDetailSheetViewController: BaseViewController { if liked { self.showToast(message: TextLiteral.Like.addedToast, type: .success) } else { + // 취소하기는 한 번만 동작 (토글 API라 두 번 누르면 원복이 뒤집힘) + var didUndo = false self.showToast( message: TextLiteral.Like.removedToast, type: .success, actionTitle: TextLiteral.Like.undo ) { [weak self] in + guard !didUndo else { return } + didUndo = true self?.setLiked(true) } } diff --git a/EATSSU/App/Sources/Presentation/Popup/ViewController/PromotionPopupViewController.swift b/EATSSU/App/Sources/Presentation/Popup/ViewController/PromotionPopupViewController.swift index f220bc0f..b0378ade 100644 --- a/EATSSU/App/Sources/Presentation/Popup/ViewController/PromotionPopupViewController.swift +++ b/EATSSU/App/Sources/Presentation/Popup/ViewController/PromotionPopupViewController.swift @@ -85,16 +85,14 @@ final class PromotionPopupViewController: BaseViewController { UIApplication.shared.open(nabatdaePostURL) } - /// 팝업의 그 외 영역 클릭 -> 나아돼 탭으로 이동 + /// 팝업의 그 외 영역 클릭 -> 닫기 (이동하던 '나만아니면돼' 탭은 제거됨) @objc private func didTapPopupContent() { AnalyticsService.logEvent("popup_event", parameters: [ "popup_name": "plz_not_me", "popup_action": "click_popup_image" ]) - dismiss(animated: true) { [weak self] in - self?.tabBarContainer?.setTab(index: 2) - } + dismiss(animated: true) } /// 다시 보지 않기 -> 평생 안 뜸 diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index 485b0910..c896d7fc 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -20,6 +20,9 @@ final class CustomTabBarContainerController: UITabBarController { case map = 1 case like = 2 case myPage = 3 + + /// 지도·찜·마이페이지는 로그인 필요 + var requiresLogin: Bool { self != .home } } // MARK: - Properties @@ -96,9 +99,13 @@ final class CustomTabBarContainerController: UITabBarController { // MARK: - Public Interface - /// 외부에서 탭 전환 요청 시 사용 + /// 외부에서 탭 전환 요청 시 사용. 탭바 탭과 같은 로그인 조건을 적용한다 public func setTab(index: Int) { - guard index < tabViewControllers.count else { return } + guard index < tabViewControllers.count, let tab = Tab(rawValue: index) else { return } + if tab.requiresLogin, RealmService.shared.isAccessTokenPresent() == false { + presentLoginAlert() + return + } selectedIndex = index } @@ -242,9 +249,7 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { return true } - // 지도·찜·마이페이지는 로그인 필요 - if (selectedTab == .map || selectedTab == .like || selectedTab == .myPage), - RealmService.shared.isAccessTokenPresent() == false { + if selectedTab.requiresLogin, RealmService.shared.isAccessTokenPresent() == false { presentLoginAlert() return false } From 0f08fa4bc1457095a89c3baeaff441938e04840a Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:39:40 +0900 Subject: [PATCH 10/15] =?UTF-8?q?[#450]=20=EC=B0=9C=20=EC=B5=9C=EB=8C=80?= =?UTF-8?q?=20=EA=B0=9C=EC=88=98=20=EC=A0=9C=ED=95=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/en.lproj/Localizable.strings | 2 -- .../Resources/ja.lproj/Localizable.strings | 2 -- .../Resources/ko.lproj/Localizable.strings | 2 -- .../Resources/vi.lproj/Localizable.strings | 2 -- .../Data/Like/PartnershipLikeManager.swift | 27 ------------------- ...PartnershipDetailSheetViewController.swift | 16 ++--------- .../Sources/Utility/Literal/TextLiteral.swift | 4 --- 7 files changed, 2 insertions(+), 53 deletions(-) diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 8041f58d..3de7f4b4 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -692,8 +692,6 @@ "like.addedToast" = "Added to liked partners."; /// "찜한 제휴에서 삭제했어요." "like.removedToast" = "Removed from liked partners."; -/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." -"like.limitReached" = "You can like up to %d partners (your partner count)."; /// "찜 처리에 실패했어요. 다시 시도해 주세요." "like.updateFailed" = "Couldn't update. Please try again."; /// "찜한 제휴를 불러오지 못했어요." diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index a8d75b03..47c14a6a 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -693,8 +693,6 @@ "like.addedToast" = "お気に入りの提携店に追加しました。"; /// "찜한 제휴에서 삭제했어요." "like.removedToast" = "お気に入りの提携店から削除しました。"; -/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." -"like.limitReached" = "お気に入りは自分の提携店数(%d件)まで追加できます。"; /// "찜 처리에 실패했어요. 다시 시도해 주세요." "like.updateFailed" = "処理に失敗しました。もう一度お試しください。"; /// "찜한 제휴를 불러오지 못했어요." diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 6391acf7..fdf4667e 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -693,8 +693,6 @@ "like.addedToast" = "찜한 제휴에 추가했어요."; /// "찜한 제휴에서 삭제했어요." "like.removedToast" = "찜한 제휴에서 삭제했어요."; -/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." -"like.limitReached" = "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요."; /// "찜 처리에 실패했어요. 다시 시도해 주세요." "like.updateFailed" = "찜 처리에 실패했어요. 다시 시도해 주세요."; /// "찜한 제휴를 불러오지 못했어요." diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index bf7ed3d7..d36124e8 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -693,8 +693,6 @@ "like.addedToast" = "Đã thêm vào ưu đãi yêu thích."; /// "찜한 제휴에서 삭제했어요." "like.removedToast" = "Đã xóa khỏi ưu đãi yêu thích."; -/// "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요." -"like.limitReached" = "Chỉ có thể thêm tối đa %d ưu đãi (số ưu đãi của bạn)."; /// "찜 처리에 실패했어요. 다시 시도해 주세요." "like.updateFailed" = "Xử lý thất bại. Vui lòng thử lại."; /// "찜한 제휴를 불러오지 못했어요." diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 9fdc9ebf..e8eca05a 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -80,11 +80,6 @@ final class PartnershipLikeManager { refresh { _ in completion() } } - /// 찜한 업체 수 (최대 개수 제한 판정용) - var likedStoreCount: Int { - likedStores.count - } - // MARK: - Fetch /// 찜 목록을 서버에서 받아 상태를 갱신하고 최근 추가순으로 정렬해 돌려준다 @@ -120,28 +115,6 @@ final class PartnershipLikeManager { } } - /// 찜 가능 최대 개수 = 내 제휴 업체 수. nil이면 제한 없이 진행 (조회 실패 또는 DEBUG 목 데이터) - func fetchLikeLimit(completion: @escaping (Int?) -> Void) { - #if DEBUG - if usesMockData { - completion(nil) - return - } - #endif - NetworkService.shared.request( - MyRouter.getMyPartnerships, - responseType: [PartnershipDTO].self, - useAuth: true - ) { result in - switch result { - case .success(let stores): - completion(stores.count) - case .failure: - completion(nil) - } - } - } - // MARK: - Update /// 업체 단위 찜 설정. 완료 시 로컬 상태(항목 id, 목록, 순서)까지 반영한다 diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index 22a31e95..335c4511 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -178,25 +178,13 @@ final class PartnershipDetailSheetViewController: BaseViewController { likeButton.setImage(image, for: .normal) } - /// 하트 탭: 찜 추가 시 최대 개수(내 제휴 업체 수)를 확인하고, 삭제 시엔 '취소하기'로 되돌릴 수 있는 토스트를 띄운다 + /// 하트 탭: 찜 토글. 삭제 시엔 '취소하기'로 되돌릴 수 있는 토스트를 띄운다 @objc private func didTapLike() { likeButton.isEnabled = false // 찜 목록을 확보한 뒤 현재 상태를 판정한다 (받기 전엔 하트가 지도 응답 기준이라 어긋날 수 있음) PartnershipLikeManager.shared.ensureLoaded { [weak self] in guard let self else { return } - if self.isLiked { - self.setLiked(false) - return - } - PartnershipLikeManager.shared.fetchLikeLimit { [weak self] limit in - guard let self else { return } - if let limit, PartnershipLikeManager.shared.likedStoreCount >= limit { - self.likeButton.isEnabled = true - self.showToast(message: TextLiteral.Like.limitReached(limit), type: .warning) - return - } - self.setLiked(true) - } + self.setLiked(!self.isLiked) } } diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index 5925b22d..90566d84 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -1396,10 +1396,6 @@ enum TextLiteral { Localization.localized("like.removedToast", fallback: "찜한 제휴에서 삭제했어요.") } - /// "찜은 내 제휴 개수(3개)까지만 담을 수 있어요." - static func limitReached(_ limit: Int) -> String { - Localization.formatted("like.limitReached", fallback: "찜은 내 제휴 개수(%d개)까지만 담을 수 있어요.", limit) - } /// "찜 처리에 실패했어요. 다시 시도해 주세요." static var updateFailed: String { From 6eb2d88141d6056e0ffdb8daae5122b95a0128e3 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:45:24 +0900 Subject: [PATCH 11/15] =?UTF-8?q?[#450]=20=ED=95=99=EA=B5=90=20=EC=A0=9C?= =?UTF-8?q?=ED=9C=B4=20=ED=83=AD=20=ED=95=99=EA=B3=BC=20=ED=95=84=EC=88=98?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9:=20=EC=B6=95=EC=A0=9C=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20=EC=A0=84=20=ED=95=84=ED=84=B0=EC=97=90=20=ED=95=99?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=ED=8A=B8,=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=B8=94=EB=9F=AC,=20=ED=95=99=EA=B3=BC=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=B0=9C=20=EB=B6=88=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Presentation/Map/View/MainMapView.swift | 20 ++++++++++++++++++- .../MainMapViewController+Marker.swift | 6 +++++- .../MainMapViewController.swift | 15 ++++++++------ ...PartnershipDetailSheetViewController.swift | 6 +++++- 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index b18afbde..adc652b2 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -59,6 +59,15 @@ final class MainMapView: BaseUIView { // MARK: - UI Components let mapView = NMFNaverMapView() + + /// 학과 미입력 상태의 학교 제휴 탭에서 지도를 흐리게 덮는 뷰 (디자인 Map-Partnership-Blur) + private let blurView: UIVisualEffectView = { + let view = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + view.isHidden = true + view.isUserInteractionEnabled = false + return view + }() + let topTabView = UnderlineTabView(titles: MapTab.allCases.map { $0.title }) let filterChipBar = FilterChipBar() @@ -83,7 +92,7 @@ final class MainMapView: BaseUIView { mapView.showLocationButton = true mapView.mapView.positionMode = .disabled - addSubviews(mapView, topTabView, filterChipBar, likeButton) + addSubviews(mapView, blurView, topTabView, filterChipBar, likeButton) } // MARK: - Layout Setup @@ -99,6 +108,10 @@ final class MainMapView: BaseUIView { $0.leading.trailing.bottom.equalToSuperview() } + blurView.snp.makeConstraints { + $0.edges.equalTo(mapView) + } + likeButton.snp.makeConstraints { $0.top.equalTo(mapView).offset(12) $0.trailing.equalToSuperview().inset(16) @@ -112,6 +125,11 @@ final class MainMapView: BaseUIView { } } + /// 학과 미입력 안내 중 지도 블러 표시/해제 + func setMapBlurred(_ blurred: Bool) { + blurView.isHidden = !blurred + } + /// 하트 노출 여부에 따라 칩 바 폭을 조정한다 (숨기면 칩이 오른쪽 끝까지 스크롤) func setLikeButtonVisible(_ visible: Bool) { likeButton.isHidden = !visible diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index 2780cfa0..5f74804c 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -156,7 +156,11 @@ extension MainMapViewController { partnerId: partnership.partnershipInfos.first?.id ?? -1 ) - let detailVC = PartnershipDetailSheetViewController(partnership: partnership, likeTarget: likeTarget) + let detailVC = PartnershipDetailSheetViewController( + partnership: partnership, + likeTarget: likeTarget, + isLikeEnabled: hasDepartment + ) detailVC.loadViewIfNeeded() presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index c61055ab..3668de6a 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -312,6 +312,7 @@ final class MainMapViewController: BaseViewController { case .partnership: refreshPartnershipTab() case .goodPrice: + root.setMapBlurred(false) applyTabUI() MapAnalyticsManager.shared.logClickMapGoodPrice( collegeId: currentCollegeId, @@ -365,13 +366,8 @@ final class MainMapViewController: BaseViewController { // MARK: - Partnership Tab - /// 학교 제휴 탭 마커 로드. 학과 미입력이면 학과 입력 시트를 띄우고 마커는 비움 + /// 학교 제휴 탭 마커 로드. 학과 미입력이면 지도를 흐리게 하고 학과 입력 시트를 띄운다 (축제 포함 모든 필터) func loadPartnershipMarkers() { - if partnershipFilter == .festival { - refreshAllPartnerships() - return - } - guard hasDepartment else { switch departmentLoadState { case .loading: @@ -382,10 +378,17 @@ final class MainMapViewController: BaseViewController { return case .loaded: displayMarkers([]) + root.setMapBlurred(true) presentNoDepartmentSheetIfNeeded() return } } + root.setMapBlurred(false) + + if partnershipFilter == .festival { + refreshAllPartnerships() + return + } fetchMyPartnerships() } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index 335c4511..16b3849f 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -19,6 +19,8 @@ final class PartnershipDetailSheetViewController: BaseViewController { private let partnership: PartnershipDTO /// 찜 토글 대상 업체 (필터와 무관한 전체 제휴 항목). 업체 찜은 항상 모든 항목을 함께 다룬다 private let likeTarget: PartnershipDTO + /// 학과 미입력 시 찜 사용 불가 (기획). false면 하트를 숨긴다 + private let isLikeEnabled: Bool private let mapAppLauncher: MapAppLauncher // MARK: - UI Components @@ -33,9 +35,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Init - init(partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil) { + init(partnership: PartnershipDTO, likeTarget: PartnershipDTO? = nil, isLikeEnabled: Bool = true) { self.partnership = partnership self.likeTarget = likeTarget ?? partnership + self.isLikeEnabled = isLikeEnabled self.mapAppLauncher = MapAppLauncher(destination: .init( name: partnership.storeName, latitude: partnership.latitude, @@ -61,6 +64,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { storeNameLabel.textColor = .label likeButton.tintColor = .gray700 + likeButton.isHidden = !isLikeEnabled likeButton.addTarget(self, action: #selector(didTapLike), for: .touchUpInside) updateLikeButton() From 6bc4762b911f6ae2273fa76d3e1bc3012723d363 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:46:43 +0900 Subject: [PATCH 12/15] =?UTF-8?q?[#450]=20=ED=95=99=EA=B5=90=20=EC=A0=9C?= =?UTF-8?q?=ED=9C=B4=20=3D=20=ED=95=99=EA=B3=BC=20=EC=A0=9C=ED=9C=B4=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=8F=84?= =?UTF-8?q?=EB=8B=AC=20=EB=B6=88=EA=B0=80=ED=95=9C=20click=5Fmap=5Fall=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Map/ViewController/MainMapViewController.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 3668de6a..c0f5a481 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -344,7 +344,8 @@ final class MainMapViewController: BaseViewController { setInitialCameraPosition(animated: true) } - /// 축제 → click_map_festival, 그 외(내 학과 제휴 기준) → 학과 있으면 click_map_mine, 없으면 click_map_all + /// 축제 → click_map_festival, 그 외 → click_map_mine + /// 학교 제휴는 곧 내 학과 제휴이고 학과 없이는 칩까지 도달할 수 없으므로 전체 제휴(click_map_all) 분기는 없다 private func logPartnershipFilterClick(_ filter: PartnershipFilter) { switch filter { case .festival: @@ -353,14 +354,8 @@ final class MainMapViewController: BaseViewController { majorId: currentDepartmentId ) case .all, .restaurant, .cafe, .pub: - if let collegeId = currentCollegeId, let majorId = currentDepartmentId { - MapAnalyticsManager.shared.logClickMapMine(collegeId: collegeId, majorId: majorId) - } else { - MapAnalyticsManager.shared.logClickMapAll( - collegeId: currentCollegeId, - majorId: currentDepartmentId - ) - } + guard let collegeId = currentCollegeId, let majorId = currentDepartmentId else { return } + MapAnalyticsManager.shared.logClickMapMine(collegeId: collegeId, majorId: majorId) } } From 976718dada70edd858c967b270c06b062390dabd Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:53:52 +0900 Subject: [PATCH 13/15] =?UTF-8?q?[#450]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=B0=9C=20=EB=AA=A9=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Like/PartnershipLikeManager.swift | 51 -------------- .../Like/LikedPartnershipMockData.swift | 67 ------------------- 2 files changed, 118 deletions(-) delete mode 100644 EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index e8eca05a..8b8f1517 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -41,14 +41,6 @@ final class PartnershipLikeManager { /// 업체별 찜한 시각 (최근 추가순 정렬용, 로컬 보관) private var likedAtByStoreKey: [String: Double] - #if DEBUG - /// 서버 찜 목록이 비어 있을 때 목 데이터로 대체할지 (DEBUG 전용). 실제 API 경로를 검증할 땐 false로 - static let isMockEnabled = true - - /// 목 데이터로 대체된 상태. 이때 찜 토글은 서버를 호출하지 않고 로컬에서만 처리한다 - private(set) var usesMockData = false - #endif - /// 로그아웃·탈퇴·세션 만료 시 호출. 다른 계정의 찜 상태가 남지 않도록 메모리와 로컬 순서 기록을 모두 비운다 func reset() { likedPartnershipIds = [] @@ -56,9 +48,6 @@ final class PartnershipLikeManager { hasLoaded = false likedAtByStoreKey = [:] UserDefaults.standard.removeObject(forKey: Constant.likedOrderKey) - #if DEBUG - usesMockData = false - #endif } // MARK: - Query @@ -92,18 +81,6 @@ final class PartnershipLikeManager { guard let self else { return } switch result { case .success(let stores): - #if DEBUG - if Self.isMockEnabled, stores.isEmpty, !self.hasLoaded { - self.applyMockData() - completion(.success(self.likedStores)) - return - } - if self.usesMockData { - // 목 상태에서는 서버 목록을 덮어쓰지 않는다 - completion(.success(self.likedStores)) - return - } - #endif self.likedPartnershipIds = Set(stores.flatMap(Self.likedIds(in:))) self.likedStores = self.sortedByRecent(stores) self.hasLoaded = true @@ -119,18 +96,6 @@ final class PartnershipLikeManager { /// 업체 단위 찜 설정. 완료 시 로컬 상태(항목 id, 목록, 순서)까지 반영한다 func setLiked(_ liked: Bool, store: PartnershipDTO, completion: @escaping (Result) -> Void) { - #if DEBUG - // 목 데이터(음수 id)는 서버에 없으므로 로컬에서만 토글 - if usesMockData || store.partnershipIds.contains(where: { $0 < 0 }) { - for id in store.partnershipIds { - if liked { likedPartnershipIds.insert(id) } else { likedPartnershipIds.remove(id) } - } - applyLocalState(liked: liked, store: store) - DispatchQueue.main.async { completion(.success(())) } - return - } - #endif - // 찜 목록을 받기 전에 토글하면 빈 집합 기준으로 잘못 판단하므로 먼저 확보한다 ensureLoaded { [weak self] in guard let self else { return } @@ -213,22 +178,6 @@ final class PartnershipLikeManager { return flagged.isEmpty ? store.partnershipIds : flagged } - #if DEBUG - /// 찜 목록 목 데이터 적용 (DEBUG 전용). 최근 추가순 확인을 위해 순서대로 시각을 부여한다 - private func applyMockData() { - usesMockData = true - hasLoaded = true - let stores = LikedPartnershipMockData.samples - likedPartnershipIds = Set(stores.flatMap(\.partnershipIds)) - let now = Date().timeIntervalSince1970 - for (index, store) in stores.enumerated() where likedAtByStoreKey[store.storeKey] == nil { - likedAtByStoreKey[store.storeKey] = now - Double(index) - } - likedStores = sortedByRecent(stores) - persistOrder() - } - #endif - private func applyLocalState(liked: Bool, store: PartnershipDTO) { if liked { likedAtByStoreKey[store.storeKey] = Date().timeIntervalSince1970 diff --git a/EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift b/EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift deleted file mode 100644 index 0e9b2c14..00000000 --- a/EATSSU/App/Sources/Presentation/Like/LikedPartnershipMockData.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// LikedPartnershipMockData.swift -// EATSSU -// -// Created by 황상환 on 8/30/26. -// - -import Foundation - -#if DEBUG -/// 서버에 찜 데이터가 없는 동안 찜 목록 UI 확인용 목 데이터 (DEBUG 전용, id는 음수) -enum LikedPartnershipMockData { - - static let samples: [PartnershipDTO] = [ - store("맘스터치 숭실대점", type: "RESTAURANT", lat: 37.4963, lng: 126.9572, infos: [ - info(-101, college: "IT대학", desc: "학생증 인증하면 음료수 1개 증정"), - info(-102, department: "컴퓨터학부", desc: "세트 메뉴 10% 할인") - ]), - store("스타벅스 숭실대입구역점", type: "CAFE", lat: 37.4959, lng: 126.9538, infos: [ - info(-103, college: "경영대학", desc: "아메리카노 사이즈업 무료") - ]), - store("역전할머니맥주 숭실대점", type: "PUB", lat: 37.4945, lng: 126.9581, infos: [ - info(-104, college: "인문대학", desc: "4인 이상 방문 시 안주 1개 서비스"), - info(-105, department: "영어영문학과", desc: "생맥주 500cc 1+1") - ]), - store("이레김밥", type: "RESTAURANT", lat: 37.4952, lng: 126.9549, infos: [ - info(-106, college: "자연과학대학", desc: "김밥 한 줄 500원 할인") - ]), - store("카페봄봄 숭실대점", type: "CAFE", lat: 37.4967, lng: 126.9555, infos: [ - info(-107, college: "공과대학", desc: "학생증 인증 시 모든 음료 10% 할인 및 텀블러 지참 시 추가 할인") - ]), - store("파동추야", type: "PUB", lat: 37.4941, lng: 126.9560, infos: [ - info(-108, college: "사회과학대학", desc: "소주·맥주 1병 무료") - ]) - ] - - private static func store( - _ name: String, type: String, lat: Double, lng: Double, infos: [PartnershipInfoDTO] - ) -> PartnershipDTO { - PartnershipDTO( - storeName: name, - longitude: lng, - latitude: lat, - restaurantType: type, - naverMapUrl: nil, - kakaoMapUrl: nil, - partnershipInfos: infos - ) - } - - private static func info( - _ id: Int, college: String? = nil, department: String? = nil, desc: String - ) -> PartnershipInfoDTO { - PartnershipInfoDTO( - id: id, - collegeName: college, - departmentName: department, - likeCount: 1, - isLiked: true, - description: desc, - startDate: "2026-09-01", - endDate: "2026-12-31", - periodType: .normal - ) - } -} -#endif From 84b30938abfb515ea5c44269e14ec4dace443433 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:12:34 +0900 Subject: [PATCH 14/15] =?UTF-8?q?[#450]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=9A=B0=EC=84=A0=20=ED=8C=90=EC=A0=95,?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=A4=91=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EB=B3=B4=ED=98=B8,=20=ED=95=99=EA=B3=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EA=B8=B0=EC=A1=B4=EA=B0=92?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80,=20=EB=82=B4=20=EC=A0=9C=ED=9C=B4=20?= =?UTF-8?q?=EC=BA=90=EC=8B=9C,=20=EC=8B=9C=ED=8A=B8=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=80=EB=93=9C,=20click=5Fmap=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Firebase/MapAnalyticsManager.swift | 6 ++-- .../Data/Like/PartnershipLikeManager.swift | 29 +++++++++++++---- .../MainMapViewController+Network.swift | 28 ++++++++--------- .../MainMapViewController.swift | 31 ++++++++++++++++--- .../CustomTabBarContainerController.swift | 4 ++- 5 files changed, 70 insertions(+), 28 deletions(-) diff --git a/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift b/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift index 882e1dcd..a1cff143 100644 --- a/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift +++ b/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift @@ -41,13 +41,13 @@ final class MapAnalyticsManager { #1 하단 탭바에서 '지도'를 클릭했을 때 호출 - Parameter collegeId: 사용자의 단과대 ID (학과 미설정 시 nil) - Parameter majorId: 사용자의 학과 ID (학과 미설정 시 nil) - - Note: 지도는 항상 학교 제휴 > 전체 필터로 진입하므로 default_type은 "general" 고정 (대시보드 호환용 파라미터 유지) + - Parameter isFestival: 진입 시 축제 필터가 선택돼 있으면 true (마지막 선택 필터가 유지되므로 실제 상태로 판단) */ - func logClickMap(collegeId: Int?, majorId: Int?) { + func logClickMap(collegeId: Int?, majorId: Int?, isFestival: Bool) { AnalyticsService.logEvent( Event.clickMap, parameters: makeParameters(collegeId: collegeId, majorId: majorId, extra: [ - Parameter.defaultType: "general" + Parameter.defaultType: isFestival ? "festival" : "general" ]) ) } diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 8b8f1517..4e742a2c 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -41,23 +41,27 @@ final class PartnershipLikeManager { /// 업체별 찜한 시각 (최근 추가순 정렬용, 로컬 보관) private var likedAtByStoreKey: [String: Double] + /// 이 세션에서 토글에 성공한 항목의 최신 상태. 찜 목록 조회가 실패했거나 아직 안 됐을 때도 하트가 서버와 일치하게 한다 + private var toggledStates: [Int: Bool] = [:] + + /// 토글이 반영될 때마다 증가. 토글 이전에 시작된 목록 조회 응답이 최신 로컬 상태를 덮어쓰지 않게 한다 + private var stateVersion = 0 + /// 로그아웃·탈퇴·세션 만료 시 호출. 다른 계정의 찜 상태가 남지 않도록 메모리와 로컬 순서 기록을 모두 비운다 func reset() { likedPartnershipIds = [] likedStores = [] hasLoaded = false likedAtByStoreKey = [:] + toggledStates = [:] + stateVersion += 1 UserDefaults.standard.removeObject(forKey: Constant.likedOrderKey) } // MARK: - Query func isLiked(_ store: PartnershipDTO) -> Bool { - let ids = store.partnershipIds - guard !ids.isEmpty else { return false } - // 서버 찜 목록을 아직 받기 전이면 지도 응답에 실린 항목별 isLiked로 판정 - guard hasLoaded else { return store.partnershipInfos.allSatisfy(\.isLiked) } - return ids.allSatisfy { likedPartnershipIds.contains($0) } + !store.partnershipInfos.isEmpty && store.partnershipInfos.allSatisfy(currentlyLiked) } /// 찜 상태를 한 번은 서버에서 받아온 뒤 이어서 실행. 이미 받아왔으면 즉시 실행 @@ -73,6 +77,7 @@ final class PartnershipLikeManager { /// 찜 목록을 서버에서 받아 상태를 갱신하고 최근 추가순으로 정렬해 돌려준다 func refresh(completion: @escaping (Result<[PartnershipDTO], Error>) -> Void) { + let version = stateVersion NetworkService.shared.request( MyRouter.getLikedPartnerships, responseType: [PartnershipDTO].self, @@ -81,6 +86,12 @@ final class PartnershipLikeManager { guard let self else { return } switch result { case .success(let stores): + // 조회 중에 토글이 있었으면 이 응답은 이미 낡은 것이므로 로컬 상태를 유지한다 + guard version == self.stateVersion else { + completion(.success(self.likedStores)) + return + } + self.toggledStates = [:] self.likedPartnershipIds = Set(stores.flatMap(Self.likedIds(in:))) self.likedStores = self.sortedByRecent(stores) self.hasLoaded = true @@ -139,9 +150,13 @@ final class PartnershipLikeManager { guard let self else { return } // 성공한 항목만 로컬에 반영해 서버 상태와 어긋나지 않게 한다 for id in toggledIds { + self.toggledStates[id] = liked if liked { self.likedPartnershipIds.insert(id) } else { self.likedPartnershipIds.remove(id) } } + self.stateVersion += 1 if let firstError { + // 일부만 성공한 경우에도 목록/순서를 항목 상태와 일치시킨다 (전부 찜일 때만 목록에 남김) + self.applyLocalState(liked: self.isLiked(store), store: store) completion(.failure(firstError)) return } @@ -168,8 +183,10 @@ final class PartnershipLikeManager { // MARK: - Private + /// 항목 찜 상태: 이 세션의 토글 결과 > 서버 찜 목록 > (목록 미확보 시) 지도 응답의 isLiked private func currentlyLiked(_ info: PartnershipInfoDTO) -> Bool { - hasLoaded ? likedPartnershipIds.contains(info.id) : info.isLiked + if let toggled = toggledStates[info.id] { return toggled } + return hasLoaded ? likedPartnershipIds.contains(info.id) : info.isLiked } /// 찜 목록 응답에서 실제 찜된 항목 id. 플래그가 하나도 없으면(구버전 응답) 소속 항목 전부로 간주 diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index 41053601..c0bd0e53 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -51,13 +51,19 @@ extension MainMapViewController { } } - /// 내 학과 제휴를 받아 현재 업종 필터에 맞춰 마커 표시 + /// 내 학과 제휴를 받아 현재 업종 필터에 맞춰 마커 표시. 캐시가 있으면 재요청 없이 필터만 적용 func fetchMyPartnerships() { guard hasDepartment else { displayMarkers([]) return } + if !cachedMyPartnerships.isEmpty { + _ = beginLoad() + applyPartnershipMarkers(from: cachedMyPartnerships, periodType: .normal) + return + } + let generation = beginLoad() NetworkService.shared.request( MyRouter.getMyPartnerships, @@ -67,6 +73,7 @@ extension MainMapViewController { guard let self, self.isCurrentLoad(generation) else { return } switch result { case .success(let partnerships): + self.cachedMyPartnerships = partnerships self.applyPartnershipMarkers(from: partnerships, periodType: .normal) case .failure(let error): @@ -117,6 +124,10 @@ extension MainMapViewController { switch result { case .success(let department): + if self.currentDepartmentId != department.departmentId { + // 학과가 바뀌면 내 제휴 캐시는 더 이상 유효하지 않다 + self.cachedMyPartnerships = [] + } self.currentDepartmentName = department.departmentName self.currentDepartmentId = department.departmentId self.currentCollegeId = department.collegeId @@ -133,20 +144,9 @@ extension MainMapViewController { } case .failure(let error): + // 일시적 조회 실패를 '학과 없음'으로 오판하지 않도록 기존 값(없으면 Realm 저장값)을 유지한다 print("학과 조회 실패: \(error.localizedDescription)") - self.currentDepartmentName = nil - self.currentDepartmentId = nil - self.currentCollegeId = nil - - if let userInfo = UserInfoManager.shared.getCurrentUserInfo() { - UserInfoManager.shared.updateDepartment( - for: userInfo, - collegeId: nil, - collegeName: nil, - departmentId: nil, - departmentName: nil - ) - } + self.seedDepartmentFromRealmIfNeeded() } completion?() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index c0f5a481..9cc23d73 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -48,6 +48,8 @@ final class MainMapViewController: BaseViewController { /// 가장 최근에 받아온 전체 제휴 목록 (축제 필터용 캐시) var cachedAllPartnerships: [PartnershipDTO] = [] + /// 내 학과 제휴 캐시 (업종 칩 필터용). 탭바 재탭·학과 변경 시 비움 + var cachedMyPartnerships: [PartnershipDTO] = [] /// 착한가격업소 전체 목록 캐시 (카테고리 필터링용) var cachedGoodPriceStores: [GoodPriceStoreDTO] = [] @@ -86,6 +88,11 @@ final class MainMapViewController: BaseViewController { return PartnershipFilter.allCases.filter { $0 != .festival || festivalEnabled } } + /// 탭바에서 지도 탭 진입 시 보이게 될 화면이 축제인지 (click_map default_type용) + var isShowingFestival: Bool { + currentTab == .partnership && partnershipFilter == .festival + } + /// 클러스터 색상: 축제 필터일 때만 축제 색 var clusterColor: UIColor { (currentTab == .partnership && partnershipFilter == .festival) ? .festivalPrimary : .primary @@ -97,6 +104,18 @@ final class MainMapViewController: BaseViewController { self.mode = mode self.currentTab = (mode == .standaloneGoodPrice) ? .goodPrice : .partnership super.init(nibName: nil, bundle: nil) + // 서버 조회 전에도 로그인 시 저장된 학과로 판정할 수 있게 미리 채운다 + seedDepartmentFromRealmIfNeeded() + } + + /// 학과 정보가 비어 있으면 Realm 저장값으로 채운다 (서버 조회 실패 시 폴백) + func seedDepartmentFromRealmIfNeeded() { + guard currentDepartmentName == nil, + let userInfo = UserInfoManager.shared.getCurrentUserInfo(), + let name = userInfo.departmentName, !name.isEmpty else { return } + currentDepartmentName = name + currentDepartmentId = userInfo.departmentId + currentCollegeId = userInfo.collegeId } /// 찜 목록에서 넘어온 업체. 지도 탭이 화면에 나타난 뒤 시트로 띄우고 비운다 @@ -182,7 +201,8 @@ final class MainMapViewController: BaseViewController { if currentTab != .partnership { switchTab(to: .partnership) } - pendingDetailStore = store + // 학과가 없으면 학교 제휴 자체를 볼 수 없으므로 상세 대신 학과 입력 안내로 이어진다 + pendingDetailStore = hasDepartment ? store : nil returnsToLikeTab = true updateLikeReturnButton() presentPendingDetailIfNeeded() @@ -234,9 +254,8 @@ final class MainMapViewController: BaseViewController { let generation = beginLoad() departmentLoadState = .loading fetchDepartment { [weak self] in - guard let self else { return } + guard let self, self.isCurrentLoad(generation) else { return } self.departmentLoadState = .loaded - guard self.isCurrentLoad(generation) else { return } self.loadPartnershipMarkers() } } @@ -392,7 +411,10 @@ final class MainMapViewController: BaseViewController { } private func presentNoDepartmentSheetIfNeeded() { - guard mode == .tabbed, currentTab == .partnership, presentedViewController == nil else { return } + // 비동기 응답 시점에 다른 탭에 있으면 띄우지 않는다 (다음 진입 시 viewWillAppear가 다시 판단) + guard mode == .tabbed, currentTab == .partnership, + presentedViewController == nil, + viewIfLoaded?.window != nil else { return } present(NoDepartmentSheetViewController(), animated: true) } @@ -403,6 +425,7 @@ final class MainMapViewController: BaseViewController { switch currentTab { case .partnership: cachedAllPartnerships = [] + cachedMyPartnerships = [] refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index c896d7fc..080fe01f 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -257,9 +257,11 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { // 지도 탭 클릭 시 Firebase 이벤트 호출 (로그인된 상태에서만) if selectedTab == .map { let userInfo = UserInfoManager.shared.getCurrentUserInfo() + let mapVC = (viewController as? UINavigationController)?.viewControllers.first as? MainMapViewController MapAnalyticsManager.shared.logClickMap( collegeId: userInfo?.collegeId, - majorId: userInfo?.departmentId + majorId: userInfo?.departmentId, + isFestival: mapVC?.isShowingFestival ?? false ) } From 74814f29effed2e74629c64791fadaa7a86ef36e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:33:36 +0900 Subject: [PATCH 15/15] =?UTF-8?q?[#450]=20CodeRabbit=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81:=20=ED=86=A0=EA=B8=80=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=20=EB=AA=A9=EB=A1=9D=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=EB=AC=B4=ED=9A=A8=ED=99=94,=20=ED=95=99=EA=B3=BC=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=ED=9B=84=20=EC=B0=9C=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C,=20=ED=95=98=ED=8A=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=EC=84=B1,=20setTab=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Data/Like/PartnershipLikeManager.swift | 4 +++- .../Sources/Presentation/Map/View/MainMapView.swift | 1 + .../Map/ViewController/MainMapViewController.swift | 11 +++++++++-- .../NoDepartmentSheetViewController.swift | 7 +++---- .../PartnershipDetailSheetViewController.swift | 3 +++ .../TabBar/CustomTabBarContainerController.swift | 9 ++++++--- .../Sources/Utility/UIComponent/EmptyStateView.swift | 2 +- 7 files changed, 26 insertions(+), 11 deletions(-) diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index 4e742a2c..7bc9b9ed 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -125,6 +125,8 @@ final class PartnershipLikeManager { return } + // 요청 시작 시점에 버전을 올려, 이보다 먼저 시작된 목록 조회 응답이 토글 전 상태를 되살리지 못하게 한다 + stateVersion += 1 let group = DispatchGroup() var toggledIds: [Int] = [] var firstError: Error? @@ -153,7 +155,6 @@ final class PartnershipLikeManager { self.toggledStates[id] = liked if liked { self.likedPartnershipIds.insert(id) } else { self.likedPartnershipIds.remove(id) } } - self.stateVersion += 1 if let firstError { // 일부만 성공한 경우에도 목록/순서를 항목 상태와 일치시킨다 (전부 찜일 때만 목록에 남김) self.applyLocalState(liked: self.isLiked(store), store: store) @@ -196,6 +197,7 @@ final class PartnershipLikeManager { } private func applyLocalState(liked: Bool, store: PartnershipDTO) { + stateVersion += 1 if liked { likedAtByStoreKey[store.storeKey] = Date().timeIntervalSince1970 likedStores.removeAll { $0.storeKey == store.storeKey } diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index adc652b2..3ea3cf6b 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -80,6 +80,7 @@ final class MainMapView: BaseUIView { button.layer.borderColor = UIColor.gray300.cgColor button.setImage(EATSSUDesignAsset.Images.icLikeLine.image, for: .normal) button.tintColor = .gray700 + button.accessibilityLabel = TextLiteral.Like.partnershipTab return button }() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 9cc23d73..dc8bab9f 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -201,8 +201,8 @@ final class MainMapViewController: BaseViewController { if currentTab != .partnership { switchTab(to: .partnership) } - // 학과가 없으면 학교 제휴 자체를 볼 수 없으므로 상세 대신 학과 입력 안내로 이어진다 - pendingDetailStore = hasDepartment ? store : nil + // 학과 확인이 끝난 뒤 판단한다 (Realm에 없어도 서버 조회로 학과가 확인될 수 있음) + pendingDetailStore = store returnsToLikeTab = true updateLikeReturnButton() presentPendingDetailIfNeeded() @@ -212,6 +212,11 @@ final class MainMapViewController: BaseViewController { guard let store = pendingDetailStore, viewIfLoaded?.window != nil, presentedViewController == nil else { return } + guard hasDepartment else { + // 학과가 없으면 학교 제휴 자체를 볼 수 없으므로 학과 안내가 대신 뜬다. 조회가 끝나기 전엔 보류 + if departmentLoadState == .loaded { pendingDetailStore = nil } + return + } pendingDetailStore = nil moveCamera(to: NMGLatLng(lat: store.latitude, lng: store.longitude), animated: false) showPartnershipDetail(for: store) @@ -257,6 +262,8 @@ final class MainMapViewController: BaseViewController { guard let self, self.isCurrentLoad(generation) else { return } self.departmentLoadState = .loaded self.loadPartnershipMarkers() + // 학과 확인을 기다리던 찜 상세가 있으면 이제 띄운다 + self.presentPendingDetailIfNeeded() } } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/NoDepartmentSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/NoDepartmentSheetViewController.swift index 8b49214c..d8020928 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/NoDepartmentSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/NoDepartmentSheetViewController.swift @@ -104,10 +104,9 @@ final class NoDepartmentSheetViewController: BaseViewController { return } - // "내 정보" 탭으로 전환 - tabContainer.setTab(index: 3) - - guard let myNav = tabContainer.getNavController(at: 3) else { + // "내 정보" 탭으로 전환 (로그인 필요로 막히면 알림만 뜨고 화면 전환은 하지 않는다) + guard tabContainer.setTab(index: 3), + let myNav = tabContainer.getNavController(at: 3) else { dismiss(animated: true) return } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index 16b3849f..5c4aeb3f 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -180,6 +180,9 @@ final class PartnershipDetailSheetViewController: BaseViewController { private func updateLikeButton() { let image = isLiked ? EATSSUDesignAsset.Images.icLikeFilled.image : EATSSUDesignAsset.Images.icLikeLine.image likeButton.setImage(image, for: .normal) + // VoiceOver: 이름은 '찜', 상태는 selected 트레이트로 전달 + likeButton.accessibilityLabel = TextLiteral.Like.title + likeButton.accessibilityTraits = isLiked ? [.button, .selected] : [.button] } /// 하트 탭: 찜 토글. 삭제 시엔 '취소하기'로 되돌릴 수 있는 토스트를 띄운다 diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index 080fe01f..d374efa7 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -100,13 +100,16 @@ final class CustomTabBarContainerController: UITabBarController { // MARK: - Public Interface /// 외부에서 탭 전환 요청 시 사용. 탭바 탭과 같은 로그인 조건을 적용한다 - public func setTab(index: Int) { - guard index < tabViewControllers.count, let tab = Tab(rawValue: index) else { return } + /// - Returns: 실제로 전환됐으면 true. 로그인 필요로 막히면 알림만 띄우고 false + @discardableResult + public func setTab(index: Int) -> Bool { + guard index < tabViewControllers.count, let tab = Tab(rawValue: index) else { return false } if tab.requiresLogin, RealmService.shared.isAccessTokenPresent() == false { presentLoginAlert() - return + return false } selectedIndex = index + return true } /// 지도 탭 → 찜 탭(제휴 찜)으로 전환. 찜 화면의 뒤로가기로 지도 탭에 복귀할 수 있게 표시한다 diff --git a/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift index 5443245f..553f33a2 100644 --- a/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift @@ -48,7 +48,7 @@ final class EmptyStateView: BaseUIView { override func setLayout() { stackView.snp.makeConstraints { $0.centerX.equalToSuperview() - // 정중앙(1.0)보다 위로: 디자인의 문구 위치(화면 약 53%)에 맞춤 + // 정중앙(1.0)보다 위로: 컨테이너 높이의 약 45% 지점 (디자인 기준) $0.centerY.equalTo(snp.centerY).multipliedBy(0.9) $0.leading.trailing.equalToSuperview().inset(24) }