diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 6d85cb8e..3de7f4b4 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,38 @@ "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."; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"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..47c14a6a 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,38 @@ "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" = "お気に入りの提携店から削除しました。"; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"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..fdf4667e 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,38 @@ "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" = "찜한 제휴에서 삭제했어요."; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"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..d36124e8 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,38 @@ "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."; +/// "찜 처리에 실패했어요. 다시 시도해 주세요." +"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/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 new file mode 100644 index 00000000..7bc9b9ed --- /dev/null +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -0,0 +1,236 @@ +// +// 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] + + /// 이 세션에서 토글에 성공한 항목의 최신 상태. 찜 목록 조회가 실패했거나 아직 안 됐을 때도 하트가 서버와 일치하게 한다 + 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 { + !store.partnershipInfos.isEmpty && store.partnershipInfos.allSatisfy(currentlyLiked) + } + + /// 찜 상태를 한 번은 서버에서 받아온 뒤 이어서 실행. 이미 받아왔으면 즉시 실행 + func ensureLoaded(completion: @escaping () -> Void) { + guard !hasLoaded else { + completion() + return + } + refresh { _ in completion() } + } + + // MARK: - Fetch + + /// 찜 목록을 서버에서 받아 상태를 갱신하고 최근 추가순으로 정렬해 돌려준다 + func refresh(completion: @escaping (Result<[PartnershipDTO], Error>) -> Void) { + let version = stateVersion + 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): + // 조회 중에 토글이 있었으면 이 응답은 이미 낡은 것이므로 로컬 상태를 유지한다 + 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 + self.pruneOrder(keeping: stores) + completion(.success(self.likedStores)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - Update + + /// 업체 단위 찜 설정. 완료 시 로컬 상태(항목 id, 목록, 순서)까지 반영한다 + func setLiked(_ liked: Bool, store: PartnershipDTO, completion: @escaping (Result) -> Void) { + // 찜 목록을 받기 전에 토글하면 빈 집합 기준으로 잘못 판단하므로 먼저 확보한다 + 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(())) + return + } + + // 요청 시작 시점에 버전을 올려, 이보다 먼저 시작된 목록 조회 응답이 토글 전 상태를 되살리지 못하게 한다 + stateVersion += 1 + 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 { + self.toggledStates[id] = liked + if liked { self.likedPartnershipIds.insert(id) } else { self.likedPartnershipIds.remove(id) } + } + if let firstError { + // 일부만 성공한 경우에도 목록/순서를 항목 상태와 일치시킨다 (전부 찜일 때만 목록에 남김) + self.applyLocalState(liked: self.isLiked(store), store: store) + 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 + + /// 항목 찜 상태: 이 세션의 토글 결과 > 서버 찜 목록 > (목록 미확보 시) 지도 응답의 isLiked + private func currentlyLiked(_ info: PartnershipInfoDTO) -> Bool { + if let toggled = toggledStates[info.id] { return toggled } + return 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 + } + + private func applyLocalState(liked: Bool, store: PartnershipDTO) { + stateVersion += 1 + 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/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/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..f3e92a6c --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift @@ -0,0 +1,171 @@ +// +// 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" + + /// 디자인(390pt 기준): 좌우 24, 아이콘 36, 텍스트 간격 12, 행 높이 77, 화살표 8×12 trailing 28 + private enum Layout { + 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 + + 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 = .gray600 + typeLabel.setContentHuggingPriority(.required, for: .horizontal) + typeLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + descriptionLabel.font = .body3 + descriptionLabel.textColor = .gray600 + descriptionLabel.numberOfLines = 1 + descriptionLabel.lineBreakMode = .byTruncatingTail + + chevronImageView.tintColor = .gray300 + chevronImageView.contentMode = .scaleAspectFit + + titleStackView.axis = .horizontal + titleStackView.alignment = .lastBaseline + titleStackView.spacing = 8 + titleStackView.addArrangedSubview(nameLabel) + titleStackView.addArrangedSubview(typeLabel) + + textStackView.axis = .vertical + textStackView.alignment = .leading + textStackView.spacing = 8 + textStackView.addArrangedSubview(titleStackView) + textStackView.addArrangedSubview(descriptionLabel) + + contentView.addSubviews(checkImageView, iconImageView, textStackView, chevronImageView) + } + + private func setLayout() { + checkImageView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(Layout.checkLeading) + $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.chevronTrailing) + $0.centerY.equalToSuperview() + $0.width.equalTo(8) + $0.height.equalTo(12) + } + + textStackView.snp.makeConstraints { + $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) + } + } + + // 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 + 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 + iconImageView.isHidden = true + checkImageView.image = isSelected + ? EATSSUDesignAsset.Images.icCheck.image + : EATSSUDesignAsset.Images.icUncheck.image + leadingAnchor = checkImageView.snp.trailing + spacing = Layout.checkTextSpacing + } + textStackView.snp.remakeConstraints { + $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/LikeViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift new file mode 100644 index 00000000..49cf22d8 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikeViewController.swift @@ -0,0 +1,168 @@ +// +// 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) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // 탭바로 다른 탭에 갔다 오면 지도 복귀 뒤로가기를 없앤다 (편집 화면 push는 같은 탭이므로 유지) + let leftTab = tabBarController?.selectedViewController !== navigationController + if showsBackToMap, leftTab { + showsBackToMap = false + updateBackButton() + } + } + + // 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..ec123230 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift @@ -0,0 +1,218 @@ +// +// LikedPartnershipEditViewController.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 제휴 찜 편집 모드: 전체/개별 선택 후 일괄 삭제. 필터는 제공하지 않는다 +final class LikedPartnershipEditViewController: BaseViewController { + + // MARK: - Constants + + /// 디자인: 전체 선택 행 52, 체크 18 (leading 35), 구분선 2pt, 삭제 버튼 52/r8, 하단 safe area + 6 + private enum Layout { + 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 + + 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.resize(newWidth: 18) + config.imagePadding = 15 + 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 = .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 = .gray100 + tableView.rowHeight = UITableView.automaticDimension + 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) + } + + override func setLayout() { + selectAllButton.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide) + $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(2) + } + + deleteButton.snp.makeConstraints { + $0.leading.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(Layout.buttonBottomInset) + } + deleteButton.snp.updateConstraints { + $0.height.equalTo(Layout.buttonHeight) + } + + 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 ? .error : .gray200 + } + + private func updateSelectAllButton() { + selectAllButton.configuration?.image = (isAllSelected + ? EATSSUDesignAsset.Images.icCheck.image + : EATSSUDesignAsset.Images.icUncheck.image).resize(newWidth: 18) + } + + // 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..6f9f9c41 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipViewController.swift @@ -0,0 +1,268 @@ +// +// LikedPartnershipViewController.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 제휴 찜 목록: 업종 필터 · 편집 진입 · 스와이프 삭제(취소하기) · 상세 진입 +final class LikedPartnershipViewController: BaseViewController { + + // MARK: - Constants + + /// 디자인: 탭 밑줄과 칩 사이 12, 좌우 24, 칩과 첫 행 사이 7 + private enum Layout { + static let chipBarTop: CGFloat = 12 + static let horizontalInset: CGFloat = 24 + static let listTop: CGFloat = 7 + } + + /// 찜 목록 필터 (축제 제외) + 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.horizontalInset = Layout.horizontalInset + filterChipBar.configure(titles: Self.filters.map { $0.title }) + + editButton.setTitle(TextLiteral.Like.edit, for: .normal) + editButton.titleLabel?.font = .body2 + 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 = .gray100 + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 77 + 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(Layout.listTop) + $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: + // 취소하기는 한 번만 동작 (토글 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: + 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) { + (tabBarController as? CustomTabBarContainerController)?.showPartnershipDetailOnMap(store) + } + + // 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 + } + + /// 빨간 원(36, danger) 안에 흰 휴지통 (디자인의 스와이프 삭제 아이콘) + private static let deleteActionImage: UIImage = { + 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.danger.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) + 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 + )) + }.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..3ea3cf6b 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -59,8 +59,30 @@ final class MainMapView: BaseUIView { // MARK: - UI Components let mapView = NMFNaverMapView() - let topTabView = MapTopTabView(titles: MapTab.allCases.map { $0.title }) - let filterChipBar = MapFilterChipBar() + + /// 학과 미입력 상태의 학교 제휴 탭에서 지도를 흐리게 덮는 뷰 (디자인 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() + + /// 찜 탭으로 이동하는 플로팅 하트 버튼. 필터 칩과 같은 줄 오른쪽에 두고, 칩은 그 왼쪽 영역에서 스크롤된다 + let likeButton: UIButton = { + let button = UIButton(type: .custom) + button.backgroundColor = .white + button.layer.cornerRadius = 18 + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor.gray300.cgColor + button.setImage(EATSSUDesignAsset.Images.icLikeLine.image, for: .normal) + button.tintColor = .gray700 + button.accessibilityLabel = TextLiteral.Like.partnershipTab + return button + }() // MARK: - UI Setup @@ -71,7 +93,7 @@ final class MainMapView: BaseUIView { mapView.showLocationButton = true mapView.mapView.positionMode = .disabled - addSubviews(mapView, topTabView, filterChipBar) + addSubviews(mapView, blurView, topTabView, filterChipBar, likeButton) } // MARK: - Layout Setup @@ -87,9 +109,39 @@ 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) + $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) + } + } + + /// 학과 미입력 안내 중 지도 블러 표시/해제 + func setMapBlurred(_ blurred: Bool) { + blurView.isHidden = !blurred + } + + /// 하트 노출 여부에 따라 칩 바 폭을 조정한다 (숨기면 칩이 오른쪽 끝까지 스크롤) + 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+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index fa5a7ca2..5f74804c 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,19 @@ 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, + isLikeEnabled: hasDepartment + ) 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..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): @@ -83,7 +90,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( @@ -115,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 @@ -131,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 fe9e1037..dc8bab9f 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,8 +104,26 @@ 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 } + /// 찜 목록에서 넘어온 업체. 지도 탭이 화면에 나타난 뒤 시트로 띄우고 비운다 + private var pendingDetailStore: PartnershipDTO? + + /// 찜 목록에서 넘어온 상태. 네비게이션 바 뒤로가기가 찜 탭 복귀로 동작한다 + private var returnsToLikeTab = false + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -108,6 +133,7 @@ final class MainMapViewController: BaseViewController { override func configureUI() { view.addSubview(root) root.setTopTabVisible(mode == .tabbed) + root.setLikeButtonVisible(mode == .tabbed) } override func setLayout() { @@ -122,6 +148,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,11 +165,6 @@ 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) @@ -154,16 +176,94 @@ final class MainMapViewController: BaseViewController { } } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + logScreenView(screenID: FirebaseScreenID.Map.map1) + + // 찜 목록에서 넘어온 업체가 있으면 화면이 붙은 뒤 시트를 띄운다 + presentPendingDetailIfNeeded() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + // 다른 탭으로 옮겨가면 찜 복귀 상태와 아직 못 띄운 상세를 함께 버린다 (나중에 엉뚱하게 뜨지 않도록) + pendingDetailStore = nil + if returnsToLikeTab { + returnsToLikeTab = false + updateLikeReturnButton() + } + } + + // MARK: - Like + + /// 찜 목록에서 업체를 선택했을 때: 학교 제휴 탭으로 맞추고, 지도가 나타나면 해당 업체 시트를 띄운다 + func showDetailFromLikes(_ store: PartnershipDTO) { + if currentTab != .partnership { + switchTab(to: .partnership) + } + // 학과 확인이 끝난 뒤 판단한다 (Realm에 없어도 서버 조회로 학과가 확인될 수 있음) + pendingDetailStore = store + returnsToLikeTab = true + updateLikeReturnButton() + presentPendingDetailIfNeeded() + } + + private func presentPendingDetailIfNeeded() { + 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) + } + + /// 찜 목록에서 넘어온 경우에만 뒤로가기(찜 탭 복귀) 버튼을 보여준다 + 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() { + (tabBarController as? CustomTabBarContainerController)?.showLikedPartnerships(fromMap: true) + } + /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드. 축제 노출 여부가 바뀌었을 수 있어 칩도 재구성 private func refreshPartnershipTab() { applyTabUI() 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() + // 학과 확인을 기다리던 찜 상세가 있으면 이제 띄운다 + self.presentPendingDetailIfNeeded() } } @@ -205,6 +305,8 @@ final class MainMapViewController: BaseViewController { /// 현재 탭에 맞춰 필터 칩과 선택 상태를 갱신 private func applyTabUI() { root.topTabView.select(index: currentTab.rawValue, animated: false) + // 찜은 학교 제휴 전용이라 착한 가격 탭에서는 플로팅 하트를 숨긴다 + root.setLikeButtonVisible(mode == .tabbed && currentTab == .partnership) switch currentTab { case .partnership: @@ -236,6 +338,7 @@ final class MainMapViewController: BaseViewController { case .partnership: refreshPartnershipTab() case .goodPrice: + root.setMapBlurred(false) applyTabUI() MapAnalyticsManager.shared.logClickMapGoodPrice( collegeId: currentCollegeId, @@ -267,7 +370,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: @@ -276,26 +380,15 @@ 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) } } // MARK: - Partnership Tab - /// 학교 제휴 탭 마커 로드. 학과 미입력이면 학과 입력 시트를 띄우고 마커는 비움 + /// 학교 제휴 탭 마커 로드. 학과 미입력이면 지도를 흐리게 하고 학과 입력 시트를 띄운다 (축제 포함 모든 필터) func loadPartnershipMarkers() { - if partnershipFilter == .festival { - refreshAllPartnerships() - return - } - guard hasDepartment else { switch departmentLoadState { case .loading: @@ -306,10 +399,17 @@ final class MainMapViewController: BaseViewController { return case .loaded: displayMarkers([]) + root.setMapBlurred(true) presentNoDepartmentSheetIfNeeded() return } } + root.setMapBlurred(false) + + if partnershipFilter == .festival { + refreshAllPartnerships() + return + } fetchMyPartnerships() } @@ -318,7 +418,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) } @@ -329,6 +432,7 @@ final class MainMapViewController: BaseViewController { switch currentTab { case .partnership: cachedAllPartnerships = [] + cachedMyPartnerships = [] refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] @@ -337,13 +441,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/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 fa08df70..5c4aeb3f 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -15,12 +15,18 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Properties + /// 시트에 표시할 업체 (지도 필터에 맞춰 제휴 항목이 걸러진 DTO) private let partnership: PartnershipDTO + /// 찜 토글 대상 업체 (필터와 무관한 전체 제휴 항목). 업체 찜은 항상 모든 항목을 함께 다룬다 + private let likeTarget: PartnershipDTO + /// 학과 미입력 시 찜 사용 불가 (기획). false면 하트를 숨긴다 + private let isLikeEnabled: Bool private let mapAppLauncher: MapAppLauncher // 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() @@ -29,8 +35,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Init - init(partnership: PartnershipDTO) { + 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, @@ -55,6 +63,11 @@ final class PartnershipDetailSheetViewController: BaseViewController { storeNameLabel.font = .header2 storeNameLabel.textColor = .label + likeButton.tintColor = .gray700 + likeButton.isHidden = !isLikeEnabled + likeButton.addTarget(self, action: #selector(didTapLike), for: .touchUpInside) + updateLikeButton() + typeIconImageView.contentMode = .scaleAspectFit typeIconImageView.snp.makeConstraints { $0.width.height.equalTo(18) } @@ -77,7 +90,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 +98,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 { @@ -111,6 +131,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() configureData() + // 찜 탭을 열기 전에 지도에서 시트를 열어도 하트가 서버 찜 상태를 반영하도록 + PartnershipLikeManager.shared.ensureLoaded { [weak self] in + self?.updateLikeButton() + } } override func viewDidAppear(_ animated: Bool) { @@ -147,6 +171,60 @@ final class PartnershipDetailSheetViewController: BaseViewController { } } + // MARK: - Like + + private var isLiked: Bool { + PartnershipLikeManager.shared.isLiked(likeTarget) + } + + 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] + } + + /// 하트 탭: 찜 토글. 삭제 시엔 '취소하기'로 되돌릴 수 있는 토스트를 띄운다 + @objc private func didTapLike() { + likeButton.isEnabled = false + // 찜 목록을 확보한 뒤 현재 상태를 판정한다 (받기 전엔 하트가 지도 응답 기준이라 어긋날 수 있음) + PartnershipLikeManager.shared.ensureLoaded { [weak self] in + guard let self else { return } + self.setLiked(!self.isLiked) + } + } + + private func setLiked(_ liked: Bool) { + likeButton.isEnabled = false + PartnershipLikeManager.shared.setLiked(liked, store: likeTarget) { [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 { + // 취소하기는 한 번만 동작 (토글 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) + } + } + case .failure: + self.showToast(message: TextLiteral.Like.updateFailed, type: .danger) + } + } + } + // MARK: - UI Helpers /// 제휴 content 갯수에 따라 유동적으로 Height 측정 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 a70a603a..d374efa7 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -18,8 +18,11 @@ final class CustomTabBarContainerController: UITabBarController { private enum Tab: Int { case home = 0 case map = 1 - case coffee = 2 + case like = 2 case myPage = 3 + + /// 지도·찜·마이페이지는 로그인 필요 + var requiresLogin: Bool { self != .home } } // MARK: - Properties @@ -27,21 +30,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 +44,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 +63,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)) ] @@ -120,16 +99,37 @@ final class CustomTabBarContainerController: UITabBarController { // MARK: - Public Interface - /// 외부에서 탭 전환 요청 시 사용 - public func setTab(index: Int) { - guard index < tabViewControllers.count else { return } - - if Tab(rawValue: index) == .coffee { - presentCoffeeWebView() - 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 false } - selectedIndex = index + return true + } + + /// 지도 탭 → 찜 탭(제휴 찜)으로 전환. 찜 화면의 뒤로가기로 지도 탭에 복귀할 수 있게 표시한다 + 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 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) } /// 특정 인덱스의 네비게이션 컨트롤러를 반환 @@ -224,14 +224,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 +252,7 @@ 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.requiresLogin, RealmService.shared.isAccessTokenPresent() == false { presentLoginAlert() return false } @@ -273,9 +260,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 ) } @@ -290,7 +279,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 +292,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..90566d84 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,88 @@ 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: "찜한 제휴에서 삭제했어요.") + } + + + /// "찜 처리에 실패했어요. 다시 시도해 주세요." + 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..553f33a2 --- /dev/null +++ b/EATSSU/App/Sources/Utility/UIComponent/EmptyStateView.swift @@ -0,0 +1,64 @@ +// +// EmptyStateView.swift +// EATSSU +// +// Created by 황상환 on 8/30/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 목록이 비었을 때 메인/서브 카피를 보여주는 공용 뷰 +/// 디자인 기준 문구 위치는 정중앙보다 약간 위(컨테이너 높이의 약 45%)이며 두 줄 모두 회색이다 +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 = .subtitle2 + titleLabel.textColor = .gray600 + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 0 + + subtitleLabel.font = .caption2 + subtitleLabel.textColor = .gray600 + 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.centerX.equalToSuperview() + // 정중앙(1.0)보다 위로: 컨테이너 높이의 약 45% 지점 (디자인 기준) + $0.centerY.equalTo(snp.centerY).multipliedBy(0.9) + $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 76% rename from EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift rename to EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift index c0ef9f97..9604b9ec 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,21 +12,28 @@ import SnapKit import EATSSUDesign /// 지도 위에 떠 있는 가로 스크롤 필터 칩 바 (단일 선택) -final class MapFilterChipBar: BaseUIView { +/// 가로 스크롤 필터 칩 바 (단일 선택). 지도 필터와 찜 목록 필터 공용 +final class FilterChipBar: BaseUIView { // MARK: - Constants 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() } @@ -34,6 +41,7 @@ final class MapFilterChipBar: BaseUIView { private(set) var selectedIndex: Int = 0 private var chips: [UIButton] = [] + private var titles: [String] = [] // MARK: - UI Components @@ -48,12 +56,7 @@ final class MapFilterChipBar: 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 @@ -80,6 +83,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 +119,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 +132,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 83% rename from EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift rename to EATSSU/App/Sources/Utility/UIComponent/UnderlineTabView.swift index bd9741df..412dfc89 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,16 @@ import SnapKit import EATSSUDesign /// 지도 상단 "학교 제휴 / 착한 가격" 언더라인 탭 -final class MapTopTabView: BaseUIView { +/// 밑줄 인디케이터가 있는 상단 탭. 지도(학교 제휴/착한 가격)와 찜(메뉴/제휴) 공용 +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 @@ -43,7 +52,7 @@ final class MapTopTabView: 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) @@ -62,7 +71,7 @@ final class MapTopTabView: BaseUIView { stackView.distribution = .fillEqually stackView.alignment = .fill - bottomLine.backgroundColor = .gray300 + bottomLine.backgroundColor = .gray400 indicatorView.backgroundColor = .primary addSubviews(stackView, bottomLine, indicatorView) @@ -71,18 +80,18 @@ final class MapTopTabView: 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() } } @@ -94,7 +103,7 @@ final class MapTopTabView: 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 { 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 e3453770..00000000 Binary files a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee.imageset/coffee.png and /dev/null differ 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 87447de4..00000000 Binary files a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/coffee_selected.imageset/coffee_selected.png and /dev/null differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/Contents.json new file mode 100644 index 00000000..fc96881f --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ic_delete.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_delete.imageset/ic_delete.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/ic_delete.pdf new file mode 100644 index 00000000..cf3fbafb Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_delete.imageset/ic_delete.pdf differ 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 00000000..ac02576a Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_filled.imageset/ic_like_filled.pdf differ 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 00000000..7faab53e Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/ic_like_line.imageset/ic_like_line.pdf differ 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 @@ - - - - -