diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 0870308f..c38d7e4d 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -8,7 +8,6 @@ // MARK: - Common /// "숭실대에서 먹자" -"common.logoSubTitle" = "Eat at Soongsil"; /// "확인" "common.confirm" = "Confirm"; /// "취소" @@ -175,7 +174,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "Partner Map"; +"map.map" = "Map"; /// MainMapView - "전체" "map.all" = "All"; /// MainMapView - "내 제휴" @@ -201,6 +200,22 @@ /// MainMapVC+Location - "지도에서 내 위치를 바로 확인하고, 현재 위치 주변의 제휴점들을 손쉽게 찾아볼 수 있도록 위치 권한을 허용해 주세요." "map.locationAuthDescription" = "Please allow location permission so you can check your location on the map and easily find nearby partners."; +"map.goodPriceMapTitle" = "Good Price Stores Map"; +"map.schoolPartnershipTab" = "Campus Partners"; +"map.goodPriceTab" = "Good Price"; +"map.korean" = "Korean"; +"map.japanese" = "Japanese"; +"map.western" = "Western"; +"map.chinese" = "Chinese"; +"map.bakery" = "Bakery"; +"map.etc" = "Others"; +"map.storeLoadFailed" = "Couldn't load store information."; +"map.priceWon" = "%@ KRW"; +"auth.loginSubTitle" = "From Soongsil to all of Seoul"; +"auth.loginSubTitleHighlight" = "to all of Seoul"; +"auth.goodPriceEntryTitle" = "Explore Good Price Stores"; +"auth.goodPriceEntrySubTitle" = "Find an affordable meal near you"; + // MARK: - MyPage diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index bbaf7846..a01c912b 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -8,7 +8,6 @@ // MARK: - Common /// "숭실대에서 먹자" -"common.logoSubTitle" = "崇実大で食べよう"; /// "확인" "common.confirm" = "確認"; /// "취소" @@ -176,7 +175,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "提携マップ"; +"map.map" = "マップ"; /// MainMapView - "전체" "map.all" = "すべて"; /// MainMapView - "내 제휴" @@ -202,6 +201,22 @@ /// MainMapVC+Location - "지도에서 내 위치를 바로 확인하고, 현재 위치 주변의 제휴점들을 손쉽게 찾아볼 수 있도록 위치 권한을 허용해 주세요." "map.locationAuthDescription" = "現在地をすぐに確認し、近くの提携レストランを探せるように、位置情報へのアクセスを許可してください。"; +"map.goodPriceMapTitle" = "良心価格店マップ"; +"map.schoolPartnershipTab" = "学校提携"; +"map.goodPriceTab" = "良心価格"; +"map.korean" = "韓食"; +"map.japanese" = "和食"; +"map.western" = "洋食"; +"map.chinese" = "中華"; +"map.bakery" = "ベーカリー"; +"map.etc" = "その他"; +"map.storeLoadFailed" = "店舗情報を読み込めませんでした。"; +"map.priceWon" = "%@ウォン"; +"auth.loginSubTitle" = "崇実大からソウル各地まで"; +"auth.loginSubTitleHighlight" = "ソウル各地まで"; +"auth.goodPriceEntryTitle" = "良心価格店を見る"; +"auth.goodPriceEntrySubTitle" = "近くのお手頃な一食を探そう"; + // MARK: - MyPage diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index bbdbc7c5..56b82129 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -8,7 +8,6 @@ // MARK: - Common /// "숭실대에서 먹자" -"common.logoSubTitle" = "숭실대에서 먹자"; /// "확인" "common.confirm" = "확인"; /// "취소" @@ -176,7 +175,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "제휴 지도"; +"map.map" = "지도"; /// MainMapView - "전체" "map.all" = "전체"; /// MainMapView - "내 제휴" @@ -202,6 +201,22 @@ /// MainMapVC+Location - "지도에서 내 위치를 바로 확인하고, 현재 위치 주변의 제휴점들을 손쉽게 찾아볼 수 있도록 위치 권한을 허용해 주세요." "map.locationAuthDescription" = "지도에서 내 위치를 바로 확인하고, 현재 위치 주변의 제휴점들을 손쉽게 찾아볼 수 있도록 위치 권한을 허용해 주세요."; +"map.goodPriceMapTitle" = "착한가격업소 지도"; +"map.schoolPartnershipTab" = "학교 제휴"; +"map.goodPriceTab" = "착한 가격"; +"map.korean" = "한식"; +"map.japanese" = "일식"; +"map.western" = "양식"; +"map.chinese" = "중식"; +"map.bakery" = "베이커리"; +"map.etc" = "기타"; +"map.storeLoadFailed" = "업소 정보를 불러오지 못했어요."; +"map.priceWon" = "%@원"; +"auth.loginSubTitle" = "숭실대에서 서울 곳곳까지"; +"auth.loginSubTitleHighlight" = "서울 곳곳까지"; +"auth.goodPriceEntryTitle" = "착한가격업소 둘러보기"; +"auth.goodPriceEntrySubTitle" = "내 주변 착한 한 끼를 찾아보세요"; + // MARK: - MyPage diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index 0d48c689..fe9c1720 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -8,7 +8,6 @@ // MARK: - Common /// "숭실대에서 먹자" -"common.logoSubTitle" = "Ăn ở Soongsil"; /// "확인" "common.confirm" = "Xác nhận"; /// "취소" @@ -176,7 +175,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "Bản đồ đối tác"; +"map.map" = "Bản đồ"; /// MainMapView - "전체" "map.all" = "Tất cả"; /// MainMapView - "내 제휴" @@ -202,6 +201,22 @@ /// MainMapVC+Location - "지도에서 내 위치를 바로 확인하고, 현재 위치 주변의 제휴점들을 손쉽게 찾아볼 수 있도록 위치 권한을 허용해 주세요." "map.locationAuthDescription" = "Vui lòng cho phép truy cập vị trí để nhanh chóng xem vị trí của bạn và tìm các nhà hàng đối tác gần đó."; +"map.goodPriceMapTitle" = "Bản đồ quán giá tốt"; +"map.schoolPartnershipTab" = "Đối tác trường"; +"map.goodPriceTab" = "Giá tốt"; +"map.korean" = "Món Hàn"; +"map.japanese" = "Món Nhật"; +"map.western" = "Món Âu"; +"map.chinese" = "Món Trung"; +"map.bakery" = "Tiệm bánh"; +"map.etc" = "Khác"; +"map.storeLoadFailed" = "Không thể tải thông tin cửa hàng."; +"map.priceWon" = "%@ won"; +"auth.loginSubTitle" = "Từ Soongsil đến khắp Seoul"; +"auth.loginSubTitleHighlight" = "đến khắp Seoul"; +"auth.goodPriceEntryTitle" = "Khám phá quán giá tốt"; +"auth.goodPriceEntrySubTitle" = "Tìm bữa ăn giá hợp lý gần bạn"; + // MARK: - MyPage diff --git a/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift b/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift index 28482178..46e065e2 100644 --- a/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift +++ b/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift @@ -21,6 +21,9 @@ final class MapAnalyticsManager { static let clickMapMine = "click_map_mine" static let clickMapFestival = "click_map_festival" static let clickPartnerRestaurant = "click_partner_restaurant" + static let clickMapGoodPrice = "click_map_good_price" + static let clickGoodPriceCategory = "click_good_price_category" + static let clickGoodPriceStore = "click_good_price_store" } private enum Parameter { @@ -28,6 +31,8 @@ final class MapAnalyticsManager { static let major = "major" static let defaultType = "default_type" static let partnerRestaurantId = "partner_restaurant_id" + static let category = "category" + static let goodPriceStoreId = "good_price_store_id" } enum MapDefaultType: String { @@ -106,6 +111,36 @@ final class MapAnalyticsManager { AnalyticsService.logEvent(Event.clickPartnerRestaurant, parameters: parameters) } + /** + #6 지도 화면에서 '착한 가격' 탭을 클릭했을 때 호출 + */ + func logClickMapGoodPrice(collegeId: Int?, majorId: Int?) { + AnalyticsService.logEvent( + Event.clickMapGoodPrice, + parameters: makeParameters(collegeId: collegeId, majorId: majorId) + ) + } + + /** + #7 착한가격 탭에서 업종 필터를 클릭했을 때 호출 + - Parameter category: 선택한 업종 (전체는 "ALL") + */ + func logClickGoodPriceCategory(category: GoodPriceCategory) { + AnalyticsService.logEvent(Event.clickGoodPriceCategory, parameters: [ + Parameter.category: category.serverValue ?? "ALL" + ]) + } + + /** + #8 지도에서 착한가격업소 마커를 클릭했을 때 호출 + - Parameter storeId: 클릭된 업소 ID + */ + func logClickGoodPriceStore(storeId: Int) { + AnalyticsService.logEvent(Event.clickGoodPriceStore, parameters: [ + Parameter.goodPriceStoreId: storeId + ]) + } + // MARK: - Helpers /// college/major가 nil이면 해당 키를 제외하고 파라미터 dict 구성 diff --git a/EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceCategory.swift b/EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceCategory.swift new file mode 100644 index 00000000..f673aa99 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceCategory.swift @@ -0,0 +1,52 @@ +// +// GoodPriceCategory.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import Foundation + +/// 착한가격업소 업종 필터. 순서 = 화면 필터 노출 순서 +enum GoodPriceCategory: CaseIterable { + case all + case korean + case japanese + case western + case chinese + case bakery + case etc + + /// 서버 category 파라미터 값. 전체는 nil + var serverValue: String? { + switch self { + case .all: return nil + case .korean: return "KOREAN" + case .japanese: return "JAPANESE" + case .western: return "WESTERN" + case .chinese: return "CHINESE" + case .bakery: return "BAKERY" + case .etc: return "ETC" + } + } + + var title: String { + switch self { + case .all: return TextLiteral.Map.all + case .korean: return TextLiteral.Map.korean + case .japanese: return TextLiteral.Map.japanese + case .western: return TextLiteral.Map.western + case .chinese: return TextLiteral.Map.chinese + case .bakery: return TextLiteral.Map.bakery + case .etc: return TextLiteral.Map.etc + } + } + + /// 서버 category 문자열 → enum (전체는 매칭 대상 아님) + init?(serverValue: String) { + guard let matched = Self.allCases.first(where: { $0.serverValue == serverValue }) else { + return nil + } + self = matched + } +} diff --git a/EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceStoreDTO.swift b/EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceStoreDTO.swift new file mode 100644 index 00000000..398a7f24 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceStoreDTO.swift @@ -0,0 +1,28 @@ +// +// GoodPriceStoreDTO.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import Foundation + +/// 착한가격업소 목록 항목 (GET /good-price-stores) +struct GoodPriceStoreDTO: Codable { + let id: Int + let storeName: String + let category: String + let latitude: Double + let longitude: Double +} + +/// 착한가격업소 상세 (GET /good-price-stores/{id}) +struct GoodPriceStoreDetailDTO: Codable { + let id: Int + let storeName: String + let category: String + let mainMenu: String? + let price: Int? + let roadAddress: String? + let imageUrl: String? +} diff --git a/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift b/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift new file mode 100644 index 00000000..78a67582 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift @@ -0,0 +1,44 @@ +// +// GoodPriceStoreRouter.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import Foundation + +import Moya + +enum GoodPriceStoreRouter { + /// 전체 목록 조회 (업종 필터는 클라이언트에서 처리) + case getStores + /// 상세 조회 + case getStoreDetail(id: Int) +} + +extension GoodPriceStoreRouter: TargetType { + var baseURL: URL { + URL(string: Config.baseURL)! + } + + var path: String { + switch self { + case .getStores: + return "/good-price-stores" + case .getStoreDetail(let id): + return "/good-price-stores/\(id)" + } + } + + var method: Moya.Method { + return .get + } + + var task: Task { + return .requestPlain + } + + var headers: [String: String]? { + return ["Content-Type": "application/json"] + } +} diff --git a/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift b/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift new file mode 100644 index 00000000..d78c8f62 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift @@ -0,0 +1,111 @@ +// +// GoodPriceEntryButton.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 로그인 화면의 "착한가격업소 둘러보기" 카드형 버튼 +final class GoodPriceEntryButton: UIControl { + + // MARK: - UI Components + + private let iconBackgroundView = UIView() + private let iconImageView = UIImageView(image: EATSSUDesignAsset.Images.restaurantPin.image) + private let titleLabel = UILabel() + private let subTitleLabel = UILabel() + private let textStackView = UIStackView() + private let chevronImageView = UIImageView( + image: UIImage( + systemName: "chevron.right", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + ) + ) + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + configureUI() + setLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Highlight + + override var isHighlighted: Bool { + didSet { alpha = isHighlighted ? 0.6 : 1 } + } + + // MARK: - Setup + + private func configureUI() { + backgroundColor = UIColor.primary.withAlphaComponent(0.08) + layer.cornerRadius = 12 + layer.borderWidth = 1 + layer.borderColor = UIColor.primary.cgColor + + iconBackgroundView.backgroundColor = .primary + iconBackgroundView.layer.cornerRadius = 18 + iconBackgroundView.isUserInteractionEnabled = false + + iconImageView.contentMode = .scaleAspectFit + iconImageView.tintColor = .white + + titleLabel.text = TextLiteral.Auth.goodPriceEntryTitle + titleLabel.font = .subtitle1 + titleLabel.textColor = .label + + subTitleLabel.text = TextLiteral.Auth.goodPriceEntrySubTitle + subTitleLabel.font = .caption2 + subTitleLabel.textColor = .gray700 + + textStackView.axis = .vertical + textStackView.spacing = 2 + textStackView.isUserInteractionEnabled = false + textStackView.addArrangedSubview(titleLabel) + textStackView.addArrangedSubview(subTitleLabel) + + chevronImageView.contentMode = .scaleAspectFit + chevronImageView.tintColor = .primary + + iconBackgroundView.addSubview(iconImageView) + addSubviews(iconBackgroundView, textStackView, chevronImageView) + } + + private func setLayout() { + snp.makeConstraints { $0.height.equalTo(68) } + + iconBackgroundView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.width.height.equalTo(36) + } + + iconImageView.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.height.equalTo(20) + } + + textStackView.snp.makeConstraints { + $0.leading.equalTo(iconBackgroundView.snp.trailing).offset(12) + $0.centerY.equalToSuperview() + $0.trailing.lessThanOrEqualTo(chevronImageView.snp.leading).offset(-8) + } + + chevronImageView.snp.makeConstraints { + $0.trailing.equalToSuperview().inset(16) + $0.centerY.equalToSuperview() + $0.width.height.equalTo(20) + } + } +} diff --git a/EATSSU/App/Sources/Presentation/Auth/View/LoginView.swift b/EATSSU/App/Sources/Presentation/Auth/View/LoginView.swift index f9bf8441..7bd30731 100644 --- a/EATSSU/App/Sources/Presentation/Auth/View/LoginView.swift +++ b/EATSSU/App/Sources/Presentation/Auth/View/LoginView.swift @@ -23,7 +23,8 @@ final class LoginView: BaseUIView { private let logoSubTitle: UILabel = { let label = UILabel() label.font = .header2 - label.attributedText = TextLiteral.Common.logoSubTitle.logoHighlightedLastWord( + label.attributedText = TextLiteral.Auth.loginSubTitle.highlighted( + TextLiteral.Auth.loginSubTitleHighlight, baseColor: .black, highlightColor: .primary ) @@ -42,23 +43,63 @@ final class LoginView: BaseUIView { button.backgroundColor = .clear return button }() + + let goodPriceEntryButton = GoodPriceEntryButton() + + /// 서울동아리ON / SEOUL MY SOUL 로고 (서울시 지원사업 필수 표기) + private let sponsorLogoStackView: UIStackView = { + let clubOnLogo = UIImageView(image: EATSSUDesignAsset.Images.seoulClubOnLogo.image) + clubOnLogo.contentMode = .scaleAspectFit + clubOnLogo.snp.makeConstraints { + $0.height.equalTo(18) + $0.width.equalTo(clubOnLogo.snp.height).multipliedBy(LoginView.aspectRatio(of: clubOnLogo.image)) + } + + let divider = UIView() + divider.backgroundColor = .gray300 + divider.snp.makeConstraints { + $0.width.equalTo(1) + $0.height.equalTo(20) + } + + let mySoulLogo = UIImageView(image: EATSSUDesignAsset.Images.seoulMySoulLogo.image) + mySoulLogo.contentMode = .scaleAspectFit + mySoulLogo.snp.makeConstraints { + $0.height.equalTo(16) + $0.width.equalTo(mySoulLogo.snp.height).multipliedBy(LoginView.aspectRatio(of: mySoulLogo.image)) + } + + let stack = UIStackView(arrangedSubviews: [clubOnLogo, divider, mySoulLogo]) + stack.axis = .horizontal + stack.alignment = .center + stack.spacing = 20 + return stack + }() private var lastLoginTooltipView: LastLoginTooltipView? + /// 이미지 가로/세로 비율 (없으면 1) + private static func aspectRatio(of image: UIImage?) -> CGFloat { + guard let size = image?.size, size.height > 0 else { return 1 } + return size.width / size.height + } + override func configureUI() { addSubviews( logoImage, logoSubTitle, appleLoginButton, kakaoLoginButton, - lookingWithNoSignInButton + lookingWithNoSignInButton, + goodPriceEntryButton, + sponsorLogoStackView ) } override func setLayout() { logoImage.snp.makeConstraints { $0.centerX.equalToSuperview() - $0.top.equalToSuperview().inset(223) + $0.bottom.equalTo(appleLoginButton.snp.top).offset(-120) } logoSubTitle.snp.makeConstraints { @@ -69,18 +110,28 @@ final class LoginView: BaseUIView { appleLoginButton.snp.makeConstraints { $0.centerX.equalToSuperview() $0.horizontalEdges.equalToSuperview().inset(45) - $0.bottom.equalTo(self.safeAreaLayoutGuide).inset(176) + $0.bottom.equalTo(kakaoLoginButton.snp.top).offset(-16) } kakaoLoginButton.snp.makeConstraints { $0.centerX.equalToSuperview() $0.horizontalEdges.equalToSuperview().inset(45) - $0.bottom.equalTo(self.safeAreaLayoutGuide).inset(115) + $0.bottom.equalTo(lookingWithNoSignInButton.snp.top).offset(-16) } lookingWithNoSignInButton.snp.makeConstraints { $0.centerX.equalToSuperview() - $0.bottom.equalTo(self.safeAreaLayoutGuide).inset(30) + $0.bottom.equalTo(goodPriceEntryButton.snp.top).offset(-28) + } + + goodPriceEntryButton.snp.makeConstraints { + $0.horizontalEdges.equalToSuperview().inset(45) + $0.bottom.equalTo(sponsorLogoStackView.snp.top).offset(-40) + } + + sponsorLogoStackView.snp.makeConstraints { + $0.centerX.equalToSuperview() + $0.bottom.equalTo(self.safeAreaLayoutGuide).inset(24) } } diff --git a/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift b/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift index b9ce6676..5d3208ef 100644 --- a/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift +++ b/EATSSU/App/Sources/Presentation/Auth/ViewController/LoginViewController.swift @@ -81,6 +81,11 @@ final class LoginViewController: BaseViewController { action: #selector(lookingWithNoSignInButtonDidTapped), for: .touchUpInside ) + loginView.goodPriceEntryButton.addTarget( + self, + action: #selector(goodPriceEntryButtonDidTapped), + for: .touchUpInside + ) } private func configureFirebaseRemoteConfig() { @@ -194,6 +199,21 @@ final class LoginViewController: BaseViewController { AnalyticsService.logEvent("click_login", parameters: ["method": "guest"]) changeIntoHomeViewController() } + + /// 로그인 없이 착한가격업소 지도로 진입 + @objc + private func goodPriceEntryButtonDidTapped() { + AnalyticsService.logEvent("click_login", parameters: ["method": "good_price"]) + let mapVC = MainMapViewController(mode: .standaloneGoodPrice) + if let navigationController { + navigationController.pushViewController(mapVC, animated: true) + } else { + // 네비게이션 없이 루트로 올라온 경우: 닫기 버튼이 있는 네비게이션으로 모달 표시 + let nav = UINavigationController(rootViewController: mapVC) + nav.modalPresentationStyle = .fullScreen + present(nav, animated: true) + } + } } // MARK: - 네트워크 요청 diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index 576bd652..6803e962 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -12,10 +12,46 @@ import SnapKit import EATSSUDesign -enum MapMode { +/// 지도 상단 탭 +enum MapTab: Int, CaseIterable { + case partnership + case goodPrice + + var title: String { + switch self { + case .partnership: return TextLiteral.Map.schoolPartnershipTab + case .goodPrice: return TextLiteral.Map.goodPriceTab + } + } +} + +/// 학교 제휴 탭 필터. festival은 Remote Config로 노출 여부 결정 +enum PartnershipFilter: CaseIterable { case festival - case myOnly case all + case restaurant + case cafe + case pub + + var title: String { + switch self { + case .festival: return TextLiteral.Map.festival + case .all: return TextLiteral.Map.all + case .restaurant: return TextLiteral.Map.restaurant + case .cafe: return TextLiteral.Map.cafe + case .pub: return TextLiteral.Map.pub + } + } + + /// 서버 restaurantType 값. 전체/축제는 nil + var restaurantType: String? { + switch self { + case .restaurant: return "RESTAURANT" + case .cafe: return "CAFE" + case .pub: return "PUB" + case .festival, .all: return nil + } + } } final class MainMapView: BaseUIView { @@ -23,137 +59,52 @@ final class MainMapView: BaseUIView { // MARK: - UI Components let mapView = NMFNaverMapView() - let toggleBackgroundView = UIView() - let festivalButton = UIButton(type: .system) - let myOnlyButton = UIButton(type: .system) - let wholeButton = UIButton(type: .system) + let topTabView = MapTopTabView(titles: MapTab.allCases.map { $0.title }) + let filterChipBar = MapFilterChipBar() // MARK: - UI Setup override func configureUI() { backgroundColor = .white - // 네이버 지도 뷰 설정 mapView.showZoomControls = false mapView.showLocationButton = true mapView.mapView.positionMode = .disabled - addSubview(mapView) - - // 상단 버튼 배경 뷰 설정 - toggleBackgroundView.layer.cornerRadius = 20 - toggleBackgroundView.layer.borderWidth = 1 - toggleBackgroundView.layer.borderColor = UIColor.gray300.cgColor - toggleBackgroundView.backgroundColor = .white - addSubview(toggleBackgroundView) - - let titleFont = UIFont.button2 - - configureToggleButton(festivalButton, title: TextLiteral.Map.festival, font: titleFont) - configureToggleButton(myOnlyButton, title: TextLiteral.Map.myPartner, font: titleFont) - configureToggleButton(wholeButton, title: TextLiteral.Map.all, font: titleFont) - - toggleBackgroundView.addSubview(festivalButton) - toggleBackgroundView.addSubview(myOnlyButton) - toggleBackgroundView.addSubview(wholeButton) - - // 초기 선택 상태: 축제 - select(.festival) - } - private func configureToggleButton(_ button: UIButton, title: String, font: UIFont) { - button.setTitle(title, for: .normal) - button.titleLabel?.font = font - button.titleLabel?.numberOfLines = 1 - button.titleLabel?.lineBreakMode = .byTruncatingTail - button.setContentHuggingPriority(.required, for: .horizontal) - button.setContentCompressionResistancePriority(.required, for: .horizontal) - button.layer.cornerRadius = 14 - button.clipsToBounds = true - button.backgroundColor = .clear - button.setTitleColor(.label, for: .normal) - if #available(iOS 15.0, *) { - var cfg = button.configuration ?? .plain() - cfg.title = title - cfg.baseForegroundColor = .label - cfg.baseBackgroundColor = .clear - cfg.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12) - cfg.titleLineBreakMode = .byTruncatingTail - cfg.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { inAttrs in - var out = inAttrs - out.font = font - return out - } - button.configuration = cfg - } else { - button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) - } + addSubviews(mapView, topTabView, filterChipBar) } // MARK: - Layout Setup override func setLayout() { - mapView.snp.makeConstraints { - $0.edges.equalToSuperview() - } - - toggleBackgroundView.snp.makeConstraints { - $0.top.equalTo(safeAreaLayoutGuide).offset(12) - $0.centerX.equalToSuperview() - $0.height.equalTo(40) - $0.leading.greaterThanOrEqualToSuperview().offset(15) - $0.trailing.lessThanOrEqualToSuperview().inset(15) - } - - festivalButton.snp.makeConstraints { - $0.top.bottom.equalToSuperview().inset(4) - $0.leading.equalToSuperview().inset(4) - $0.width.greaterThanOrEqualTo(60) + topTabView.snp.makeConstraints { + $0.top.equalTo(safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() } - myOnlyButton.snp.makeConstraints { - $0.top.bottom.equalToSuperview().inset(4) - $0.leading.equalTo(festivalButton.snp.trailing) - $0.width.greaterThanOrEqualTo(60) + mapView.snp.makeConstraints { + $0.top.equalTo(topTabView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() } - wholeButton.snp.makeConstraints { - $0.top.bottom.equalToSuperview().inset(4) - $0.leading.equalTo(myOnlyButton.snp.trailing) - $0.trailing.equalToSuperview().inset(4) - $0.width.greaterThanOrEqualTo(60) + filterChipBar.snp.makeConstraints { + $0.top.equalTo(mapView).offset(12) + $0.leading.trailing.equalToSuperview() } } - // MARK: - Button Selection Handling - - func select(_ mode: MapMode) { - let highlightColor: UIColor = (mode == .festival) ? .festivalPrimary : .primary - applySelection(festivalButton, isSelected: mode == .festival, highlightColor: highlightColor) - applySelection(myOnlyButton, isSelected: mode == .myOnly, highlightColor: highlightColor) - applySelection(wholeButton, isSelected: mode == .all, highlightColor: highlightColor) - } + // MARK: - Configuration - /// 축제 탭 노출 여부 토글. 숨길 때는 width를 0으로 만들어 레이아웃에서 사라지게 함 - func setFestivalVisible(_ visible: Bool) { - festivalButton.isHidden = !visible - festivalButton.snp.remakeConstraints { - $0.top.bottom.equalToSuperview().inset(4) - $0.leading.equalToSuperview().inset(4) + /// 단독 진입(착한가격업소 지도)에서는 상단 탭을 숨기고 지도를 safe area 상단까지 올린다 + func setTopTabVisible(_ visible: Bool) { + topTabView.isHidden = !visible + mapView.snp.remakeConstraints { if visible { - $0.width.greaterThanOrEqualTo(60) + $0.top.equalTo(topTabView.snp.bottom) } else { - $0.width.equalTo(0) + $0.top.equalTo(safeAreaLayoutGuide) } - } - } - - private func applySelection(_ button: UIButton, isSelected: Bool, highlightColor: UIColor) { - if isSelected { - button.backgroundColor = highlightColor - button.setTitleColor(.white, for: .normal) - } else { - button.backgroundColor = .clear - button.setTitleColor(.label, for: .normal) + $0.leading.trailing.bottom.equalToSuperview() } } } diff --git a/EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift b/EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift new file mode 100644 index 00000000..c0ef9f97 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift @@ -0,0 +1,152 @@ +// +// MapFilterChipBar.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 지도 위에 떠 있는 가로 스크롤 필터 칩 바 (단일 선택) +final class MapFilterChipBar: 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 + } + + // MARK: - Properties + + var onSelect: ((Int) -> Void)? + + /// 선택 칩 배경색 (축제 필터 등 모드에 따라 변경 가능) + var highlightColor: UIColor = .primary { + didSet { applySelection() } + } + + private(set) var selectedIndex: Int = 0 + private var chips: [UIButton] = [] + + // MARK: - UI Components + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + // MARK: - View Setup + + override func configureUI() { + backgroundColor = .clear + + scrollView.showsHorizontalScrollIndicator = false + scrollView.alwaysBounceHorizontal = true + scrollView.clipsToBounds = false + scrollView.contentInset = UIEdgeInsets( + top: 0, + left: Layout.horizontalInset, + bottom: 0, + right: Layout.horizontalInset + ) + + stackView.axis = .horizontal + stackView.spacing = Layout.chipSpacing + stackView.alignment = .center + + addSubview(scrollView) + scrollView.addSubview(stackView) + } + + override func setLayout() { + scrollView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalTo(Layout.chipHeight) + } + + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + $0.height.equalToSuperview() + } + } + + // MARK: - Configuration + + /// 칩 목록을 교체하고 첫 칩을 선택 상태로 둔다 + func configure(titles: [String], selectedIndex: Int = 0) { + chips.forEach { $0.removeFromSuperview() } + chips = titles.enumerated().map { index, title in + let chip = makeChip(title: title, index: index) + stackView.addArrangedSubview(chip) + return chip + } + select(index: selectedIndex, animated: false) + } + + func select(index: Int, animated: Bool = true) { + guard chips.indices.contains(index) else { return } + selectedIndex = index + applySelection() + scrollToSelectedChip(animated: animated) + } + + // MARK: - Private + + private func makeChip(title: String, index: Int) -> UIButton { + let chip = UIButton(type: .custom) + chip.tag = index + chip.setContentHuggingPriority(.required, for: .horizontal) + chip.setContentCompressionResistancePriority(.required, for: .horizontal) + chip.layer.cornerRadius = Layout.chipHeight / 2 + chip.layer.borderWidth = 1 + chip.clipsToBounds = true + chip.addTarget(self, action: #selector(didTapChip(_:)), for: .touchUpInside) + + var config = UIButton.Configuration.plain() + config.buttonSize = .mini + config.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: Layout.chipHorizontalPadding, + bottom: 0, + trailing: Layout.chipHorizontalPadding + ) + config.attributedTitle = AttributedString( + title, + attributes: AttributeContainer([.font: UIFont.button2]) + ) + chip.configuration = config + + chip.snp.makeConstraints { $0.height.equalTo(Layout.chipHeight) } + return chip + } + + private func applySelection() { + for (index, chip) in chips.enumerated() { + let isSelected = index == selectedIndex + chip.backgroundColor = isSelected ? highlightColor : .white + chip.layer.borderColor = isSelected ? highlightColor.cgColor : UIColor.gray300.cgColor + chip.configuration?.baseForegroundColor = isSelected ? .white : .gray500 + } + } + + /// 선택된 칩이 화면 밖에 있으면 보이도록 스크롤 + private func scrollToSelectedChip(animated: Bool) { + guard chips.indices.contains(selectedIndex) else { return } + layoutIfNeeded() + let chipFrame = chips[selectedIndex].frame + scrollView.scrollRectToVisible(chipFrame, animated: animated) + } + + // MARK: - Action + + @objc private func didTapChip(_ sender: UIButton) { + guard sender.tag != selectedIndex else { return } + select(index: sender.tag) + onSelect?(sender.tag) + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift b/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift new file mode 100644 index 00000000..bd9741df --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift @@ -0,0 +1,127 @@ +// +// MapTopTabView.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 지도 상단 "학교 제휴 / 착한 가격" 언더라인 탭 +final class MapTopTabView: BaseUIView { + + // MARK: - Properties + + var onSelect: ((Int) -> Void)? + + private(set) var selectedIndex: Int = 0 + + // MARK: - UI Components + + private let stackView = UIStackView() + private let bottomLine = UIView() + private let indicatorView = UIView() + private var buttons: [UIButton] = [] + + // MARK: - Init + + init(titles: [String]) { + super.init(frame: .zero) + titles.enumerated().forEach { index, title in + let button = UIButton(type: .system) + button.setTitle(title, for: .normal) + button.titleLabel?.font = .subtitle1 + button.tag = index + button.addTarget(self, action: #selector(didTapButton(_:)), for: .touchUpInside) + stackView.addArrangedSubview(button) + buttons.append(button) + } + // setLayout()은 버튼 추가 전에 호출되므로 여기서 탭 개수 기준으로 다시 잡는다 + indicatorView.snp.remakeConstraints { + $0.bottom.leading.equalToSuperview() + $0.height.equalTo(2) + $0.width.equalToSuperview().dividedBy(max(buttons.count, 1)) + } + select(index: 0, animated: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Setup + + override func configureUI() { + backgroundColor = .white + + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.alignment = .fill + + bottomLine.backgroundColor = .gray300 + indicatorView.backgroundColor = .primary + + addSubviews(stackView, bottomLine, indicatorView) + } + + override func setLayout() { + stackView.snp.makeConstraints { + $0.top.leading.trailing.equalToSuperview() + $0.height.equalTo(44) + } + + bottomLine.snp.makeConstraints { + $0.top.equalTo(stackView.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + $0.height.equalTo(1) + } + + indicatorView.snp.makeConstraints { + $0.bottom.leading.equalToSuperview() + $0.height.equalTo(2) + $0.width.equalToSuperview() + } + } + + // MARK: - Selection + + func select(index: Int, animated: Bool) { + guard buttons.indices.contains(index) else { return } + selectedIndex = index + + for (buttonIndex, button) in buttons.enumerated() { + button.setTitleColor(buttonIndex == index ? .primary : .gray500, for: .normal) + } + + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseInOut]) { + self.layoutIfNeeded() + self.updateIndicatorTransform() + } + } else { + updateIndicatorTransform() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + updateIndicatorTransform() + } + + private func updateIndicatorTransform() { + let tabWidth = bounds.width / CGFloat(max(buttons.count, 1)) + indicatorView.transform = CGAffineTransform(translationX: tabWidth * CGFloat(selectedIndex), y: 0) + } + + // MARK: - Action + + @objc private func didTapButton(_ sender: UIButton) { + guard sender.tag != selectedIndex else { return } + select(index: sender.tag, animated: true) + onSelect?(sender.tag) + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift new file mode 100644 index 00000000..782a8b42 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift @@ -0,0 +1,202 @@ +// +// GoodPriceDetailSheetViewController.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import UIKit + +import Kingfisher +import SnapKit + +import EATSSUDesign + +/// 착한가격업소 마커 선택 시 업소명/업종/주소/대표메뉴/가격/대표이미지를 보여주는 바텀시트 +final class GoodPriceDetailSheetViewController: BaseViewController { + + // MARK: - Constants + + private enum Layout { + static let horizontalInset: CGFloat = 24 + static let imageSize: CGFloat = 120 + static let bottomPadding: CGFloat = 24 + } + + // MARK: - Properties + + private let store: GoodPriceStoreDTO + + // MARK: - UI Components + + private let storeNameLabel = UILabel() + private let categoryStackView = UIStackView() + private let categoryIconImageView = UIImageView() + private let categoryTextLabel = UILabel() + private let addressLabel = UILabel() + private let menuLabel = UILabel() + private let textStackView = UIStackView() + private let storeImageView = UIImageView() + + // MARK: - Init + + init(store: GoodPriceStoreDTO) { + self.store = store + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .pageSheet + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View Setup + + override func configureUI() { + view.backgroundColor = .white + + storeNameLabel.font = .header2 + storeNameLabel.textColor = .label + storeNameLabel.numberOfLines = 2 + + categoryIconImageView.contentMode = .scaleAspectFit + categoryIconImageView.snp.makeConstraints { $0.width.height.equalTo(18) } + + categoryTextLabel.font = .body2 + categoryTextLabel.textColor = .gray + + categoryStackView.axis = .horizontal + categoryStackView.alignment = .center + categoryStackView.spacing = 4 + categoryStackView.addArrangedSubview(categoryIconImageView) + categoryStackView.addArrangedSubview(categoryTextLabel) + + addressLabel.font = .subtitle2 + addressLabel.textColor = .label + addressLabel.numberOfLines = 0 + + menuLabel.font = .body3 + menuLabel.textColor = .gray700 + menuLabel.numberOfLines = 0 + + textStackView.axis = .vertical + textStackView.alignment = .leading + textStackView.spacing = 6 + textStackView.addArrangedSubview(storeNameLabel) + textStackView.addArrangedSubview(categoryStackView) + textStackView.setCustomSpacing(16, after: categoryStackView) + textStackView.addArrangedSubview(addressLabel) + textStackView.addArrangedSubview(menuLabel) + + storeImageView.contentMode = .scaleAspectFill + storeImageView.clipsToBounds = true + storeImageView.layer.cornerRadius = 8 + storeImageView.backgroundColor = .gray300 + + view.addSubviews(textStackView, storeImageView) + } + + override func setLayout() { + storeImageView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).offset(20) + $0.trailing.equalToSuperview().inset(Layout.horizontalInset) + $0.width.height.equalTo(Layout.imageSize) + } + + textStackView.snp.makeConstraints { + $0.top.equalTo(view.safeAreaLayoutGuide).offset(20) + $0.leading.equalToSuperview().inset(Layout.horizontalInset) + $0.trailing.equalTo(storeImageView.snp.leading).offset(-16) + } + } + + // MARK: - Life Cycle + + override func viewDidLoad() { + super.viewDidLoad() + configureBasicInfo() + fetchDetail() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + logScreenView(screenID: FirebaseScreenID.Map.map4) + } + + /// 시트가 화면에 붙어 실제 safe area가 확정되면 detent 높이 재계산 + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + if #available(iOS 16.0, *) { + sheetPresentationController?.invalidateDetents() + } + } + + // MARK: - Data + + /// 목록 API에 있는 정보(업소명/업종)는 상세 응답 전에 먼저 표시 + private func configureBasicInfo() { + storeNameLabel.text = store.storeName + categoryIconImageView.image = MainMapViewController.goodPriceIcon(for: store.category) + categoryTextLabel.text = GoodPriceCategory(serverValue: store.category)?.title ?? store.category + } + + private func fetchDetail() { + NetworkService.shared.request( + GoodPriceStoreRouter.getStoreDetail(id: store.id), + responseType: GoodPriceStoreDetailDTO.self, + useAuth: false + ) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let detail): + self.applyDetail(detail) + case .failure(let error): + print("착한가격업소 상세 조회 실패: \(error.localizedDescription)") + // 시트가 지도 위에 떠 있으므로 토스트는 시트 자신의 view에 띄운다 + self.showToast(message: TextLiteral.Map.storeLoadFailed, type: .danger) + } + } + } + + private func applyDetail(_ detail: GoodPriceStoreDetailDTO) { + storeNameLabel.text = detail.storeName + addressLabel.text = detail.roadAddress + addressLabel.isHidden = (detail.roadAddress ?? "").isEmpty + + menuLabel.text = Self.menuText(menu: detail.mainMenu, price: detail.price) + menuLabel.isHidden = menuLabel.text == nil + + if let urlString = detail.imageUrl, let url = URL(string: urlString) { + storeImageView.kf.setImage(with: url) + } + + if #available(iOS 16.0, *) { + sheetPresentationController?.animateChanges { + self.sheetPresentationController?.invalidateDetents() + } + } + } + + /// "메뉴명 가격원" 형태로 조합. 둘 다 없으면 nil + private static func menuText(menu: String?, price: Int?) -> String? { + let menuPart = menu?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let pricePart: String + if let price { + pricePart = TextLiteral.Map.priceWon(price.formattedWithCommas) + } else { + pricePart = "" + } + + let joined = [menuPart, pricePart].filter { !$0.isEmpty }.joined(separator: " ") + return joined.isEmpty ? nil : joined + } + + // MARK: - Height + + /// 텍스트/이미지 중 더 큰 쪽 기준으로 시트 높이 계산 + func calculatePreferredHeight() -> CGFloat { + view.layoutIfNeeded() + let contentBottom = max(textStackView.frame.maxY, storeImageView.frame.maxY) + return contentBottom + Layout.bottomPadding + view.safeAreaInsets.bottom + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index 23543c18..63714b00 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -13,140 +13,138 @@ import EATSSUDesign // MARK: - Marker Management extension MainMapViewController { - - // MARK: - Display Markers - - /// 제휴점 데이터를 받아 지도에 마커 클러스터링으로 표시 - func displayMarkers(_ partnerships: [PartnershipDTO]) { - removeExistingClusterer() - - let newClusterer = buildClusterer(with: partnerships) - - let markerData = createMarkerData(from: partnerships) - - newClusterer.addAll(markerData) - - attachClustererToMap(newClusterer) - } - - // MARK: - Private Methods - - private func removeExistingClusterer() { + + /// 마커 데이터를 받아 지도에 클러스터링으로 표시 + func displayMarkers(_ items: [MapMarkerItem]) { clusterer?.mapView = nil - } - - /// 클러스터러 생성 및 설정 - private func buildClusterer(with partnerships: [PartnershipDTO]) -> NMCClusterer { - let builder = NMCComplexBuilder() - + + let builder = NMCComplexBuilder() builder.maxScreenDistance = 25 builder.distanceStrategy = NMCDefaultDistanceStrategy() builder.thresholdStrategy = NMCDefaultThresholdStrategy(threshold: 25) - - builder.leafMarkerUpdater = createLeafMarkerUpdater(with: partnerships) - builder.clusterMarkerUpdater = createClusterMarkerUpdater() - - return builder.build() - } - - /// Leaf 마커 업데이터 생성 - private func createLeafMarkerUpdater(with partnerships: [PartnershipDTO]) -> PartnershipLeafMarkerUpdater { - let leafMarkerUpdater = PartnershipLeafMarkerUpdater() - leafMarkerUpdater.partnerships = partnerships - leafMarkerUpdater.currentMapMode = currentMapMode - - leafMarkerUpdater.onMarkerTap = { [weak self] partnership in - self?.showPartnershipDetail(for: partnership) - } - return leafMarkerUpdater - } - - /// 클러스터 마커 업데이터 생성 - private func createClusterMarkerUpdater() -> PartnershipClusterMarkerUpdater { - let clusterMarkerUpdater = PartnershipClusterMarkerUpdater() - clusterMarkerUpdater.viewController = self - return clusterMarkerUpdater - } - - /// 제휴점 데이터를 마커 키-값 딕셔너리로 변환 - private func createMarkerData(from partnerships: [PartnershipDTO]) -> [AnyHashable: NSObject] { + let leafUpdater = MapLeafMarkerUpdater() + leafUpdater.items = items + builder.leafMarkerUpdater = leafUpdater + + let clusterUpdater = MapClusterMarkerUpdater() + clusterUpdater.viewController = self + builder.clusterMarkerUpdater = clusterUpdater + + let newClusterer = builder.build() + var markerData: [AnyHashable: NSObject] = [:] - - for (index, partnership) in partnerships.enumerated() { - let key = PartnershipMarkerKey( + for (index, item) in items.enumerated() { + let key = MapMarkerKey( identifier: index, - position: NMGLatLng(lat: partnership.latitude, lng: partnership.longitude) + position: NMGLatLng(lat: item.latitude, lng: item.longitude) ) markerData[key] = NSNull() } - - return markerData - } - - /// 클러스터러를 지도에 연결 - private func attachClustererToMap(_ newClusterer: NMCClusterer) { - self.clusterer = newClusterer + newClusterer.addAll(markerData) + + clusterer = newClusterer clusterer?.mapView = root.mapView.mapView } - + + // MARK: - Marker Items + + func makeMarkerItem(for partnership: PartnershipDTO) -> 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) } + ) + } + + func makeMarkerItem(for store: GoodPriceStoreDTO) -> MapMarkerItem { + MapMarkerItem( + title: store.storeName, + latitude: store.latitude, + longitude: store.longitude, + icon: Self.goodPriceIcon(for: store.category), + onTap: { [weak self] in self?.showGoodPriceDetail(for: store) } + ) + } + + /// 제휴 업종별 아이콘 (축제 여부로 분기) + static func partnershipIcon(for type: String, isFestival: Bool) -> UIImage { + if isFestival { + switch type { + case "CAFE": return EATSSUDesignAsset.Images.festivalCafePin.image + case "PUB": return EATSSUDesignAsset.Images.festivalPubPin.image + default: return EATSSUDesignAsset.Images.festivalRestaurantPin.image + } + } else { + switch type { + case "CAFE": return EATSSUDesignAsset.Images.cafePin.image + case "PUB": return EATSSUDesignAsset.Images.pubPin.image + default: return EATSSUDesignAsset.Images.restaurantPin.image + } + } + } + + /// 착한가격 업종별 아이콘: 베이커리는 전용, 기타는 카페, 나머지는 수저 + static func goodPriceIcon(for category: String) -> UIImage { + switch GoodPriceCategory(serverValue: category) { + case .bakery: return EATSSUDesignAsset.Images.bakeryPin.image + case .etc: return EATSSUDesignAsset.Images.cafePin.image + default: return EATSSUDesignAsset.Images.restaurantPin.image + } + } + // MARK: - Cluster Image - - /// 클러스터 마커용 원형 이미지 생성 (개수 표시) + + /// 클러스터 마커용 원형 이미지 생성 (개수 표시). 줌/팬마다 재호출되므로 (색상, 개수)별로 캐시 func makeClusterImage(count: Int) -> UIImage { + let color = clusterColor + let cacheKey = "\(color.hashValue)-\(count)" + if let cached = Self.clusterImageCache[cacheKey] { + return cached + } + let size = CGSize(width: 40, height: 40) let renderer = UIGraphicsImageRenderer(size: size) - - return renderer.image { context in - drawCircle(in: context, size: size) - drawCountText(in: context, count: count, size: size) + + let image = renderer.image { context in + color.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .center + let attributes: [NSAttributedString.Key: Any] = [ + .font: EATSSUDesignFontFamily.Pretendard.bold.font(size: 14), + .foregroundColor: UIColor.white, + .paragraphStyle: paragraphStyle + ] + let text = NSAttributedString(string: "\(count)", attributes: attributes) + let textSize = text.size() + text.draw(in: CGRect( + x: (size.width - textSize.width) / 2, + y: (size.height - textSize.height) / 2, + width: textSize.width, + height: textSize.height + )) } + + Self.clusterImageCache[cacheKey] = image + return image } - - // MARK: - Private Helpers - - /// 원형 배경 그리기 (현재 모드에 따라 색상 분기) - private func drawCircle(in context: UIGraphicsImageRendererContext, size: CGSize) { - let circleRect = CGRect(origin: .zero, size: size) - let fillColor: UIColor = (currentMapMode == .festival) ? .festivalPrimary : .primary - fillColor.setFill() - context.cgContext.fillEllipse(in: circleRect) - } - - /// 중앙에 개수 텍스트 그리기 - private func drawCountText(in context: UIGraphicsImageRendererContext, count: Int, size: CGSize) { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.alignment = .center - - let attributes: [NSAttributedString.Key: Any] = [ - .font: EATSSUDesignFontFamily.Pretendard.bold.font(size: 14), - .foregroundColor: UIColor.white, - .paragraphStyle: paragraphStyle - ] - - let text = "\(count)" - let attributedString = NSAttributedString(string: text, attributes: attributes) - let textSize = attributedString.size() - let textRect = CGRect( - x: (size.width - textSize.width) / 2, - y: (size.height - textSize.height) / 2, - width: textSize.width, - height: textSize.height - ) - - attributedString.draw(in: textRect) - } + + private static var clusterImageCache: [String: UIImage] = [:] } -// MARK: - Marker Tap Handler +// MARK: - Detail Sheets extension MainMapViewController { - - /// 마커 탭 델리게이트 설정 + func setupMarkerTapHandler() { root.mapView.mapView.touchDelegate = self } - + /// 제휴점 상세 바텀시트 표시 func showPartnershipDetail(for partnership: PartnershipDTO) { MapAnalyticsManager.shared.logClickPartnerRestaurant( @@ -156,37 +154,41 @@ extension MainMapViewController { ) let detailVC = PartnershipDetailSheetViewController(partnership: partnership) - - // 뷰가 로드된 후 높이 계산 detailVC.loadViewIfNeeded() - - if let sheet = detailVC.sheetPresentationController { - // iOS 16.0 이상에서만 custom detent 사용 - // 표시 후 safe area 확정 시 invalidateDetents()로 클로저가 재실행되어 높이가 보정됨 + presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) + } + + /// 착한가격업소 상세 바텀시트 표시 + func showGoodPriceDetail(for store: GoodPriceStoreDTO) { + MapAnalyticsManager.shared.logClickGoodPriceStore(storeId: store.id) + + let detailVC = GoodPriceDetailSheetViewController(store: store) + detailVC.loadViewIfNeeded() + presentSheet(detailVC, heightProvider: { [weak detailVC] in detailVC?.calculatePreferredHeight() }) + } + + /// 커스텀 detent 시트 공통 표시. 표시 후 safe area 확정 시 invalidateDetents()로 높이가 보정됨 + private func presentSheet(_ viewController: UIViewController, heightProvider: @escaping () -> CGFloat?) { + if let sheet = viewController.sheetPresentationController { if #available(iOS 16.0, *) { - let customDetent = UISheetPresentationController.Detent.custom { [weak detailVC] _ in - detailVC?.calculatePreferredHeight() - } + let customDetent = UISheetPresentationController.Detent.custom { _ in heightProvider() } sheet.detents = [customDetent, .large()] } else { - // iOS 16.0 미만에서는 medium detent 사용 sheet.detents = [.medium(), .large()] } - sheet.prefersGrabberVisible = true } - - present(detailVC, animated: true) + present(viewController, animated: true) } } // MARK: - NMFMapViewTouchDelegate extension MainMapViewController: NMFMapViewTouchDelegate { - + func mapView(_ mapView: NMFMapView, didTapMap latlng: NMGLatLng, point: CGPoint) { } - + func mapView(_ mapView: NMFMapView, didTap symbol: NMFSymbol) -> Bool { return false } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index d877f66b..e2fdb770 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -7,50 +7,74 @@ import Foundation -// MARK: - Network Requests +// MARK: - Partnership Requests extension MainMapViewController { - /// 전체 제휴 데이터를 받아 캐시에 저장하고, 현재 모드에 맞춰 마커를 표시 + /// 전체 제휴 데이터를 받아 캐시에 저장하고 축제 제휴 마커를 표시 func refreshAllPartnerships() { + let generation = beginLoad() NetworkService.shared.request( PartnershipRouter.getAllPartnerships, responseType: [PartnershipDTO].self, useAuth: true ) { [weak self] result in - guard let self = self else { return } + guard let self, self.isCurrentLoad(generation) else { return } switch result { case .success(let partnerships): self.cachedAllPartnerships = partnerships - self.applyCachedMarkers() + self.applyPartnershipMarkers(from: partnerships, periodType: .festival) case .failure(let error): print("제휴 조회 실패: \(error.localizedDescription)") self.cachedAllPartnerships = [] self.displayMarkers([]) + self.showStoreLoadFailedToast() } #if DEBUG // 서버에 제휴 데이터가 없는 동안 Mock으로 대체 (DEBUG 빌드 전용) if self.cachedAllPartnerships.isEmpty { self.cachedAllPartnerships = PartnershipMockData.samples - self.applyCachedMarkers() + self.applyPartnershipMarkers(from: self.cachedAllPartnerships, periodType: .festival) } #endif } } - /// 캐시된 데이터에서 현재 모드의 periodType만 필터해 마커 표시 - /// myOnly 모드는 별도 API를 쓰므로 여기서 처리하지 않음 - func applyCachedMarkers() { - let periodType: PartnershipPeriodType - switch currentMapMode { - case .festival: periodType = .festival - case .all: periodType = .normal - case .myOnly: return + /// 내 학과 제휴를 받아 현재 업종 필터에 맞춰 마커 표시 + func fetchMyPartnerships() { + guard hasDepartment else { + displayMarkers([]) + return + } + + let generation = beginLoad() + NetworkService.shared.request( + MyRouter.getMyPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { [weak self] result in + guard let self, self.isCurrentLoad(generation) else { return } + switch result { + case .success(let partnerships): + self.applyPartnershipMarkers(from: partnerships, periodType: .normal) + + case .failure(let error): + print("내 제휴 조회 실패: \(error.localizedDescription)") + self.displayMarkers([]) + self.showStoreLoadFailedToast() + } + } + } + + /// periodType + 현재 업종 필터로 걸러서 마커 표시 + private func applyPartnershipMarkers(from partnerships: [PartnershipDTO], periodType: PartnershipPeriodType) { + var filtered = Self.filterPartnerships(partnerships, by: periodType) + if let type = partnershipFilter.restaurantType { + filtered = filtered.filter { $0.restaurantType == type } } - let filtered = Self.filterPartnerships(cachedAllPartnerships, by: periodType) - displayMarkers(filtered) + displayMarkers(filtered.map { makeMarkerItem(for: $0) }) } private static func filterPartnerships( @@ -71,7 +95,7 @@ extension MainMapViewController { ) } } - + func fetchDepartmentAndUpdateButton(completion: (() -> Void)? = nil) { NetworkService.shared.request( MyRouter.getDepartment, @@ -82,14 +106,10 @@ extension MainMapViewController { switch result { case .success(let department): - let departmentName = department.departmentName - self.currentDepartmentName = departmentName + self.currentDepartmentName = department.departmentName self.currentDepartmentId = department.departmentId self.currentCollegeId = department.collegeId - let buttonTitle = departmentName.isEmpty ? TextLiteral.Map.myPartner : departmentName - self.root.myOnlyButton.setTitle(buttonTitle, for: .normal) - // Realm 데이터도 함께 동기화하여 서버-클라이언트 불일치 방지 if let userInfo = UserInfoManager.shared.getCurrentUserInfo() { UserInfoManager.shared.updateDepartment( @@ -106,9 +126,7 @@ extension MainMapViewController { self.currentDepartmentName = nil self.currentDepartmentId = nil self.currentCollegeId = nil - self.root.myOnlyButton.setTitle(TextLiteral.Map.myPartner, for: .normal) - // Realm 데이터도 함께 동기화하여 서버-클라이언트 불일치 방지 if let userInfo = UserInfoManager.shared.getCurrentUserInfo() { UserInfoManager.shared.updateDepartment( for: userInfo, @@ -123,30 +141,48 @@ extension MainMapViewController { completion?() } } - - func fetchMyPartnerships() { - // 학과 정보가 없으면 API 호출하지 않음 (서버 에러 방지) - guard let departmentName = currentDepartmentName, - !departmentName.isEmpty else { - print("학과 정보가 없어 학과별 제휴 조회를 건너뜁니다") - displayMarkers([]) +} + +// MARK: - Good Price Store Requests + +extension MainMapViewController { + + /// 착한가격업소 마커 로드. 캐시가 있으면 카테고리만 필터, 없으면 전체 목록을 받아온다 + func loadGoodPriceMarkers() { + if !cachedGoodPriceStores.isEmpty { + _ = beginLoad() + applyGoodPriceMarkers() return } + let generation = beginLoad() NetworkService.shared.request( - MyRouter.getMyPartnerships, - responseType: [PartnershipDTO].self, - useAuth: true + GoodPriceStoreRouter.getStores, + responseType: [GoodPriceStoreDTO].self, + useAuth: false ) { [weak self] result in + guard let self, self.isCurrentLoad(generation) else { return } switch result { - case .success(let partnerships): - let filtered = Self.filterPartnerships(partnerships, by: .normal) - self?.displayMarkers(filtered) + case .success(let stores): + self.cachedGoodPriceStores = stores + self.applyGoodPriceMarkers() case .failure(let error): - print("내 제휴 조회 실패: \(error.localizedDescription)") - self?.displayMarkers([]) + print("착한가격업소 조회 실패: \(error.localizedDescription)") + self.cachedGoodPriceStores = [] + self.displayMarkers([]) + self.showStoreLoadFailedToast() } } } + + private func applyGoodPriceMarkers() { + let filtered: [GoodPriceStoreDTO] + if let serverValue = goodPriceCategory.serverValue { + filtered = cachedGoodPriceStores.filter { $0.category == serverValue } + } else { + filtered = cachedGoodPriceStores + } + displayMarkers(filtered.map { makeMarkerItem(for: $0) }) + } } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 066d5a0a..f7299c5e 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -11,7 +11,6 @@ import CoreLocation import NMapsMap import Moya - import EATSSUDesign final class MainMapViewController: BaseViewController { @@ -27,8 +26,17 @@ final class MainMapViewController: BaseViewController { static let animationDuration: TimeInterval = 0.3 } + /// 화면 진입 형태 + enum Mode { + /// 탭바 안의 지도 탭: 상단에 학교 제휴 / 착한 가격 탭 노출 + case tabbed + /// 로그인 화면에서 바로 진입하는 착한가격업소 지도 (탭 없음) + case standaloneGoodPrice + } + // MARK: - Properties + let mode: Mode let root = MainMapView() let locationManager = CLLocationManager() var currentDepartmentName: String? @@ -36,19 +44,61 @@ final class MainMapViewController: BaseViewController { var currentCollegeId: Int? var hasRequestedLocationPermission = false - var clusterer: NMCClusterer? + var clusterer: NMCClusterer? - /// 가장 최근에 받아온 전체 제휴 목록 (모드 전환 시 필터링용 캐시) + /// 가장 최근에 받아온 전체 제휴 목록 (축제 필터용 캐시) var cachedAllPartnerships: [PartnershipDTO] = [] + /// 착한가격업소 전체 목록 캐시 (카테고리 필터링용) + var cachedGoodPriceStores: [GoodPriceStoreDTO] = [] + + /// 탭/필터가 바뀔 때마다 증가. 늦게 도착한 응답이 현재 화면을 덮어쓰지 않도록 완료 시점에 비교 + private(set) var loadGeneration = 0 + + /// 새 로드 시작을 알리고 해당 로드의 세대 번호를 반환 + func beginLoad() -> Int { + loadGeneration += 1 + return loadGeneration + } + + /// 해당 세대의 응답이 아직 유효한지 + func isCurrentLoad(_ generation: Int) -> Bool { + generation == loadGeneration + } + + // MARK: - State + + private(set) var currentTab: MapTab + private(set) var partnershipFilter: PartnershipFilter = .all + private(set) var goodPriceCategory: GoodPriceCategory = .all + + /// 현재 노출 중인 학교 제휴 필터 목록 (축제 활성 여부에 따라 달라짐) + private var visiblePartnershipFilters: [PartnershipFilter] { + let festivalEnabled = FirebaseRemoteConfig.shared.isFestivalEnabled + return PartnershipFilter.allCases.filter { $0 != .festival || festivalEnabled } + } + + /// 클러스터 색상: 축제 필터일 때만 축제 색 + var clusterColor: UIColor { + (currentTab == .partnership && partnershipFilter == .festival) ? .festivalPrimary : .primary + } + + // MARK: - Init - // MARK: - Map Mode Management + init(mode: Mode = .tabbed) { + self.mode = mode + self.currentTab = (mode == .standaloneGoodPrice) ? .goodPrice : .partnership + super.init(nibName: nil, bundle: nil) + } - var currentMapMode: MapMode = .festival + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } // MARK: - View Setup - + override func configureUI() { view.addSubview(root) + root.setTopTabVisible(mode == .tabbed) } override func setLayout() { @@ -56,9 +106,13 @@ final class MainMapViewController: BaseViewController { } override func setButtonEvent() { - root.festivalButton.addTarget(self, action: #selector(didTapFestival), for: .touchUpInside) - root.wholeButton.addTarget(self, action: #selector(didTapWhole), for: .touchUpInside) - root.myOnlyButton.addTarget(self, action: #selector(didTapMyOnly), for: .touchUpInside) + root.topTabView.onSelect = { [weak self] index in + guard let tab = MapTab(rawValue: index) else { return } + self?.switchTab(to: tab) + } + root.filterChipBar.onSelect = { [weak self] index in + self?.didSelectFilter(at: index) + } } // MARK: - Life Cycle @@ -72,8 +126,9 @@ final class MainMapViewController: BaseViewController { setInitialCameraPosition(animated: false) setupLocationButtonObserver() setupMarkerTapHandler() + applyTabUI() } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) logScreenView(screenID: FirebaseScreenID.Map.map1) @@ -82,36 +137,32 @@ final class MainMapViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // Remote Config에 따라 축제 탭 노출 여부 결정 - let festivalEnabled = FirebaseRemoteConfig.shared.isFestivalEnabled - root.setFestivalVisible(festivalEnabled) + switch currentTab { + case .partnership: + refreshPartnershipTab() + case .goodPrice: + loadGoodPriceMarkers() + } + } + /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드. 축제 노출 여부가 바뀌었을 수 있어 칩도 재구성 + private func refreshPartnershipTab() { + applyTabUI() + let generation = beginLoad() fetchDepartmentAndUpdateButton { [weak self] in - guard let self = self else { return } - - if festivalEnabled { - self.currentMapMode = .festival - self.root.select(.festival) - self.refreshAllPartnerships() - } else if let departmentName = self.currentDepartmentName, !departmentName.isEmpty { - self.currentMapMode = .myOnly - self.root.select(.myOnly) - self.fetchMyPartnerships() - } else { - self.currentMapMode = .all - self.root.select(.all) - self.refreshAllPartnerships() - } + guard let self, self.isCurrentLoad(generation) else { return } + self.loadPartnershipMarkers() } } // MARK: - Configuration - + private func configureNavigationBar() { - title = TextLiteral.Map.map + title = (mode == .standaloneGoodPrice) ? TextLiteral.Map.goodPriceMapTitle : TextLiteral.Map.map let navBarAppearance = UINavigationBarAppearance() navBarAppearance.configureWithOpaqueBackground() navBarAppearance.backgroundColor = .white + navBarAppearance.shadowColor = .clear navBarAppearance.titleTextAttributes = [ .foregroundColor: UIColor.black, .font: EATSSUDesignFontFamily.Pretendard.bold.font(size: 16) @@ -119,93 +170,146 @@ final class MainMapViewController: BaseViewController { navigationController?.navigationBar.standardAppearance = navBarAppearance navigationController?.navigationBar.scrollEdgeAppearance = navBarAppearance navigationController?.navigationBar.compactAppearance = navBarAppearance + navigationController?.navigationBar.tintColor = .black + navigationItem.backButtonDisplayMode = .minimal + + // 모달로 띄워진 단독 화면이면 닫기 버튼 제공 + if mode == .standaloneGoodPrice, + presentingViewController != nil, + navigationController?.viewControllers.first === self { + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: EATSSUDesignAsset.Images.icClose.image.resize(newWidth: 24), + style: .plain, + target: self, + action: #selector(didTapClose) + ) + } } - // MARK: - Action Methods - - @objc private func didTapFestival() { - guard currentMapMode != .festival else { return } + @objc private func didTapClose() { + dismiss(animated: true) + } - currentMapMode = .festival - setInitialCameraPosition(animated: true) - root.select(.festival) - MapAnalyticsManager.shared.logClickMapFestival( - collegeId: currentCollegeId, - majorId: currentDepartmentId - ) - refreshOrApplyCachedMarkers() + /// 현재 탭에 맞춰 필터 칩과 선택 상태를 갱신 + private func applyTabUI() { + root.topTabView.select(index: currentTab.rawValue, animated: false) + + switch currentTab { + case .partnership: + let filters = visiblePartnershipFilters + if !filters.contains(partnershipFilter) { partnershipFilter = .all } + root.filterChipBar.highlightColor = clusterColor + root.filterChipBar.configure( + titles: filters.map { $0.title }, + selectedIndex: filters.firstIndex(of: partnershipFilter) ?? 0 + ) + case .goodPrice: + root.filterChipBar.highlightColor = .primary + root.filterChipBar.configure( + titles: GoodPriceCategory.allCases.map { $0.title }, + selectedIndex: GoodPriceCategory.allCases.firstIndex(of: goodPriceCategory) ?? 0 + ) + } } - @objc private func didTapWhole() { - guard currentMapMode != .all else { return } + // MARK: - Tab & Filter Actions - currentMapMode = .all + private func switchTab(to tab: MapTab) { + guard currentTab != tab else { return } + currentTab = tab setInitialCameraPosition(animated: true) - root.select(.all) - MapAnalyticsManager.shared.logClickMapAll( - collegeId: currentCollegeId, - majorId: currentDepartmentId - ) - refreshOrApplyCachedMarkers() + + switch tab { + case .partnership: + refreshPartnershipTab() + case .goodPrice: + applyTabUI() + MapAnalyticsManager.shared.logClickMapGoodPrice( + collegeId: currentCollegeId, + majorId: currentDepartmentId + ) + loadGoodPriceMarkers() + } } - /// 캐시가 비어있으면(내 제휴 모드로 시작해 전체 데이터를 아직 안 받은 경우) 서버에서 받아오고, 있으면 캐시로 필터링 - private func refreshOrApplyCachedMarkers() { - if cachedAllPartnerships.isEmpty { - refreshAllPartnerships() - } else { - applyCachedMarkers() + private func didSelectFilter(at index: Int) { + switch currentTab { + case .partnership: + let filters = visiblePartnershipFilters + guard filters.indices.contains(index) else { return } + partnershipFilter = filters[index] + root.filterChipBar.highlightColor = clusterColor + logPartnershipFilterClick(partnershipFilter) + loadPartnershipMarkers() + + case .goodPrice: + let categories = GoodPriceCategory.allCases + guard categories.indices.contains(index) else { return } + goodPriceCategory = categories[index] + MapAnalyticsManager.shared.logClickGoodPriceCategory(category: goodPriceCategory) + loadGoodPriceMarkers() } } - @objc private func didTapMyOnly() { - guard !(currentDepartmentName?.isEmpty ?? true) else { - presentNoDepartmentSheet() - return + /// 축제 → click_map_festival, 그 외(내 학과 제휴 기준) → 학과 있으면 click_map_mine, 없으면 click_map_all + private func logPartnershipFilterClick(_ filter: PartnershipFilter) { + switch filter { + case .festival: + MapAnalyticsManager.shared.logClickMapFestival( + collegeId: currentCollegeId, + 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 + ) + } } + } - // 이미 학과별 모드면 return - guard currentMapMode != .myOnly else { return } + // MARK: - Partnership Tab - currentMapMode = .myOnly + /// 학교 제휴 탭 마커 로드. 학과 미입력이면 학과 입력 시트를 띄우고 마커는 비움 + func loadPartnershipMarkers() { + if partnershipFilter == .festival { + refreshAllPartnerships() + return + } - if let collegeId = currentCollegeId, let majorId = currentDepartmentId { - MapAnalyticsManager.shared.logClickMapMine(collegeId: collegeId, majorId: majorId) + guard hasDepartment else { + displayMarkers([]) + presentNoDepartmentSheetIfNeeded() + return } - setInitialCameraPosition(animated: true) - root.select(.myOnly) fetchMyPartnerships() } - private func presentNoDepartmentSheet() { - let sheetVC = NoDepartmentSheetViewController() - present(sheetVC, animated: true) + var hasDepartment: Bool { + !(currentDepartmentName?.isEmpty ?? true) + } + + private func presentNoDepartmentSheetIfNeeded() { + guard mode == .tabbed, currentTab == .partnership, presentedViewController == nil else { return } + present(NoDepartmentSheetViewController(), animated: true) } // MARK: - Helper Methods - + + /// 탭바에서 지도 탭을 다시 눌렀을 때 현재 탭 데이터 갱신 func reloadContent() { - // 학과 정보를 다시 불러온 후, 현재 모드에 맞게 데이터 갱신 - fetchDepartmentAndUpdateButton { [weak self] in - guard let self = self else { return } - switch self.currentMapMode { - case .festival, .all: - self.refreshAllPartnerships() - case .myOnly: - // 학과가 없어졌다면 축제 활성 여부에 따라 폴백 - if self.currentDepartmentName?.isEmpty ?? true { - if FirebaseRemoteConfig.shared.isFestivalEnabled { - self.didTapFestival() - } else { - self.didTapWhole() - } - } else { - self.fetchMyPartnerships() - } - } + switch currentTab { + case .partnership: + refreshPartnershipTab() + case .goodPrice: + cachedGoodPriceStores = [] + loadGoodPriceMarkers() } } - + func setInitialCameraPosition(animated: Bool) { let cameraUpdate = NMFCameraUpdate( scrollTo: NMGLatLng( @@ -222,5 +326,9 @@ final class MainMapViewController: BaseViewController { root.mapView.mapView.moveCamera(cameraUpdate) } - + + /// 업소 정보 로드 실패 토스트 + func showStoreLoadFailedToast() { + showToast(message: TextLiteral.Map.storeLoadFailed, type: .danger) + } } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift new file mode 100644 index 00000000..d23f9d9a --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift @@ -0,0 +1,27 @@ +// +// MapClusterMarkerUpdater.swift +// EATSSU +// +// Created by 황상환 on 10/4/25. +// + +import UIKit +import NMapsMap + +/// 클러스터 마커를 개수 표시 원형 이미지로 그리는 클래스 +final class MapClusterMarkerUpdater: NMCDefaultClusterMarkerUpdater { + + weak var viewController: MainMapViewController? + + override func updateClusterMarker(_ info: NMCClusterMarkerInfo, _ marker: NMFMarker) { + super.updateClusterMarker(info, marker) + + marker.captionText = "" + + guard let image = viewController?.makeClusterImage(count: info.size) else { return } + + marker.iconImage = NMFOverlayImage(image: image) + marker.width = 40 + marker.height = 40 + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift new file mode 100644 index 00000000..5880fbf1 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift @@ -0,0 +1,46 @@ +// +// MapLeafMarkerUpdater.swift +// EATSSU +// +// Created by 황상환 on 10/4/25. +// + +import UIKit +import NMapsMap + +/// 개별 마커(Leaf Marker)의 아이콘/탭 핸들러를 설정하는 클래스 +final class MapLeafMarkerUpdater: NMCDefaultLeafMarkerUpdater { + + var items: [MapMarkerItem] = [] + + /// 줌/팬으로 같은 마커가 반복 노출될 때 재래스터화를 피하기 위한 캐시 (identifier 기준) + private var imageCache: [Int: UIImage] = [:] + + override func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) { + super.updateLeafMarker(info, marker) + + guard let key = info.key as? MapMarkerKey, + items.indices.contains(key.identifier) else { return } + let item = items[key.identifier] + + let iconImage: UIImage + if let cached = imageCache[key.identifier] { + iconImage = cached + } else { + let markerView = MapMarkerView(icon: item.icon, title: item.title) + markerView.layoutIfNeeded() + markerView.frame = CGRect(origin: .zero, size: markerView.intrinsicContentSize) + iconImage = markerView.toImage() + imageCache[key.identifier] = iconImage + } + + marker.iconImage = NMFOverlayImage(image: iconImage) + marker.width = iconImage.size.width + marker.height = iconImage.size.height + + marker.touchHandler = { _ -> Bool in + item.onTap() + return true + } + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerItem.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerItem.swift new file mode 100644 index 00000000..ec83f2a0 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerItem.swift @@ -0,0 +1,17 @@ +// +// MapMarkerItem.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import UIKit + +/// 지도 마커 하나를 그리기 위한 공통 데이터 (제휴/착한가격 공용) +struct MapMarkerItem { + let title: String + let latitude: Double + let longitude: Double + let icon: UIImage? + let onTap: () -> Void +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerKey.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerKey.swift new file mode 100644 index 00000000..edd8081d --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerKey.swift @@ -0,0 +1,35 @@ +// +// MapMarkerKey.swift +// EATSSU +// +// Created by 황상환 on 10/4/25. +// + +import Foundation +import NMapsMap + +/// 마커 클러스터링을 위한 고유 키 클래스 +final class MapMarkerKey: NSObject, NMCClusteringKey { + + /// 마커 목록 내 인덱스 + let identifier: Int + let position: NMGLatLng + + init(identifier: Int, position: NMGLatLng) { + self.identifier = identifier + self.position = position + super.init() + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? MapMarkerKey else { return false } + if self === other { return true } + return other.identifier == self.identifier + } + + override var hash: Int { identifier } + + func copy(with zone: NSZone? = nil) -> Any { + MapMarkerKey(identifier: identifier, position: position) + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipClusterMarkerUpdater.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipClusterMarkerUpdater.swift deleted file mode 100644 index 9655b59c..00000000 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipClusterMarkerUpdater.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// PartnershipClusterMarkerUpdater.swift -// EATSSU -// -// Created by 황상환 on 10/4/25. -// - -import UIKit -import NMapsMap -import EATSSUDesign - -// MARK: - PartnershipClusterMarkerUpdater - -/// 클러스터 마커의 스타일을 업데이트하는 클래스 -final class PartnershipClusterMarkerUpdater: NMCDefaultClusterMarkerUpdater { - - // MARK: - Properties - - /// 클러스터 이미지 생성을 위한 ViewController 참조 - weak var viewController: MainMapViewController? - - // MARK: - Override Methods - - /// 클러스터 마커가 지도에 표시될 때 호출되어 원형 이미지로 스타일 설정 - override func updateClusterMarker(_ info: NMCClusterMarkerInfo, _ marker: NMFMarker) { - super.updateClusterMarker(info, marker) - - marker.captionText = "" - - guard let image = viewController?.makeClusterImage(count: info.size) else { return } - - marker.iconImage = NMFOverlayImage(image: image) - marker.width = 40 - marker.height = 40 - } -} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index a19be2f8..b73bea55 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -159,20 +159,13 @@ final class PartnershipDetailSheetViewController: BaseViewController { private func configureData() { storeNameLabel.text = partnership.storeName - switch partnership.restaurantType { - case "RESTAURANT": - typeIconImageView.image = EATSSUDesignAsset.Images.restaurantPin.image - typeTextLabel.text = TextLiteral.Map.restaurant - case "CAFE": - typeIconImageView.image = EATSSUDesignAsset.Images.cafePin.image - typeTextLabel.text = TextLiteral.Map.cafe - case "PUB": - typeIconImageView.image = EATSSUDesignAsset.Images.pubPin.image - typeTextLabel.text = TextLiteral.Map.pub - default: - typeIconImageView.image = EATSSUDesignAsset.Images.restaurantPin.image - typeTextLabel.text = partnership.restaurantType - } + typeIconImageView.image = MainMapViewController.partnershipIcon( + for: partnership.restaurantType, + isFestival: false + ) + typeTextLabel.text = PartnershipFilter.allCases + .first { $0.restaurantType == partnership.restaurantType }? + .title ?? partnership.restaurantType for (index, info) in partnership.partnershipInfos.enumerated() { let isLast = index == partnership.partnershipInfos.count - 1 diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipLeafMarkerUpdater.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipLeafMarkerUpdater.swift deleted file mode 100644 index 057b3404..00000000 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipLeafMarkerUpdater.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// PartnershipLeafMarkerUpdater.swift -// EATSSU -// -// Created by 황상환 on 10/4/25. -// - -import UIKit -import NMapsMap -import EATSSUDesign - -// MARK: - PartnershipLeafMarkerUpdater - -/// 개별 제휴점 마커(Leaf Marker)의 스타일과 데이터를 업데이트하는 클래스 -final class PartnershipLeafMarkerUpdater: NMCDefaultLeafMarkerUpdater { - - // MARK: - Properties - - /// 지도에 표시할 제휴점 데이터 목록 - var partnerships: [PartnershipDTO] = [] - - /// 현재 지도 모드 (마커 아이콘 분기에 사용) - var currentMapMode: MapMode = .all - - /// 마커 탭 시 호출될 클로저 - var onMarkerTap: ((PartnershipDTO) -> Void)? - - // MARK: - Override Methods - - /// 개별 마커가 지도에 표시될 때 호출되어 마커의 아이콘과 캡션을 설정 - override func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) { - super.updateLeafMarker(info, marker) - - guard let key = info.key as? PartnershipMarkerKey else { return } - - guard let partnership = findPartnership(for: key) else { return } - - configureMarkerIcon(marker, with: partnership) - - // 마커 탭 핸들러 설정 - marker.touchHandler = { [weak self] _ -> Bool in - self?.onMarkerTap?(partnership) - return true - } - } - - // MARK: - Private Methods - - /// 마커 키에 해당하는 제휴점 데이터 검색 - private func findPartnership(for key: PartnershipMarkerKey) -> PartnershipDTO? { - guard partnerships.indices.contains(key.identifier) else { - return nil - } - return partnerships[key.identifier] - } - - /// 마커 아이콘 설정 - private func configureMarkerIcon(_ marker: NMFMarker, with partnership: PartnershipDTO) { - let iconImage = makeMarkerImage( - type: partnership.restaurantType, - title: partnership.storeName - ) - marker.iconImage = NMFOverlayImage(image: iconImage) - marker.width = iconImage.size.width - marker.height = iconImage.size.height - } - - /// 제휴점 타입에 따른 마커 이미지 생성 - private func makeMarkerImage(type: String, title: String) -> UIImage { - let icon = iconForRestaurantType(type) - let markerView = MapMarkerView(icon: icon, title: title) - markerView.layoutIfNeeded() - markerView.frame = CGRect(origin: .zero, size: markerView.intrinsicContentSize) - return markerView.toImage() - } - - /// 제휴점 타입에 따른 아이콘 이미지 반환 (모드별로 분기) - private func iconForRestaurantType(_ type: String) -> UIImage? { - if currentMapMode == .festival { - switch type { - case "RESTAURANT": return EATSSUDesignAsset.Images.festivalRestaurantPin.image - case "CAFE": return EATSSUDesignAsset.Images.festivalCafePin.image - case "PUB": return EATSSUDesignAsset.Images.festivalPubPin.image - default: return EATSSUDesignAsset.Images.festivalRestaurantPin.image - } - } else { - switch type { - case "RESTAURANT": return EATSSUDesignAsset.Images.restaurantPin.image - case "CAFE": return EATSSUDesignAsset.Images.cafePin.image - case "PUB": return EATSSUDesignAsset.Images.pubPin.image - default: return EATSSUDesignAsset.Images.restaurantPin.image - } - } - } -} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipMarkerKey.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipMarkerKey.swift deleted file mode 100644 index 0f4a9aa9..00000000 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipMarkerKey.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// PartnershipMarkerKey.swift -// EATSSU -// -// Created by 황상환 on 10/4/25. -// - -import Foundation -import NMapsMap - -// MARK: - PartnershipMarkerKey - -/// 제휴점 마커 클러스터링을 위한 고유 키 클래스 -final class PartnershipMarkerKey: NSObject, NMCClusteringKey { - - // MARK: - Properties - - /// 제휴점 고유 식별자 - let identifier: Int - - /// 제휴점 위치 좌표 - let position: NMGLatLng - - // MARK: - Initialization - - init(identifier: Int, position: NMGLatLng) { - self.identifier = identifier - self.position = position - super.init() - } - - // MARK: - NSObject Override - - /// 두 마커 키의 동일성 비교 - override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? PartnershipMarkerKey else { - return false - } - if self === other { - return true - } - return other.identifier == self.identifier - } - - /// 해시값 반환 (identifier 기반) - override var hash: Int { - return self.identifier - } - - // MARK: - NSCopying - - /// 마커 키 복사 메서드 (NSCopying 프로토콜 준수) - func copy(with zone: NSZone? = nil) -> Any { - return PartnershipMarkerKey(identifier: self.identifier, position: self.position) - } -} diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index d4b164d8..944eeb40 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -273,12 +273,11 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { // 지도 탭 클릭 시 Firebase 이벤트 호출 (로그인된 상태에서만) if selectedTab == .map { let userInfo = UserInfoManager.shared.getCurrentUserInfo() - let defaultType: MapAnalyticsManager.MapDefaultType = - FirebaseRemoteConfig.shared.isFestivalEnabled ? .festival : .general + // 지도는 항상 학교 제휴 > 전체 필터로 진입 (축제는 칩 선택 시에만) MapAnalyticsManager.shared.logClickMap( collegeId: userInfo?.collegeId, majorId: userInfo?.departmentId, - defaultType: defaultType + defaultType: .general ) } diff --git a/EATSSU/App/Sources/Utility/Extension/String+.swift b/EATSSU/App/Sources/Utility/Extension/String+.swift index dce0e050..dd6ad1a8 100644 --- a/EATSSU/App/Sources/Utility/Extension/String+.swift +++ b/EATSSU/App/Sources/Utility/Extension/String+.swift @@ -10,35 +10,22 @@ import Foundation import UIKit extension String { - /// 문자열에서 마지막 단어만 지정한 색상으로 강조한 attributed string을 반환 - /// - Parameters: - /// - baseColor: 전체 문자열에 기본으로 적용할 색상 - /// - highlightColor: 마지막 단어에 적용할 강조 색상 - /// - Returns: 마지막 단어만 강조 색상이 적용된 `NSAttributedString` - func logoHighlightedLastWord( + /// 문자열 중 `highlight` 부분만 강조 색으로 칠한 AttributedString 반환. + /// `highlight`가 포함되어 있지 않으면 전체가 baseColor + func highlighted( + _ highlight: String, baseColor: UIColor, highlightColor: UIColor ) -> NSAttributedString { let attributedString = NSMutableAttributedString( string: self, - attributes: [ - .foregroundColor: baseColor - ] + attributes: [.foregroundColor: baseColor] ) - guard let lastWord = self.split(separator: " ").last else { - return attributedString - } - - let nsString = self as NSString - let range = nsString.range(of: String(lastWord), options: .backwards) - - attributedString.addAttribute( - .foregroundColor, - value: highlightColor, - range: range - ) + let range = (self as NSString).range(of: highlight) + guard range.location != NSNotFound else { return attributedString } + attributedString.addAttribute(.foregroundColor, value: highlightColor, range: range) return attributedString } } diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index 843e9ecd..e7f30aa9 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -47,10 +47,6 @@ enum TextLiteral { // MARK: - Common enum Common { - /// "확인" - static var logoSubTitle: String { - Localization.localized("common.logoSubTitle", fallback: "숭실대에서 먹자") - } /// "확인" static var confirm: String { Localization.localized("common.confirm", fallback: "확인") @@ -164,6 +160,26 @@ enum TextLiteral { // MARK: - Auth enum Auth { + /// LoginView - "숭실대에서 서울 곳곳까지" + static var loginSubTitle: String { + Localization.localized("auth.loginSubTitle", fallback: "숭실대에서 서울 곳곳까지") + } + + /// LoginView - 서브타이틀 중 강조할 부분 "서울 곳곳까지" + static var loginSubTitleHighlight: String { + Localization.localized("auth.loginSubTitleHighlight", fallback: "서울 곳곳까지") + } + + /// LoginView - "착한가격업소 둘러보기" + static var goodPriceEntryTitle: String { + Localization.localized("auth.goodPriceEntryTitle", fallback: "착한가격업소 둘러보기") + } + + /// LoginView - "내 주변 착한 한 끼를 찾아보세요" + static var goodPriceEntrySubTitle: String { + Localization.localized("auth.goodPriceEntrySubTitle", fallback: "내 주변 착한 한 끼를 찾아보세요") + } + /// "닉네임을 입력해주세요" static var inputNickName: String { Localization.localized("auth.inputNickName", fallback: "닉네임을 입력해주세요") @@ -445,9 +461,64 @@ enum TextLiteral { // MARK: - Map enum Map { - /// MainMapVC - "제휴 지도" + /// MainMapVC - "지도" static var map: String { - Localization.localized("map.map", fallback: "제휴 지도") + Localization.localized("map.map", fallback: "지도") + } + + /// 착한가격 단독 진입 화면 타이틀 - "착한가격업소 지도" + static var goodPriceMapTitle: String { + Localization.localized("map.goodPriceMapTitle", fallback: "착한가격업소 지도") + } + + /// 상단 탭 - "학교 제휴" + static var schoolPartnershipTab: String { + Localization.localized("map.schoolPartnershipTab", fallback: "학교 제휴") + } + + /// 상단 탭 - "착한 가격" + static var goodPriceTab: String { + Localization.localized("map.goodPriceTab", fallback: "착한 가격") + } + + /// 착한가격 필터 - "한식" + static var korean: String { + Localization.localized("map.korean", fallback: "한식") + } + + /// 착한가격 필터 - "일식" + static var japanese: String { + Localization.localized("map.japanese", fallback: "일식") + } + + /// 착한가격 필터 - "양식" + static var western: String { + Localization.localized("map.western", fallback: "양식") + } + + /// 착한가격 필터 - "중식" + static var chinese: String { + Localization.localized("map.chinese", fallback: "중식") + } + + /// 착한가격 필터 - "베이커리" + static var bakery: String { + Localization.localized("map.bakery", fallback: "베이커리") + } + + /// 착한가격 필터 - "기타" + static var etc: String { + Localization.localized("map.etc", fallback: "기타") + } + + /// 업소 조회 실패 토스트 - "업소 정보를 불러오지 못했어요." + static var storeLoadFailed: String { + Localization.localized("map.storeLoadFailed", fallback: "업소 정보를 불러오지 못했어요.") + } + + /// 착한가격 바텀시트 가격 포맷 - "%@원" + static func priceWon(_ price: String) -> String { + Localization.formatted("map.priceWon", fallback: "%@원", price) } /// MainMapView - "전체" diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/Contents.json new file mode 100644 index 00000000..007148d2 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SeoulClubOnLogo.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/SeoulClubOnLogo.imageset/SeoulClubOnLogo.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/SeoulClubOnLogo.png new file mode 100644 index 00000000..a3bd271d Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/SeoulClubOnLogo.png differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/Contents.json new file mode 100644 index 00000000..82a51fc6 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SeoulMySoulLogo.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/SeoulMySoulLogo.imageset/SeoulMySoulLogo.png b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/SeoulMySoulLogo.png new file mode 100644 index 00000000..9267061e Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/SeoulMySoulLogo.png differ diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/Contents.json b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/Contents.json new file mode 100644 index 00000000..cd45b306 --- /dev/null +++ b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "bakery_pin.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/bakery_pin.pdf b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/bakery_pin.pdf new file mode 100644 index 00000000..295d31e2 Binary files /dev/null and b/EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/bakery_pin.pdf differ