From 23b10536820aba637c2593bc9bdd8485d10b012a Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:20:27 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#435]=20=EC=B0=A9=ED=95=9C=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=EC=97=85=EC=86=8C=20=EC=A7=80=EB=8F=84=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/en.lproj/Localizable.strings | 18 +- .../Resources/ja.lproj/Localizable.strings | 18 +- .../Resources/ko.lproj/Localizable.strings | 18 +- .../Resources/vi.lproj/Localizable.strings | 18 +- .../Data/Firebase/MapAnalyticsManager.swift | 35 +++ .../GoodPriceStore/GoodPriceCategory.swift | 52 ++++ .../GoodPriceStore/GoodPriceStoreDTO.swift | 28 ++ .../Network/Router/GoodPriceStoreRouter.swift | 55 ++++ .../Auth/View/GoodPriceEntryButton.swift | 111 +++++++ .../Presentation/Auth/View/LoginView.swift | 63 +++- .../ViewController/LoginViewController.swift | 20 ++ .../Presentation/Map/View/MainMapView.swift | 168 ++++------- .../Map/View/MapFilterChipBar.swift | 152 ++++++++++ .../Presentation/Map/View/MapTopTabView.swift | 121 ++++++++ .../GoodPriceDetailSheetViewController.swift | 207 +++++++++++++ .../MainMapViewController+Marker.swift | 236 ++++++++------- .../MainMapViewController+Network.swift | 108 ++++--- .../MainMapViewController.swift | 273 ++++++++++++------ .../MapClusterMarkerUpdater.swift | 27 ++ .../ViewController/MapLeafMarkerUpdater.swift | 37 +++ .../Map/ViewController/MapMarkerItem.swift | 17 ++ .../Map/ViewController/MapMarkerKey.swift | 35 +++ .../PartnershipClusterMarkerUpdater.swift | 36 --- .../PartnershipLeafMarkerUpdater.swift | 95 ------ .../ViewController/PartnershipMarkerKey.swift | 56 ---- .../Sources/Utility/Extension/String+.swift | 19 ++ .../Sources/Utility/Literal/TextLiteral.swift | 79 ++++- .../SeoulClubOnLogo.imageset/Contents.json | 21 ++ .../SeoulClubOnLogo.png | Bin 0 -> 33560 bytes .../SeoulMySoulLogo.imageset/Contents.json | 21 ++ .../SeoulMySoulLogo.png | Bin 0 -> 12605 bytes .../bakery_pin.imageset/Contents.json | 15 + .../bakery_pin.imageset/bakery_pin.pdf | Bin 0 -> 6899 bytes 33 files changed, 1599 insertions(+), 560 deletions(-) create mode 100644 EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceCategory.swift create mode 100644 EATSSU/App/Sources/Data/Network/DTO/GoodPriceStore/GoodPriceStoreDTO.swift create mode 100644 EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift create mode 100644 EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/View/MapFilterChipBar.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerItem.swift create mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/MapMarkerKey.swift delete mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipClusterMarkerUpdater.swift delete mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipLeafMarkerUpdater.swift delete mode 100644 EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipMarkerKey.swift create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulClubOnLogo.imageset/SeoulClubOnLogo.png create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/SeoulMySoulLogo.imageset/SeoulMySoulLogo.png create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/Contents.json create mode 100644 EATSSUDesign/EATSSUDesign/Resources/Images.xcassets/bakery_pin.imageset/bakery_pin.pdf diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 0870308f..f00f91da 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -175,7 +175,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "Partner Map"; +"map.map" = "Map"; /// MainMapView - "전체" "map.all" = "All"; /// MainMapView - "내 제휴" @@ -201,6 +201,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..f1e330f1 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -176,7 +176,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "提携マップ"; +"map.map" = "マップ"; /// MainMapView - "전체" "map.all" = "すべて"; /// MainMapView - "내 제휴" @@ -202,6 +202,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..5efc213e 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -176,7 +176,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "제휴 지도"; +"map.map" = "지도"; /// MainMapView - "전체" "map.all" = "전체"; /// MainMapView - "내 제휴" @@ -202,6 +202,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..157041ab 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -176,7 +176,7 @@ // MARK: - Map /// MainMapVC - "제휴 지도" -"map.map" = "Bản đồ đối tác"; +"map.map" = "Bản đồ"; /// MainMapView - "전체" "map.all" = "Tất cả"; /// MainMapView - "내 제휴" @@ -202,6 +202,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..382116e3 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift @@ -0,0 +1,55 @@ +// +// GoodPriceStoreRouter.swift +// EATSSU +// +// Created by 황상환 on 8/23/26. +// + +import Foundation + +import Moya + +enum GoodPriceStoreRouter { + /// 목록 조회. category가 nil이면 전체 + case getStores(category: GoodPriceCategory?) + /// 상세 조회 + 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 { + switch self { + case .getStores(let category): + guard let category, let serverValue = category.serverValue else { + return .requestPlain + } + return .requestParameters( + parameters: ["category": serverValue], + encoding: URLEncoding.queryString + ) + case .getStoreDetail: + 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..8c7806c0 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,49 @@ 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) - } - - /// 축제 탭 노출 여부 토글. 숨길 때는 width를 0으로 만들어 레이아웃에서 사라지게 함 - func setFestivalVisible(_ visible: Bool) { - festivalButton.isHidden = !visible - festivalButton.snp.remakeConstraints { - $0.top.bottom.equalToSuperview().inset(4) - $0.leading.equalToSuperview().inset(4) - if visible { - $0.width.greaterThanOrEqualTo(60) - } else { - $0.width.equalTo(0) - } - } - } + // MARK: - Configuration - 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) + /// 단독 진입(착한가격업소 지도)에서는 상단 탭을 숨긴다 + func setTopTabVisible(_ visible: Bool) { + topTabView.isHidden = !visible + topTabView.snp.remakeConstraints { + $0.top.equalTo(safeAreaLayoutGuide) + $0.leading.trailing.equalToSuperview() + if !visible { $0.height.equalTo(0) } } } } 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..5b5d9262 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift @@ -0,0 +1,121 @@ +// +// 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) + } + 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.equalToSuperview() + $0.height.equalTo(2) + $0.width.equalToSuperview().dividedBy(max(buttons.count, 1)) + $0.leading.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) + } + + let update = { + self.layoutIfNeeded() + let tabWidth = self.bounds.width / CGFloat(max(self.buttons.count, 1)) + self.indicatorView.transform = CGAffineTransform(translationX: tabWidth * CGFloat(index), y: 0) + } + + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseInOut], animations: update) + } else { + update() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + 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..60b386c0 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift @@ -0,0 +1,207 @@ +// +// 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 + + /// 상세 조회 실패 시 호출 (부모가 토스트 표시) + var onLoadFailed: (() -> Void)? + + // 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)") + self.onLoadFailed?() + } + } + } + + 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 { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + let formatted = formatter.string(from: NSNumber(value: price)) ?? "\(price)" + pricePart = TextLiteral.Map.priceWon(formatted) + } 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..67a2204e 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -13,140 +13,127 @@ 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 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) + clusterColor.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 + )) } } - - // 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) - } } -// 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 +143,42 @@ 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.onLoadFailed = { [weak self] in self?.showStoreLoadFailedToast() } + 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..4f962910 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -7,11 +7,11 @@ import Foundation -// MARK: - Network Requests +// MARK: - Partnership Requests extension MainMapViewController { - /// 전체 제휴 데이터를 받아 캐시에 저장하고, 현재 모드에 맞춰 마커를 표시 + /// 전체 제휴 데이터를 받아 캐시에 저장하고 축제 제휴 마커를 표시 func refreshAllPartnerships() { NetworkService.shared.request( PartnershipRouter.getAllPartnerships, @@ -22,35 +22,59 @@ extension MainMapViewController { 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 + } + + NetworkService.shared.request( + MyRouter.getMyPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { [weak self] result in + guard let self = self else { return } + switch result { + case .success(let partnerships): + self.cachedMyPartnerships = partnerships + self.applyPartnershipMarkers(from: partnerships, periodType: .normal) + + case .failure(let error): + print("내 제휴 조회 실패: \(error.localizedDescription)") + self.cachedMyPartnerships = [] + 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,46 @@ 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 { + applyGoodPriceMarkers() return } NetworkService.shared.request( - MyRouter.getMyPartnerships, - responseType: [PartnershipDTO].self, - useAuth: true + GoodPriceStoreRouter.getStores(category: nil), + responseType: [GoodPriceStoreDTO].self, + useAuth: false ) { [weak self] result in + guard let self = self 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..95a53e4c 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,49 @@ final class MainMapViewController: BaseViewController { var currentCollegeId: Int? var hasRequestedLocationPermission = false - var clusterer: NMCClusterer? + var clusterer: NMCClusterer? - /// 가장 최근에 받아온 전체 제휴 목록 (모드 전환 시 필터링용 캐시) + /// 가장 최근에 받아온 전체 제휴 목록 (축제 필터용 캐시) var cachedAllPartnerships: [PartnershipDTO] = [] + /// 내 학과 제휴 목록 캐시 (학교 제휴 탭 필터링용) + var cachedMyPartnerships: [PartnershipDTO] = [] + /// 착한가격업소 전체 목록 캐시 (카테고리 필터링용) + var cachedGoodPriceStores: [GoodPriceStoreDTO] = [] + + // 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 +94,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 +114,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 +125,26 @@ final class MainMapViewController: BaseViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // Remote Config에 따라 축제 탭 노출 여부 결정 - let festivalEnabled = FirebaseRemoteConfig.shared.isFestivalEnabled - root.setFestivalVisible(festivalEnabled) - - 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() + switch currentTab { + case .partnership: + // 축제 노출 여부가 바뀌었을 수 있으므로 칩 재구성 + applyTabUI() + fetchDepartmentAndUpdateButton { [weak self] in + self?.loadPartnershipMarkers() } + case .goodPrice: + loadGoodPriceMarkers() } } // 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 +152,145 @@ 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 + applyTabUI() setInitialCameraPosition(animated: true) - root.select(.all) - MapAnalyticsManager.shared.logClickMapAll( - collegeId: currentCollegeId, - majorId: currentDepartmentId - ) - refreshOrApplyCachedMarkers() + + switch tab { + case .partnership: + fetchDepartmentAndUpdateButton { [weak self] in + self?.loadPartnershipMarkers() + } + case .goodPrice: + 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 + private func logPartnershipFilterClick(_ filter: PartnershipFilter) { + switch filter { + case .festival: + MapAnalyticsManager.shared.logClickMapFestival( + collegeId: currentCollegeId, + majorId: currentDepartmentId + ) + case .all, .restaurant, .cafe, .pub: + 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: + fetchDepartmentAndUpdateButton { [weak self] in + self?.loadPartnershipMarkers() } + case .goodPrice: + cachedGoodPriceStores = [] + loadGoodPriceMarkers() } } - + func setInitialCameraPosition(animated: Bool) { let cameraUpdate = NMFCameraUpdate( scrollTo: NMGLatLng( @@ -222,5 +307,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..0d755eb1 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift @@ -0,0 +1,37 @@ +// +// MapLeafMarkerUpdater.swift +// EATSSU +// +// Created by 황상환 on 10/4/25. +// + +import UIKit +import NMapsMap + +/// 개별 마커(Leaf Marker)의 아이콘/탭 핸들러를 설정하는 클래스 +final class MapLeafMarkerUpdater: NMCDefaultLeafMarkerUpdater { + + var items: [MapMarkerItem] = [] + + 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 markerView = MapMarkerView(icon: item.icon, title: item.title) + markerView.layoutIfNeeded() + markerView.frame = CGRect(origin: .zero, size: markerView.intrinsicContentSize) + let iconImage = markerView.toImage() + + 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/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/Utility/Extension/String+.swift b/EATSSU/App/Sources/Utility/Extension/String+.swift index dce0e050..60d50ca3 100644 --- a/EATSSU/App/Sources/Utility/Extension/String+.swift +++ b/EATSSU/App/Sources/Utility/Extension/String+.swift @@ -41,4 +41,23 @@ extension String { return attributedString } + + /// 문자열 중 `highlight` 부분만 강조 색으로 칠한 AttributedString 반환. + /// `highlight`가 포함되어 있지 않으면 전체가 baseColor + func highlighted( + _ highlight: String, + baseColor: UIColor, + highlightColor: UIColor + ) -> NSAttributedString { + let attributedString = NSMutableAttributedString( + string: self, + attributes: [.foregroundColor: baseColor] + ) + + 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..15a60e58 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -164,6 +164,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 +465,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 0000000000000000000000000000000000000000..a3bd271df28bc8431c40c7af23dfc5091d4f7b10 GIT binary patch literal 33560 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%0D7OXXE7L>x)1-Iic zRoEhdW~n8J`Nm9T%e+QtIBDRbz zzXOs?A`G&I#3{Hjt}jtSoQ|NIg@ zFfJLE5F6X}gTP6*;^1Y%wD~TrJ=P1o7_f^M?&AfRX2<9}>Y6iiO~ez&#!m-DG*Kj) z)1v{(-DPmHE)dm=&LXP^A09vPVyqgD)Z?0t%i%;@`=InRh6BM%eIhK(r0peb=HMj) zc)>5<5sqkpk5>@Yr-U6b3}_oU5jqR0L2YGbkCbS;1Pk&L1DgaxveH)=%xk?g7j&I| zdl0p(mGfqWo}vOj4zlv)FhBUfsY{kbEg0G10H9EPC{OFJc?;JI#IA45kw*E&SSdzn zDa3eXIoj9(^os5>go>OuZyw*RY+?q4yn%SGURWy_wg}~~tC#n1)lHi=;pHUVZ-b3W zCLMW0rtxb%lrD&gaU3k00{&O`(ppes`6qREeyLu3g|);oEIty;hT1Wp$!f>LfQ_cZ zwi^rb`)pj>Pi!5wDG9SLjiceou#HQRaWfEPRuM-z1q$V{G1tkR} zK&HW@#GN657Z+ukz1&O|RGP7yJlHuC&1!h%eU);h2na(b_R;&AYL{j6X)|V zS@NpIYp@#B$ThSHH4;I=hA`Zite}cHA5m;1MaSb+dA!r*Tv`F`B9dM!DE(1o)vf7) zlw?(OCmTk0&4jK2N)rC^t-!vZ5T5ke4MBd~ZSVnz+a}Z4dVIxXS2w-+nfq><6%;1( zcr$cJinqowG}`03cx^3T9PJllqo`mSD^Jo~iJlPNo>!aM+(;aD$`|k3MOs)9ts8?( zv>o!+aE8f26hdt{m!%a-Ac!}~TN4EWv(QS;uJB|n`aNWf|4(KanX7mfQ27U9u2SiK zQ@aAyQmjcYHIcdqzDlmp4}RN~rHdtu_ObLKp4Lv#BMun|U;(EfI0x0@^BN4B!Y%UI z$!xHGRW9GdM0X z+upJs2U0Zw3SMOwlKlXV0%0|#c;6* zH=NyX@$N+r+j^jA0Mmnd2pMshW4PZIam%KYY-vWph;Zn-B*PSX8Bhy#*icPc20#Z+ zWNiQMnwk$D3vn3OtedctZa~aiL=&Q!IWEdIMl)f5SFr8-Ck6Q>MGWJ?od1;LToNE! z8wXA`YVG(Q(x?`Tal{V_j+DxDrNp~35lUdm8B&T2yX>kR?VWey#k_viDB$}Lcp^Xs zXf=6}8Pl4Ae>KmbWZK~#~kw%^8f z{pgV5lN1o;pKZA`DimufQowQ-gAB#ku^sK3x5?m`wlbT6UL|t*v|yDdu8%6)?d}A;1Z-{2miF^l_IRlp=kyic(j_*T<$kYN-?J zqmZz8SE$C(M$zna81V!#h}-k%TQh*GJ64OvL7u&4;E*6&Mp!OPV4TFga&BN?20Dub#Ar1=_$-~S^;G&Tf?)dTJt9$RgcmB|W5B}og zPd<4e=v)CYN99XLqwQ$JR_4?qGqyUfLgRcQ^x%vP?LoE1g3T}=l41r0X0o7zUYuWt z86#A{refd{k3z9KDu?e+n^_%Z995hpc0SI@@jq{F=*kTX~#=7{9m*j=IT=nWub7AwuhL+SF-xjT=AVuS0`_IKLO?Kk(TRTEy{_ zCOsdlUM;xfTU(0>YAa}~rGx^cpq|K-Vlg0vEyVzwT0rdY?_Qy|EelZr5?@)k_{53gUhP6x!TYF&&^aYjR*^HTO zL3c=+Aj`F6uw%k>GU)6VD@I{>R4IC9+^_K=18i78oE++E0%4Ar0)IkDM+MsUi+BM4ekWx(R0(3ew65kCE(Gko`MhFBngsi+)gP`eL7nMBM z-`~I4W+F#|Hl8*Dv0VEAw}=Kk)gfJV)m3;~Ie5b6vZwq<9DewVBjv>z;hdQ>U--@M zfB(WrdDNr(^Pm6hMC08d_Fqmu(7wFl7C{1}W$m0GmbSfZ_jEq^E1%}2X|uwV!%N0X z?iyFd*uN%5dFWsTEVXRQLuhR}?e_w8BzfU`NbA=p3L2Ed2q4warmzGbonFMsxh>zd zvd5e}7Egpt6K%dmlJo=o`?Ct6UPJ&cA415cihm=>lvkehR*Q3)YBT<=LN4!ILh z&t3!r%~uKb%2z;a(CWi$i6=|{JOjZ!4 ztVFNG9}2p<)Z+@Y!JW%~9rSGfWRR;&K_!tqEfPtNj6`mSQSY>Q9 zBI$#kOn@&DN#^x%bX<7k5l66*R~NxaHagCB1s-xej3!N*)UR~X2%5JJI}9&{?icGA zk83wZ$!IKva>>$P(X>=0O$Rj(3D!{U>eysh8kjUXMzOUZI?lW5BpB=5)I78_&0$DjLsiWk*Z96pQ%BPqkVa8Z&0hM!@W6xWV9{nB`;{hqf|tx(s+UZ4Ba`chRgF zb{1=2mKfvII&02zzQ(d721XY8oFBtW(K^XW^FwQp1}aF-+A!u*TcCOPTpp{1yHZ+bU7XapToD!~g>wd6>ge zUHQZlPrUDsfBfTZh)>NDyecJuI$INP2P`Ziek?4VHh@$sV$;}ui%59u#U*i_u$K^n zInUB0KH8B~*i^JsgeYHzx!a&C1>Cps70_X1=pB`z9JA1#U7^pjc7VFrbwe$9bFnT0 zBKgUc6b@CRuG%F6{90p{DU`!Cx83&J$C8~1QAc}q@)!g#ZiRebp!PjiXiRTa}W{OVy z=mM7JV&FA;Zmy+#{D7fNZ`Bx_>Qm$=EL@6ov4EPE(l)jnt}+sXju5ZXC3p#3AIxR~ zzmGi(Z^{eN#RoPLbj<`Y6LcO$`Dxhu*)b+S57g5d8n{NrkxlTXz&d&~Zn@lo!IYP` zR)?-nwhvZE6;n9s;HW5a7F9y1fKQtZ*n@_~^a=xyq+xQ6u9KtH)~=4X+{l0Qn!v!z zEEcB6>)|Uz<1tEc&}ajbM!}@dJs51ha!ZgKm?}_Ljf}wJG?46I@$=z|?34~zEi^Cq0PXrS>!7&YL#@TAkNJz(%M zB&z|Nd_f0`t7!mdd&!Hly&QLuI2Dg-f*EefOi`%@9p6CYvHJKCyYMle#00mDDdhmZ zn0Oz)r{v5Kf#WbqYLEQJG|N3z1-qB@N9UfWtUhsG}QE zg{dVmet8MbJfXS4g^%@R*C#UJ1rb_`1Yl%Z6ieqCLxcZTZl+w!+%n@*%5lp+YlFD# z6oLi0*!7mt=8EzILoL~MGk9qaAD*SHsbyUro-`;V@YUJ@3QV}Brz?7C2BV?k;pjW2e!{to9 zlG%)P*pczaB8*2q)ODBH>KlTh6$ZQ!ju`Xs^nf&1?Mf6)+*~K?^vgzuX^S;8iqL*~ z@vY+J%Wa4u{Tq06dgXeOt(Mvh+oTS%dyvQ$7o8Wrx<=7>y|ul)**tPo;j}fQ@30JP zc?C7X82A+Tn~x63VJ8E!08)Pv4&(C7LRaz7)YiA$6!fltFNRfY?~~dz5I|+A3vN;U z5oVKZtEPshBkIw$^p!8YG!2g`@Q#qfT1O{bDr8*ZQ)wL!{BOTpT5D^(~+es zF&O7z${mlnwsgeUEB+kxz4nP9-}9P)UyH5JoC2&uVzFb##7>D%qAU!AszZ3!X~Z4U zCDA3hYo}k0<+gf>7HWBaxEzCx0lBA_KaFoqaBw=Zkuz7*b>-B8;(XAC$kq`tc2QGQ;h8u3!2(mJ8)VQlTyEpyFdZBf z<69PEuj1mS7!u&3NWl?~V+nbxiFHXlG0=4QI01vN2flob+yFB?6{qVO}dU`XfAoSzolTkEmQhM{hK3m#pusQkCr0F${jqmr%9eMt^;Xq-h*3(XV z`+@uHv)^f3w{AHBFEcJdXE&ZZ1NYqJw95-^z8f(pT>0Y4t>eaxd%mNs>^O2W@Z(nL8g(dN!tp=4SM_rt1rdfeRZ>NOnZ0y&gwCU9e-vj+3wqXPt+~ zf*b3mHFx>j>2PD}hcsVFkG<|;wKUi@hn zF7q{i4eJ?IoCdXJN-JmN!A=HA*uifMgAb47f$9sM_MnM;3@@7?E*maXhhT%uvaWH! z*wMYQnvxQ)IIt1S&`Wb4nafY?fO9?GjMMfSnGhjEjh;lJ9WyAN87hb_MQAw!*WH0TrOZm&*Vo6=;t;Y^B3F6qftw47V|6V_sHN&o63Q?+ zWnYY?I8HxhNOVF_k}fYg z@|JB%98Q>4U^oAx`4|cf5H8L_Y}?hd>!fFvFaPf;lPCY|?Qehk0_tqA8+gzONV_ym zp%`NAY9kj76IW{21huSt0l_aDIVNA2iMzNgMhOOT?LG@xz`Zs~u+a=`FzjUqHOd}w z`3$L{xwNFF5jBB!IdXS8ejNdqOnu2yu#nHVMs$`P=#tB*9$CpWK`hU*TV-kJdYKkN zEJDYE7F-h&UpzYL(h$eJvtO&%Tx+WtC+Ha;gaRSrUtg5 znn8?4R@N;#DTIY=ewtb`BMiSM{`?4o)(F z{K}nq+L^~){U6`Eef|3NA3{EI3pStiz*aPueJ813?XXUx)CX&>;r1On-hRt1x7>cr zF~^(&-R;>%rZQ-olsiQ3wH& z0OEONZ6KLPL}+xOhO3=@hPm-5T8|<^Y`*YLtWo&{U}u607c6X$?RPjtLD!&1AaTMd zKki+(MN!A9AkUs~(fcfb4HgeHj`G(JN@f7E0_@akE`d$pNs-&hXBJCmV)*NQp7-B%;N(HQce zRj{#cQK+e#($9_){k{0%cSi7FKhgU-))-j;>C4R+1}W2U#u;avaL1ju-?*!%XCHP% zqB9~7kpy2!9EN~Bi7L%1^uzPOy&rk_;s0E^WXXGwhDHORs94DR*WqLRREEU>`cN3X z@VKQ@_+Bg-$ASi*#K2J4%+?es9BJe_JYv+?hYckSGY21=bJ~TbMW7JGlJ=~xR^$E` zM-wL?rFq5U#S|Wr(&VLQNGa}F<|Z6B>SoY6qN9UAueOV)WGbEA@wNgAx%r_f?hAJ+ z5)U0K;v*0`oa!`8M8d;q*X9Jw3Wih9*%lUO{yN~^YF<=>vu-`}_N9S1&SfE~IJ_J< zviFcsk#jg<`#pmL1M}DjiKfyKmJCRZb=}hS$Y3G5cX#jNFMQz(r-Rvln1;M}h>4A1I8+<)z`;>J`635Is9gBQn)onR8C_HraBgkpxqLam^Ein) zhaG--w509W?mVSjD$U!tWy>Lby}c7LS{`9iErVzdEE~zg;w?0v_ro9l@KPK#^uxaX z{^@pp`OAV9L{3&g^f7)FVM=m@L!ZXIuwupcjy>wAT_|tAv-MAj$Lq5Wj7f1JPP7hy zZ*|QY9g1CKZsUegnTxiR&AXT`;~kfFV@RARfx_o3Et$Ajn%~#V5EP4JjrCOQX$~zM z>28|NuU$42OXs3qxfph4Xf0d}Ky4&9(i`%&{@~Jpjaaf9YJlVw2;fM}22mJD9+okZ z%wtVAC5LL955n?T@N;qf*`NgLNFys&u_nJ9(P)$y z6DZ#YKls7DUs?akN2rCXNea~hCjJK^Bu$&<~oPB>A5ML-IaO3_cB3<-hS zOV_@f$&Vdj{9-I6`3qvr$spJsEm^{n!feb@`Ej6d8KY}(y7()W-kGmEd5oro9eL^zu|>%y!ni>Hr7<8r2x(aCAD zOK-X6>5GrO<$)mB@J4ZRgOo&#Tq8NEBZ{%&QV}|yQ5cjD`#q!$8#Z(txb(oUV;7Tb zj6@>DkCv2hwSGnu6`u%0o4)?uLoT}LqEDj1z8Dp7INK*#3KI-%HMD-(wc~_8A4T@r zO{w@{19Yc@k!&7~NA8oK{N&h29(iQq&Yinw&c}t>)&k6e_QN5T3rgkEY`Ejm;k0p1 z1zJa*Y?KrTqY*b2aGmm0gvACp2F>zIC`uqt32WklZn;AaI_T`J+qWN&JaQ<~iaF6L z5Yr^c6UZ?hb+mcwn0m>T%!r||;T7@_6ypJO>3stO``z^On=U|_KAlzGFryV62-7Nc zs3(=OWIQ@D9*31``EgX1RLx+Tpwur83ZLMHAIze zY;YQ0@k*uEp&IYZGWP zc**&}z{RT;{45yUe3E#AAkvSxq&z!JKl4q%@T0_O0a#QhuN}^NNT|52doNsg@s6E4 zPDce*>=IlxcSkd=mC#6GkWRzlMUaD%mtTJQ<3}8E#0to|9x~!uVfbcvGTe{cU>bT)I*+04So zcnWrWO~4}ESd8*`-kZZK5kRE2jhYV&bpB|#MPajIG^sskn!*>A&*j6-n|;hRm2_{t z_14L&SFinFYRX;XI^&M zW#0jKT~g|$XkJ@d3ood?nxqtw7UO}HtL3hT9(u@`wSzXq>kShfIZ8$4I8jaj12ov& z-Ljrsd(#>^goyxf#AZV;K+ zqqGKen%;7?j`%QQ|0_+N)PRD~vS8kVe_gl!WzJv(H-95UeY6BisPWqIw*JD;lN;Kf zeBz0#4n6cWY_xxhMlT*x8XhSR=`uDY*!HnyTRf0$jlOWq^y90wE#LUYHzse~xN+vr zoxA4Enl)>m%1~t?zVtAsQZCKF=$FfC<8TTOK2Kh^PS!J!Qp`rtemgCe{>p0_?fPhG zLIJ6(uc5(53s3GF=v#t(F|ejQK({F1MVjP)Q{*<1;S=j1M}pc+HnR4+@&%M>;B(L) z|M(T zhv#oC|MqYHH0`j%4tvv@HEZ6#t9#e6*o8vI`AYlPmdbdq?*3tOQF2 zQwb43WG41a`Hl`sBqdyS_Ri?~uiPF)E72h(BE3QI*L6148B(2EuA{xd;i@Pc;b%Yb z+37Q<&-~)%t(!l@!U9q_l&BIwuefwhX%|54O`- zJg@i~mMV4|^b8NCdree=EG43_-nP)Q&pvzp>{+uf-`%_W06f#_!blY}omwILtq6|b zbi%1{mfVcQc)>P5FM_gPMqD;nFouTtY16Wi7=ui2TfEmks#oOTefU@9b$ipJ5{X~FIrT3^pQu(mFi%tV2q#agv%I; zfo@Z`Z(qk#-R{$La9i*ltw~lmW|q{=MgrTm*7hgo&70S_jFXQ?LER46Z0q@%9ZtOA ztHD}f6&8yHXv+(Q1@C;{`(AVW8{ZfrZ=xJW&bTT#sXUMOrYgA#wqUmecxKB@Kpb?k zEam%3rIy`0cTPZFPQ^QI`=Op4irLUobnIPLJ=4M+)v^Lc65LUb_p0h2)Rbm$C&wCAy~TxmW1=Y`&v&Ov8_S6animNt?GezWcuYmG$fYcmKeEJ~EM&xT(vo1Wl3%dq88HCRJ&{cZfa%`Ntl8 z^s!g|;0OPG+v3Gt{cO0B!tr7(zC4-!{B(?`rP~&8Vr$jBeYAfZM_;M0Z{C(Io8Jzd z&>3rMqSV<51fJh6TDHq`PlR-8ghocKOwIyBXC77O9zpE=tDs}p(b?AVBq$p#kruXX z-+o9^f?jhzcv}|&l9dIk~TL z3SXo-@zO}cd*wV79qpj`f@e?OLi)F_ZhaZ0UnlFF(%bS2#*M=CNjK}jZ z=UuV-#xQxxB2n3Eue~mR>7|!0qb>jXxBlXsm9fCE57NaD7blj zm@7|_OLyRH)G!{2ZmY%5S^?Cw64 zWlc-Z6a>Yk?ikcoD^s6bvBKLSfqrQ6lqrAOuyNywnY^3`auw29Zabe_zWjR=CQSHe z)Twb;f|@6ElhKMwXkj=o%7(BGwzjpugjX$=-+%x81~1d2#3#$5%@+e;TZETYHMwNx zVAZok!ygdt@~hU9=L7TroCj z>n-OG+Yki)a#6(^cw(NbxWY0*Zy_xZVP{5#PQwJj<0MOKmH`lZi)x)i-hlZ5uwilh2nhf?q#=-1rA( zOq+2%HlBQ8@#4j2yy5k4Jo56dUvtEkty@pWa@55*SoEfwZ@&2nEE{bFS&5xdmh>z* zb}aLwEj7@mP-tcMa&hpiXg!`$nR4Lb?vYS+?6`5On&Bkust}cyEn~-y9p|G(TB`>g zbkHALibcKyWD@0?fT_1FV9>a9*Sc%ht^-kC=b@Ncb;%Whnzx*4Y@}ATk5F^w8+4TRIFk#YMBDtNo1x3@4uQv`nAhhQ)j}MGR@q_v}qr z8ZFLzR6Yu_Xhbt)g|*iRLfU!8TUeeR4E*_qXvL!c6TNucDbePCo`#Iamt7+qH4$}` zR-U^!dj6n)j<%h5S1#P}ek|1ioa&6EF@-s`O_d-9IY!QRJpJ(cD>KV{K3A!vvcu>K z9zMT-p{Z@S%T6ag2-@H9wN16$_&-@EH)w@pB(E?lT6iT{VcoiQ`2L02d~TS~uK@V6bS>qRX)SfP;z7CONU`((*D8N(xLNQ(u>T!B+! z!31X{cm@NSmv(J=v1pFtF!GN92_Iy!25TlNHcMA1WQmDwXfxFalBMW=WFI7vKqO-D zLrl!yIO7>C6pGvMrS|n`9M@vhykh)>@hhfJpT6SArAMy4?25~}fy!ot-_41he){R) z_ka9-@R5(aPx9OYznFO;Xu}Nf}WD6M_95~?rws$4aRTXLa-k0~XJhG4g35JAC zc94UFu}2nn#L>2KPUDJw4q$f=NRLNx8C2Rvr&U~LT5Vf74kAKBi=eh6BBQd%DhwC{ zf;L1T5+V>nHnP2!ygT3j*R6Z&zIX3S!qVa#>Ye*;RsDbc|6hOYb!%C)Xc2wTKjw{f z>X2>Qwxv{8R`YjGsXI4nQhEua^uePe{i8t=MO(OV;jT5S*K9dlc6cm}K9L_Rm~Bym zJK(0nsV_SmfuU73xDBLJJL(4w==UDdH2V7r$DRpM=3MA93p)n3P2o*e5a^hZxpnsJ z7yt9-o3Al)y5tlbJAUkEYVSI>67%B61o3AJPd>P9mRb>G55)x=C&(|p@uaU~Rg`B` z;?ryeN>shsGEk%puQY4Gp?NiET&pmuJdzklIur~h7iV_txG%3qk9`v+Oemc+X%b~X zYIta*V(h_v(YSq8d+Nof;B|IQ=d{kXNCNYU5SK{B#8s#i>1<#l@I?>Le0a{{#f$I1 zw}!DQHId@w;7N>5AsXwCZdpR47@#H53$ zVs003Z7V4Nu{T-S+i6WoF0W7|WOeo=n1b7}aJnSUnR`3+Lb{g5Gma7=`b`mzKLZjS zLi@wpPwc(#x;HmyTIqPl3@7W+)`At#QU}3MJn8EVu5Y4ZicKZm(Li-){i7b{sNiN) zv9Q1@1};R2x{Ns9%o|=fOiXJ|I_m3i#d5&C$FfREAWCeQhRKQ=SGGzX5=qm`%Uy1a zwZO5Kgf7o)f~t0EzWU+3G>Z8M>H?d1n0&1`DRfu_-Jo8zY=|tWyc66)@#@hD4ruvM zp_Yufe9Uidxn*oMdER~Z-S;nib78N#x|0_nu#}AHu!BcSmbake`cF7Q4(tt~UL)O5 zNglRvuHoW(B5a~30+#-qE`2ZBMp|0xvFz-eZJ09Jn-oYo5=>32O-W5{z+uZFG?JlE zeM2)g+C~n4cewj=Pd}GGXU-hdQw9wP^&vxskX+^op?;JbtQ4d^5ypxHHs=aZc0mL=AZ~p$&SegxXMA_ zzkgH0zJ2==@IA_h5m5#f-{cM-K4Nr9$${G{D=UYQVg&2x;L>f6?<^4k!nEgssZ*zx zE?tW6&@KjXCDNbbuBZ2p9yxNWn)$h+1P#GKs$BAOA?ENh@ntXvOw;l0^5lq1E_n(3 z-KIJrl{nYP5u6;Yu&47CovdPA!-fqzxNGOGvigSlTq~{HKs%Pwsr#snkV?hw2&leJ zyF(|a8!$~7<>kbWcFmZfH{AEnKVP7oJbFFq`~Y-HIup$a*|`dHQ3z{Rsuyq?1`%u7 z?#OyhW>uB33poZ$auS!$i8Z=@px^k)0Zn^V`o_LvvO5T|Qi%~6=@u^W&&-T2UFfv3 zwi`Y3irSXlSs(cHI&nXq%!Q6PNe7XJo!S9vpG|R4&FYDDSoV!&ujFNVOCh4=vB7Q` zMP^z`cx>W3GQ6N*%KPuXPd9Zk!B<{+rFh)98z-&*?9*k9O%1&jDbH~M z^2oO>2PVU%C*E#_Oya6E$xn4N2_qrSvTw|R(Fjr!;{a{_V{R%-*xAV<7U8tP6_(PFq zU0^^T3E)XY>xq?IE$J8_r_+@etV)i8GfuJcz(*?61X2i0ffi#mopnx}TR3VIFgks5 z>03)jW94Z#xIxL}*9Nw+G*J#IoR@*pa=Uh&iv=fcqazB1o&DA{yQCtkJL$IH3NIga z2iX93+uXTx4}QFA)n5*j;C4{Ou*`_LsdEK#CaglpSiRV~5_t)=7*JUTFF};}vSf+g z9ZoOFSA4bn;~RQ)U#SPm#%m!EL{22Kf@L%V0Ajp=X@yV(RjkZACY*~z)Uc7k=*S2q zpU|_edPT!jnG2@vMRWa@z?Q{^TR=qsvSJ3Yyu4cRRDyU~(eDjh|B>EUHUo>%(@0kq zDpP`!bU}88&>y=69^;6V_xVF1vQee0P$5^WiOnnmhxuTKZ%T4<`NhMBPr>Qf>qMco zX{=iH--W|2{)b;ACnfKrF1rmeX$YaBRj0p1^htkBY;QUL)B6EJFM zwUAP1p*^QfQuTUU++eZnCNe74M2t{6QUyx~r{e9W>jCgFT*E^j#i1&G^D$wtx9d%G zWEhhs(Zz%F$psI76Ao5|(R7^J$~&S&m2!T5k9DT598t0Lq6TrGm;jy>r)ByHIJLY} zbyZcjs_Lqq`0(EcE93^@8A_v_ipt92^uT^G{DzS~uDKXkd-CLkH8nLo(XeGTabv^{ zgUQ}q3c=Wv#VHcIy5!{GkVx-%%NV`!#v8i_4H$G^z#qW0DN2_@i^_#vHWYviY`qB< z56<~f&+3@9diE1f&=PsiO2_KaS+v_|iMxVM&;Z}?HP>GIS}-{Uhq-XAY7@7jAT9CZ zV+VAzIceBBBwPuD6*Az{7d&_|4-<;Z)sYp@1Y-{_2WrRPjW_D`&8gQsp*1HT;b}4s zOJvliHG&dX!BYh%yF{UPv@vJ+7>Cz&K`VjobM$WWc1EK{yK!8Z?IpfNneJ$YZj9}q zZMBu?AAYIp!OyJEkJNjRq-Dd*Z$z5nE&Ftm0XqHxKRQ|=?3#ujH%N>}e^zE-A1-Xp z%p$4qn<`K}wL@z0RhM6V2iEug4>EL2#RflP^XAXCTsv;uO<2^l+05w7O;VEuTa=9XVo3S=jo?SbAIXPWF45p-%J7ggOv4#TL1{B(}?As2A(R0uFMOt9+Oi*azET{r4>B-Z{(t#H3 zP*GKTES?=ZN-;Vs#Fw+Qw8J+qLtAm`J3$DW-W7@#sdyX(KJthAeX$BT7i-_XSUHlA zy$m%a@+B2%LIOLLBl%gPm=^gmM^&zwlQM_;#PSzZD09%-tFGDEAKiJ&FMhgW)r!^e z3O8T|rt6CKt2DRsBqz%jQ64j zlG!FjR>f%p95#t%Va9kk(9aDAx9{;Icl+(PAMci%w}et3Y44C*5!3+h6#U~ zNai2t5lz=e;;?v5zR^^jZV#zIg81ZQXMa3@(W0-NVZ2?)3(bXvg|7}AH0U0DMWPIAD4(C0GH4fkSM3)q29sZYo2tB;A+~dk95IahN{olpfS8I%ZRjV1L=m!-p1J zRa8`T7d{76snK>ID8_EL2D5!-ug3UlixY#lsK%EQpTY^1(ra2T%Rt%cB!s zo&W0JoRzmnP)&q@_R$>h%Ir*mWRHHLNqdwr8O(|9^o7fZsWS3( zHJGBJhEJ-^funkoU3H5@H}&rs$^rO69gy8ai?&!)N!>f+-g#-M={u>?upzL)6`TJh zihMgloOK6r<%}A|9vNOwPE4%0{K_kzL7W=eBM9(As2pD#GxD;V($mtnfply|ZSrIO zrVLgdb~5A27kMm{pO^QiH{X2omRV2EI^;xgbSi<<%zaDj&SHr6HP2G(NGsucT7*;(=9i5De9Fe zcTAl4kH=5Ed*8OgZQF7E1VxxuImR^<-Kczs4;{8Sa$PhzL_1Wu3CJmR)!K^?#T*Z{8u~r?!N5&_)>AVZR6~@HT$% z!3XzL9IvKhv`l(T4@e^Fh#5Ec@VVxTCTBiNXln!2ukpFS^c-nNZq7uxzQl!lVAbB4qa9IX5v z6Mx*b(rOAjr8eVA9gyi0Cr+HRbLY+xCr(riXl$(Si9`025DnxTz89GSDsjd!PN)@V z1_w}iCox4Fy#ubR(>PSrgqf;LLkpiaVn~(efDI%akgeW+x*SQeF@>Op3@TRdhkLA8DFVqEB3Z9 ztPz4+Fv&&+B6k!p0Vxl_4qiD8gb?UTy_=vQ0CVPlTEBkd&nu1} zyQQwKekk^Q@Od9Bn8&S{01a3}*ePQJ4K@$Bu1tLrqN?s9as6&N_+TLbuAV z{Rkp~&1ANNQ~KaUaWqEFxYz43Oufye{A(k$--bL(Q%KJ|`sh>h|N7428!D?13L99UTtBXK-|2-!P;H-zS= zYIbBJ-=vw}I zvwwiG+&DD7P^+!TLrcEs zDg9@ktY~#boh74UX~E&D(vXchNW_xmg9k&U45pi4J`BDiO%fm}wEdlW@8??Ro^Nml z&=5W_R@TQ%0c4pk$w54c4c^F-SRdcg6w)Go43h&$XmLgvG8*GSA=G=JYXXA7l%kCE z^bNgw^w{vp+O>tCK!wLxvSbOfI1_)MM@vHXA)`Nw172T#=%I&xf_sOqJ6U`3s;0(< z3q#@N46HZf5t`(ORcXE*aNrA)ft~jCGM*yFF8Z_)Bnf^a?xcfwr4+k+cwqzZiHHkr zlcL4Kv^dg2a5FLK^h77*ww+pr}n1?u=Uboql z7Ia8ww6S)+vCmli!ipEN)~s27;rHJk&abPh?HXy01fXm5YkVhDGg3=CckW#J;G_qO zuekXNKD{2{O-k*0As9xqrx2FpJtdBcc%VUwR+Oy(&ytm9F<`tKBoDqKpP%waq$sLr zG4i!yM14BWtNXLoSoAARufARjG-lF?3tRzHZlKCvRS#*3lrG;kRrFBcNwp8fEbPaY z+0-vIU)};O`}x1vSz+u*I*?1Fb8(%;oOAbj+CjxRkCE!RZnl(OTKO&$tadND;IrWHlse%iP?b0hb{JJSX*Zz~i95DtSm`I7P(G|tzBZp|8_=myS~Y$8^l|Sj zf9H26PE_2EE2gQ|AXH0Caz;1IR5~mKi>2-3G(Wb|QANwfeA7!N2)(JM3sFntKY@EBuyQqXotBilQtP`3o73ss zM`xY^V*89gZ%3r1@jhQ9oQzE!5Xa2&BnJ-p5XRt5!H;+x1;F zO0cjN9Q8-7E_@(j_|ruX0&IMK%;92`t@AZ1D7v_E1SiA$=zN1_nto3C-*3OvYW$-| zkFG_=o=IZji98N1&lU}XwrT5D?fK`QkK*_DahjC|DP}J{knuyjNk)1|t$c19{IP!B zUm6T{_!3HmKWbeuIHe@#2OeULEQJz4>Nh|sdLpJFj|GE4&yR)pE`S1T;FKjIjgPiO z!Zb2}seS~K{t_?x9gQ#HUj794g4zDI&BiwURFDTVRB&kk$($;&;=av_wis#f%PX(E z=AEy06-~xIp(}A42(D@uOo0P6yNC}k>&a3UEN*gIaEKzqP)OC^mXe7OYvN1aiqP5E z(Je1;!Amc_v}Ekqv2<*ajs179nL0YNoXX3!jE)_@Mh75~=)+JxC+*QA^F299^FR?J z3!v5qlM{Ethwy$>COtj9J}Hp68fCSgsZ)w48@Mrjf`8E+;*yF2&b#liRJ=N)OGd54 zJ+p5;taqauVvW6+s`;a_>R&o*6<-dFM5+e*A`Limq^S!A$7!fHQh7!}I7*yk}MqagB zu|ODKpFVx4VMAtcH`As}>Hpy;pUw=0Ln)|Q>9l|Z+OCR~c_G;O-T^ZL%>4>XZPNrd zecVOufK?FI6dx6R9j6Hx=r5Uf_0XqAT2>?y%b<($FllQ7xj(`4XF_0_U7`MqIm?1j zs9V6!3Qs~)H<`L*bf}+*1ie57*i44#qb;Qq5XhTCg9LzBU5U|yfjtZUwA5hH?yvtw ztIo{x>IA9w{DWTN`0?Wh6cz0nS5s4c4SKc1LZK$QWtWa>2k*0INF#h3ZDQil2`XU(ccE z#eR50+`|R+T5&`}LcK^U9(QuzP=QVI%1LwE~tb%`z%XwbBAGrq1-YrkLmX>P1Eye2+jvPMhOR7u6 znSOP8wNdG7DsN0Ud9s$S1F`aDhYqO`Y%?&@)Ab1MdTMTJN;i7Px#IAvH#W=%~1DgXcg2mk?xX#fNO00031000^Q000000-yo_1ONa40RR92q@V); z1ONa40RR91MF0Q*0QgGcG5`QDvq?ljRCodHT?e34#nnD@clR|q_6{NnHWF+}Z0K(i zj2cT4(i3B%iHRD(0{TaNv4Mys0X4zIsIf=QZ>)(LFqWvXM2&z!5d{P6{JQPy&4O)pV0vn_17wM=T1{?>5OvJLn) zf^WBo^`0ae-&Sjy*M#L;OINFbS`E}{V12Iv<@*5h&)G`q)KQYj904IT2J~A%5H(2L z!X=Yyk#t5Rqq}gwLSpLQ2)tj~yZ<6%X0F6_5cm#=cM6gq0BtL1X9GLE(&+wY9xQ1& z5rX0%&}{`_5zjs+o>4MMT)zW+RJ6WPbHBw(kPZPZ=ZwVv{g{o+NcK zSPAGONxTmjMD}%YGIk>in7|;*>+aMtkj#=q{jX(sv!@WqV>K7o+|l5FTam~8;S#6 z?w>;V0eNwU;rfxhd#}soh#Ucg0#i|-!2Pvhj;wdq2M3>0U<)L^0oZ83gpKWo`ZZaO z;r2R-%MN{3WV5NJF(@bG2kMIw+FP)$PS_`@h#n2x73f!2Ye7S$_YUCgK)*f$5@R?R zl<22&2yh1=JERx1BXU0QFTh)Xi-D!V1$uwH`}2I?J=A1L%4>4In(Ub2g?c>rOahHJ zkWX5km2=g3-h`jTo{g(FPRM7EJwtjGn&44)^|JD!9D4ILET_pn$zW4>)@hJmcM>5(o1w zXR$Y3HaS`F8C4wrzYY0xknT6>B-M81!^J6d!z2g`)T6|9${GY-^+=v}-9hTreP7aQ z7v1m7V^!N_D~kE%-3O^Jyhu;*i_cG7oNqcm(&w7*p&Bp*nd1E#!$qKc$(Y)ikV0_0 zbChG~K((8^rSF%e@Iidf+m?4sUzxxLWYa2Fmne)Vc+LWz3fu%}3Ijk$C5*_y_?N(& zfTh6hNPIEyC?NWuG0zHAnc<>h2_NnNK97M`^?gwcZ#C}ZJv>k%-8RQ`7qOiW)F9|v9!%EB{- z`zNYAg_h8=MvCkmQE%uyKepKx`Yz^4bK0xBJ0chPw_i>AG2&YibHj z{|aQe!hwai&N?UrVvEQwRsi_B+eJ9*zFJ?TiceAX|`T;w65x6 zqeaJWmKA5@e8OHSDjV0Y~N61${11>v)SK?TYLq`4Vbicm!I0{9&6?gwsP5L&x2s)0g*B$*k~kQfn}Fv=By zLaC|lbFwBrB^T5ees#mdVWKVgM8gV=+*45bR*mSWzqf^v($~W>8eBSGDi~^#?teOc z>ours4bVdJn?vv_5#2d-*@Xiw*Fs0?3OvMy%grnmhx-&dOH8gdOVEpo^?5CjZmy`| zBrv4=&lEld!a2Zli2c#vi!nejXIz6IHP9oF7M{JoB-A&hBdn$1lWix2l2SjHMZe&L zcHaOM`>9BLXz#@fVTwng2KSxPm`hR+Ac3t_hfL|giTA_Q(j;e$MMeYlHlBdFb-up+dc zAd8yW*{cgl>rhx9TSsFZz7v(LD9hU4-qdPRt{9Ri-GPm-_ew`=2AhZ4U0MMJ|E_i12Ify4nQI?&ks*(ImL@iWx8$wrksn|?R8 z!e-=mC9oWYwCq5pFd2kb=Mmq6>p8#^^TNC?haz$`@ckkpo-q+*9_u;Kj|b+DQ@7#% zFF@`!qSwU)#)2rUb!ozXgQ2mcrF9?%$iYSyQWDZc%YBYduk`I$LFYfZaYI#u|2SV=|EALJF@O!_F5-kIQwDx0SqSp zgZZ{S?DUe}17Sag)$^gbd)Y3>2&Y_XVP&m>FpCqYMMnl$0=^t=@b-#2nsTLQFU!ci zC_^b4{;(W7gWvg}q3|h&OAydf9O!YsyF(nUrXWlCr-MFU80A>b#l^k38-rg8% z05w3KoI>5%))9h~+mS&nFnA46yQQzy)SRZ)yl3!id{QX+4VE~ZfF{*oY*hQO z^o?c{PTC94^Q{%%WDW#dI)u3eJ&gz_H_H-)KMy<48Ax{W>>ZlFXx8hFCDk!Q!(c31_ijXCp3&k4dtrlO)NN%|OJF%mm&A(JBwZAQmJ0t*Lv@Bg+jx%7WbHvNwa51QHD`F{zz3#l7cHt662V z1f0vnV^$RhS7qKfRO~jtW&oiB7eo3tJ^LUj$Wnii_JJ4rrA7Au{~}rDmPmi{eHb^2 zT6pv|z`=Ca0Bkv-UKfDgoD`cXYAag?d zm92RFWFDMZ&)Q9_Y?-WMOr0tTt8A4#RPL;b_@g?pZVZH^d)FIH=HNLrH(o_mkhPdHSuH?7s2a$Z0$<&fyLA^XDP~41xigU37 z$GJdL@Ke&+7ft)4J(!qgyfM?ov9WcnG}O0XFoikm;W}beeIh2E-Rzt%IaZp2x4APl zM7on6jF1vDSHS-|@~B84Kez4&Y(wcbX-s$c>PqWaskWK7>ZY^iVG+Z7BA5GRurB^J zlx2Ivj)o1%HK;}n?14ziw{j{(F5eqD8(E2_}WgM4y96Zp)l%_ccG@< zUJ9GMv{3Af!D~gDsYSxfsPG06K*j}JM@WI?C$xg{-011!^6)0D^7YOYSRIGGNIvzE zT|M^=$gMdLDZPcxsOcAj1F?^TW!OQm!tR55zgj7HR@FY^U6C0DVcs{pp&nb%u>I6t zJKj)mdFNP*4O|1;BE^otSA9~BM!c7X?~~j=8dg%Peh_ z!J(iT1DDxz&V??qVxZSyT5+Ju{c41ltA>~*$G_fVAY*C}x(4FXjCqa;Q(uQGZXZed zjJ@4>zlr!kvS7-WL@s?GbgtD-sPzZFuey z9}m38A;A)ao#PN*3)(|sd^3ue?1}XX=iGBzD z!^GU~kK=kaDeD8Yr3PaFKt1{u2lJ;&FDYwTYRtWAU@t^w`-?LbC(-wGjw(y&9L(`_ z8ueMz?AiZQ_{W74(E=@`w*s*I6lkXhps$f`AT^NfV5{nI%7Xk`ux7ysI-%^;#}ARF z#=B+y#I20d^d%x?Ek$v#-@!`)Q$WPHVR*ap7Tww!#lUh@KY^4^Vd2T*@|LB>-J=F} zMx56Q(_x6;!6SX7QLteC19#nnky=T#jxzFhITGJsiWayS=N|!Co&x&jfN5&v>v;`i z1=1nS(2A@ZjZRs}JunYZt&1NjiN?RmYg6f8Uw@#zX(bB#j<>?g$3gXEEKXFH4=jy= zj)$~*f&H3RN+N|a*Pu!@Fa-JT>XA3K`f@nYJhGJ8uxCt{9((c0#*tG-W@F#FpLXQW zt2f16y@Py1&IiQ|prNMh%dbYz$7{eI>FhGU7|ukB+ku+}PFYK^-qk>l^w|v? zJ1sp83l@Ss1XtdPYyzQlvn)LON_oDyyo&&ON=Ns}2t(YaC)v6yrT^J0tXF_NZ}Vv_ zUn`5|i=)$_i)!ra@>6p8n$)wsidPG$bPc!(9cmP1+YsyakoZ1P8b8(Zf}1D(kMfS)Gk zmnRzH6J=ocgEIfjkpWVNB|Q%bxeV6?sbLj@(obOq*JtIAZ6S@6{@d?Vm^h-l#wie# zdUt<;M8T;35mBx+HsNc48)TgnE^6L*aPxtp^hY1;2kXS#NXxB zGrtgwL11c1O|EP*!G>Pk+mcv{s&QI`*K<-J#q01njYE2+k0c;-04fY;ZZRJ>pz@tw zC01wD6U}ZJ*23LuV0%OjcZlXZV|iC&I%LWq^+lZ9pzV#aE))jAS^e`5SSZ2Gq~!#j zka8NZEo3I>EBFkD$ZvsI2vma&tp<9J#-peG7X$F|XyhG%7CGcX@>W-_pnW^R->5PpZ~gwN0`9l~ryE4&6=*cq;C$ zD|k>#{s;@_15e$mAVJ;@M}TguI8BE9`f?sC!^`JEH^q?FjqD^uJOJFmji463e%3&5 zfyCz+HT_|n+He8{OJ|@(4!JtuUL_67B_6vDr#ei|1^2yQ>poZ?KE-0Z+F$pdNG&)r z(2bL}K^=YGKYwJY@QfC@*W~f>n@O( z#ctF8B;A=~L}!*^!9ws%c^E4NQ(f$GSvX}va0cZ~q2!uYxb5!uR}g3=>2K*FQwBv* zA}y!ZW0ArS{1-FW?z+cR`z7y1k#CJhBsH)LxNMQfiEg7|dG}Shgj1QD+wTu#CDA%c z%cGb(dZ7x1W~btO(q$#)y*1L`1LUHI^&jv)WDTArBfEPdY5I%1lKc+p#PZ?6JjmxG z`B`77IDaX#+w{N7%V!-12izZ|G4WX(G32e(7e5(=lBMunTI)A5|D5@<>m~mwCaLOu zM()4|^7umR(cif&5ULX20mAucT-@YL6Ckvy{xDPuR@bpldVy^{-Qq=2i>r7I3`LG~ z->z7fGhIgY>#nQ&lYzyNlCPi;-il_flx)_5&9&77mVlPhG9Wj=hONhKkoI=qp}_Zn z>n}9hA4^?Z@&n9Kw0w!{6__>LF*9D5k6On|SL!;r;4pLt@e80nxGX#ogL^7-jVyw7 z(3ha2^GhUr} zpS<#T%^y{EQ0SB=A&{On+9*rwC@C+($=4U!*RN3;F7TmX3Jbd;A%&~im=-pdTDr2< zfceY&%&ZpKW!gzt?C?{#n_2_UD{=K*W8y$bH635pCvs-q=p0P3Fei!qsWN5JjK!*Y=iAKoGQ`kO;?L8Lk+wOo=ZLW=7bP6v%Nj&79;LM zkN8NV?2WQ949X_@B6~i3)=1MGX(7Thdk9Gn1wJ00g?trh-Q8FvFs>!7ObrCr_D4;- zRu-H+Ut;KmMm(%O-44im?w0kq~92e5`2N(^F2>0mHHFn&qEL0 z#M>VU(@<+J(0I&~pJ!z36j)<*-t$K+auUn#v7EOC)u@4wk?C^ajxL#V5d+?#{n?OF zE;I~&1(*{u1f# z1AY`Tefg6$>pIkZKckFR<1NlmrMvL*GYgV`7wqU}7$6mb-~x#S8$IoDYOI|b`rYs=p5*PRq42fF>&7$o zQynv(^(dQTl&(K(7YHIO?(h@h95GT4skN75^KK2QPy^JQUIHHJlK1uq-wwFxI&>j} zOU&DXa!VIFTpcCt`T?xCi_7J%&5Z84v|O(~&lB(a9JHfErvVoLuK+dxgTM|TJ^?s0 zNTQ0;DmelRP_A)(Ke@tibnY)t;V&(}=pZIv2D4e7PHE=G{4M*G_OJ7{5Jye`OM~{c|AxC`5KYLYk%!=_->%VTqU}TN+_j>4~;n8g*ly|0dC9?#(T{KqB9< z(>{P_X#(7K|AhO_JEvT1H~XpVC-cu9kqhbzzp#>Cg!S@>|KbMQO8T;HJpF(m<<|XA z$E2(E9W(1F^Nh3&niqtBjW*I6pbbF{o!Yt^d=?S=xDPRNq5$3@AvEN+YeXl zYUl4!>e2)f8S|8It-l3JX}Qs<4z!RO63`G--)k^RU4{4WXTIQ}tvK}J^R+aa*I!!y zubFiO_FDhS{zL}rw+7Xy0s2gr0lfz8!w~;DkHWoyI0^>yHsZl7MAUDwg6?^^=3aqo zCX2l8gc~tk@`lt9#R;9{A4vHPU~51QM+Ky>k@tZbh^DiQp1uT2yU)VvJF`d1l2OC# zThR9QT)4`f@KD_8zakw2@AYL`BPeGLu!XJ# z&iBaV(}<%c?gm`Y&;c~;6mD$7h2uD(|H^alM6}6Rlp>3~epdETN85`n!ZEH+s=p)K zDFL&l2ZOd@4c4O?h~pbZ`(p|BrIP)wk$chW@NT=4^JVmqm5q1GmYoxED#JdK zb;~&rtqbDm4a)QDo9=1L7^CD*X|2z|WT-yKC>zVV5T`f%O|8E4V>fzOQFUUion~8A zww>-q$#9HaO0Ix)#COP++l>mm zP6RKwcD#5oHS0h5nzXh4$(2_vqS7_swXngfEF5^Nv!KDNRMm;2khv2$#xQ%XY<$9j zmV?R_z;O<7PC)|^I2>3LNKUoWN5f$D#JcUhL|l^X1-5Pjp`CiLiiiNs=18xgV^b!Y?mV4ikrb! z;yKbR*MBh^vk)6%`COHP{>X>|!UVkP`f)H=2He(!5omTWq^ptjks83{e6kxSS^F0! zyIeaVFKk6s-dP~U(n}?yS};fth6Uu#lTlhXNixPp`mQ@+m15clJ}gaZAH#TZn73ua z2eBF;NM>s}<6tE{)}FoK!CH$&60uFadGHxorRnFTt(%%;4f;k6@TDvVQp4UkV3FPg z{YSuxE}3N+_1$n&HOUX55KaJ&f=En2Jrc4)T?JO#v6DEt;MNtY z*AMI64Y-+}mZzyeqRVdm2JPp%3h{W}pREYNFuEib1W1Ryu*}C6u(MPeo77%NxMm;g zXa&Eu7@%EAvJtkhic37z2K0H*)N(Qe8dmy% zg)gu^G8ft(4fLh05mcxKIE;4z7lg=<3mOQ1jVDVL(O+zc{Vs&jvk*ZSLI~{vft0Wh z2BJ71VwW8thJT|lqjN5V)w4+R6FaoLe^`&E;AJ6^$6Kb1KsPi1Z6!%tJv~UdlGwwf zY%V-mAzIkhsAXnbZ_w3^C6yGNPSItS^zA#W9Ig6EV?Xzu(+%+?9u!|P7e*QXwh1>+i@6gBZPfjd|ZWD zWXJLM3Ioe~#|q{0)=pm`V!P~cc9&Ie>I*J!$t%I#l3$8Sp?-`cYz$=A=>C%qMoXH5 zmV|n88WHjfPQIBg(eZO_t1Lgt?ApZq%5m}&{_cuKjziCH5RJDd)%tlAR3P!C=Pabk zJn{Weyd0 zH>!>q&xL%_Ae!d@pFI5H^OFY|6!O!pkJohB1$ka-@SH=c2%;VJ#C<0OFnF%K>83TC-^6mnHA(JoUT}W6HAf3A+ zk!7+)0A8yD(5~;KOYs0iQ=l+fWlCHM9o%dU7!s$UPsWS}7`M#WZ0)8i7wJ2ND_^6A z6#*Cy_n{3kFn1L33Njz!=Lupt(ASNCBELtenV{0B@28E#8O2xvbAIh|hEYylM9q3v z zL7UZU10)JX+5zjR4hOsj(fi#pqBdMFJ7EQ$JrC+e7BW0Y8)rMD_2CAaxkWgH7mqJN z6e;G@;J`B%L

PoH;uFQV?S0L66USc1U+SZKM;eA)qBnsKd;`$T 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..295d31e2583ed459b30de3ae70d78c2d0af9c54c GIT binary patch literal 6899 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 D)HwS# literal 0 HcmV?d00001 From 956f4c89a739d0f201d4886895c971db8ab20000 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:04:54 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[#435]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=EB=8A=A6=EC=9D=80=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80,=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=B6=A9=EB=8F=8C=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=8F=20=EB=B6=88=ED=95=84=EC=9A=94=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/en.lproj/Localizable.strings | 1 - .../Resources/ja.lproj/Localizable.strings | 1 - .../Resources/ko.lproj/Localizable.strings | 1 - .../Resources/vi.lproj/Localizable.strings | 1 - .../Network/Router/GoodPriceStoreRouter.swift | 17 +----- .../Presentation/Map/View/MainMapView.swift | 13 +++-- .../Presentation/Map/View/MapTopTabView.swift | 28 ++++++---- .../GoodPriceDetailSheetViewController.swift | 11 +--- .../MainMapViewController+Marker.swift | 18 ++++-- .../MainMapViewController+Network.swift | 14 +++-- .../MainMapViewController.swift | 55 +++++++++++++------ .../ViewController/MapLeafMarkerUpdater.swift | 17 ++++-- ...PartnershipDetailSheetViewController.swift | 21 +++---- .../CustomTabBarContainerController.swift | 5 +- .../Sources/Utility/Extension/String+.swift | 32 ----------- .../Sources/Utility/Literal/TextLiteral.swift | 4 -- 16 files changed, 112 insertions(+), 127 deletions(-) diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index f00f91da..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"; /// "취소" diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index f1e330f1..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" = "確認"; /// "취소" diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 5efc213e..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" = "확인"; /// "취소" diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index 157041ab..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"; /// "취소" diff --git a/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift b/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift index 382116e3..78a67582 100644 --- a/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/GoodPriceStoreRouter.swift @@ -10,8 +10,8 @@ import Foundation import Moya enum GoodPriceStoreRouter { - /// 목록 조회. category가 nil이면 전체 - case getStores(category: GoodPriceCategory?) + /// 전체 목록 조회 (업종 필터는 클라이언트에서 처리) + case getStores /// 상세 조회 case getStoreDetail(id: Int) } @@ -35,18 +35,7 @@ extension GoodPriceStoreRouter: TargetType { } var task: Task { - switch self { - case .getStores(let category): - guard let category, let serverValue = category.serverValue else { - return .requestPlain - } - return .requestParameters( - parameters: ["category": serverValue], - encoding: URLEncoding.queryString - ) - case .getStoreDetail: - return .requestPlain - } + return .requestPlain } var headers: [String: String]? { diff --git a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift index 8c7806c0..6803e962 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MainMapView.swift @@ -95,13 +95,16 @@ final class MainMapView: BaseUIView { // MARK: - Configuration - /// 단독 진입(착한가격업소 지도)에서는 상단 탭을 숨긴다 + /// 단독 진입(착한가격업소 지도)에서는 상단 탭을 숨기고 지도를 safe area 상단까지 올린다 func setTopTabVisible(_ visible: Bool) { topTabView.isHidden = !visible - topTabView.snp.remakeConstraints { - $0.top.equalTo(safeAreaLayoutGuide) - $0.leading.trailing.equalToSuperview() - if !visible { $0.height.equalTo(0) } + mapView.snp.remakeConstraints { + if visible { + $0.top.equalTo(topTabView.snp.bottom) + } else { + $0.top.equalTo(safeAreaLayoutGuide) + } + $0.leading.trailing.bottom.equalToSuperview() } } } diff --git a/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift b/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift index 5b5d9262..bd9741df 100644 --- a/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift +++ b/EATSSU/App/Sources/Presentation/Map/View/MapTopTabView.swift @@ -40,6 +40,12 @@ final class MapTopTabView: BaseUIView { 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) } @@ -75,10 +81,9 @@ final class MapTopTabView: BaseUIView { } indicatorView.snp.makeConstraints { - $0.bottom.equalToSuperview() + $0.bottom.leading.equalToSuperview() $0.height.equalTo(2) - $0.width.equalToSuperview().dividedBy(max(buttons.count, 1)) - $0.leading.equalToSuperview() + $0.width.equalToSuperview() } } @@ -92,21 +97,22 @@ final class MapTopTabView: BaseUIView { button.setTitleColor(buttonIndex == index ? .primary : .gray500, for: .normal) } - let update = { - self.layoutIfNeeded() - let tabWidth = self.bounds.width / CGFloat(max(self.buttons.count, 1)) - self.indicatorView.transform = CGAffineTransform(translationX: tabWidth * CGFloat(index), y: 0) - } - if animated { - UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseInOut], animations: update) + UIView.animate(withDuration: 0.25, delay: 0, options: [.curveEaseInOut]) { + self.layoutIfNeeded() + self.updateIndicatorTransform() + } } else { - update() + 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) } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift index 60b386c0..782a8b42 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift @@ -27,9 +27,6 @@ final class GoodPriceDetailSheetViewController: BaseViewController { private let store: GoodPriceStoreDTO - /// 상세 조회 실패 시 호출 (부모가 토스트 표시) - var onLoadFailed: (() -> Void)? - // MARK: - UI Components private let storeNameLabel = UILabel() @@ -155,7 +152,8 @@ final class GoodPriceDetailSheetViewController: BaseViewController { self.applyDetail(detail) case .failure(let error): print("착한가격업소 상세 조회 실패: \(error.localizedDescription)") - self.onLoadFailed?() + // 시트가 지도 위에 떠 있으므로 토스트는 시트 자신의 view에 띄운다 + self.showToast(message: TextLiteral.Map.storeLoadFailed, type: .danger) } } } @@ -184,10 +182,7 @@ final class GoodPriceDetailSheetViewController: BaseViewController { let menuPart = menu?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let pricePart: String if let price { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - let formatted = formatter.string(from: NSNumber(value: price)) ?? "\(price)" - pricePart = TextLiteral.Map.priceWon(formatted) + pricePart = TextLiteral.Map.priceWon(price.formattedWithCommas) } else { pricePart = "" } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index 67a2204e..63714b00 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -98,13 +98,19 @@ extension MainMapViewController { // 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 - clusterColor.setFill() + let image = renderer.image { context in + color.setFill() context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: size)) let paragraphStyle = NSMutableParagraphStyle() @@ -123,7 +129,12 @@ extension MainMapViewController { height: textSize.height )) } + + Self.clusterImageCache[cacheKey] = image + return image } + + private static var clusterImageCache: [String: UIImage] = [:] } // MARK: - Detail Sheets @@ -152,7 +163,6 @@ extension MainMapViewController { MapAnalyticsManager.shared.logClickGoodPriceStore(storeId: store.id) let detailVC = GoodPriceDetailSheetViewController(store: store) - detailVC.onLoadFailed = { [weak self] in self?.showStoreLoadFailedToast() } 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 4f962910..e2fdb770 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -13,12 +13,13 @@ 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 @@ -48,20 +49,19 @@ extension MainMapViewController { return } + let generation = beginLoad() NetworkService.shared.request( MyRouter.getMyPartnerships, 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.cachedMyPartnerships = partnerships self.applyPartnershipMarkers(from: partnerships, periodType: .normal) case .failure(let error): print("내 제휴 조회 실패: \(error.localizedDescription)") - self.cachedMyPartnerships = [] self.displayMarkers([]) self.showStoreLoadFailedToast() } @@ -150,16 +150,18 @@ extension MainMapViewController { /// 착한가격업소 마커 로드. 캐시가 있으면 카테고리만 필터, 없으면 전체 목록을 받아온다 func loadGoodPriceMarkers() { if !cachedGoodPriceStores.isEmpty { + _ = beginLoad() applyGoodPriceMarkers() return } + let generation = beginLoad() NetworkService.shared.request( - GoodPriceStoreRouter.getStores(category: nil), + GoodPriceStoreRouter.getStores, responseType: [GoodPriceStoreDTO].self, useAuth: false ) { [weak self] result in - guard let self = self else { return } + guard let self, self.isCurrentLoad(generation) else { return } switch result { case .success(let stores): self.cachedGoodPriceStores = stores diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index 95a53e4c..f7299c5e 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -48,11 +48,23 @@ final class MainMapViewController: BaseViewController { /// 가장 최근에 받아온 전체 제휴 목록 (축제 필터용 캐시) var cachedAllPartnerships: [PartnershipDTO] = [] - /// 내 학과 제휴 목록 캐시 (학교 제휴 탭 필터링용) - var cachedMyPartnerships: [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 @@ -127,16 +139,22 @@ final class MainMapViewController: BaseViewController { switch currentTab { case .partnership: - // 축제 노출 여부가 바뀌었을 수 있으므로 칩 재구성 - applyTabUI() - fetchDepartmentAndUpdateButton { [weak self] in - self?.loadPartnershipMarkers() - } + refreshPartnershipTab() case .goodPrice: loadGoodPriceMarkers() } } + /// 학과 정보를 다시 받아온 뒤 학교 제휴 마커 로드. 축제 노출 여부가 바뀌었을 수 있어 칩도 재구성 + private func refreshPartnershipTab() { + applyTabUI() + let generation = beginLoad() + fetchDepartmentAndUpdateButton { [weak self] in + guard let self, self.isCurrentLoad(generation) else { return } + self.loadPartnershipMarkers() + } + } + // MARK: - Configuration private func configureNavigationBar() { @@ -199,15 +217,13 @@ final class MainMapViewController: BaseViewController { private func switchTab(to tab: MapTab) { guard currentTab != tab else { return } currentTab = tab - applyTabUI() setInitialCameraPosition(animated: true) switch tab { case .partnership: - fetchDepartmentAndUpdateButton { [weak self] in - self?.loadPartnershipMarkers() - } + refreshPartnershipTab() case .goodPrice: + applyTabUI() MapAnalyticsManager.shared.logClickMapGoodPrice( collegeId: currentCollegeId, majorId: currentDepartmentId @@ -235,6 +251,7 @@ final class MainMapViewController: BaseViewController { } } + /// 축제 → click_map_festival, 그 외(내 학과 제휴 기준) → 학과 있으면 click_map_mine, 없으면 click_map_all private func logPartnershipFilterClick(_ filter: PartnershipFilter) { switch filter { case .festival: @@ -243,10 +260,14 @@ final class MainMapViewController: BaseViewController { majorId: currentDepartmentId ) case .all, .restaurant, .cafe, .pub: - MapAnalyticsManager.shared.logClickMapAll( - collegeId: currentCollegeId, - majorId: currentDepartmentId - ) + if let collegeId = currentCollegeId, let majorId = currentDepartmentId { + MapAnalyticsManager.shared.logClickMapMine(collegeId: collegeId, majorId: majorId) + } else { + MapAnalyticsManager.shared.logClickMapAll( + collegeId: currentCollegeId, + majorId: currentDepartmentId + ) + } } } @@ -282,9 +303,7 @@ final class MainMapViewController: BaseViewController { func reloadContent() { switch currentTab { case .partnership: - fetchDepartmentAndUpdateButton { [weak self] in - self?.loadPartnershipMarkers() - } + refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] loadGoodPriceMarkers() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift index 0d755eb1..5880fbf1 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapLeafMarkerUpdater.swift @@ -13,6 +13,9 @@ final class MapLeafMarkerUpdater: NMCDefaultLeafMarkerUpdater { var items: [MapMarkerItem] = [] + /// 줌/팬으로 같은 마커가 반복 노출될 때 재래스터화를 피하기 위한 캐시 (identifier 기준) + private var imageCache: [Int: UIImage] = [:] + override func updateLeafMarker(_ info: NMCLeafMarkerInfo, _ marker: NMFMarker) { super.updateLeafMarker(info, marker) @@ -20,10 +23,16 @@ final class MapLeafMarkerUpdater: NMCDefaultLeafMarkerUpdater { items.indices.contains(key.identifier) else { return } let item = items[key.identifier] - let markerView = MapMarkerView(icon: item.icon, title: item.title) - markerView.layoutIfNeeded() - markerView.frame = CGRect(origin: .zero, size: markerView.intrinsicContentSize) - let iconImage = markerView.toImage() + 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 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/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 60d50ca3..dd6ad1a8 100644 --- a/EATSSU/App/Sources/Utility/Extension/String+.swift +++ b/EATSSU/App/Sources/Utility/Extension/String+.swift @@ -10,38 +10,6 @@ import Foundation import UIKit extension String { - /// 문자열에서 마지막 단어만 지정한 색상으로 강조한 attributed string을 반환 - /// - Parameters: - /// - baseColor: 전체 문자열에 기본으로 적용할 색상 - /// - highlightColor: 마지막 단어에 적용할 강조 색상 - /// - Returns: 마지막 단어만 강조 색상이 적용된 `NSAttributedString` - func logoHighlightedLastWord( - baseColor: UIColor, - highlightColor: UIColor - ) -> NSAttributedString { - let attributedString = NSMutableAttributedString( - string: self, - 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 - ) - - return attributedString - } - /// 문자열 중 `highlight` 부분만 강조 색으로 칠한 AttributedString 반환. /// `highlight`가 포함되어 있지 않으면 전체가 baseColor func highlighted( diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index 15a60e58..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: "확인") From e81df29c1d30da8936b0e73439b28e60b3a37c35 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:54:25 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[#435]=20=EC=B0=A9=ED=95=9C=EA=B0=80?= =?UTF-8?q?=EA=B2=A9=EC=97=85=EC=86=8C=20=EC=8B=9C=ED=8A=B8=20=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=EB=A7=B5/=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=97=B0=EA=B2=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A7=80=EB=8F=84=20=EC=95=B1=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B3=B5=EC=9A=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Map/View/MapAppButtonBar.swift | 106 ++++++++++++ .../GoodPriceDetailSheetViewController.swift | 26 ++- ...PartnershipDetailSheetViewController.swift | 161 ++---------------- .../Utility/Helper/MapAppLauncher.swift | 140 +++++++++++++++ 4 files changed, 285 insertions(+), 148 deletions(-) create mode 100644 EATSSU/App/Sources/Presentation/Map/View/MapAppButtonBar.swift create mode 100644 EATSSU/App/Sources/Utility/Helper/MapAppLauncher.swift diff --git a/EATSSU/App/Sources/Presentation/Map/View/MapAppButtonBar.swift b/EATSSU/App/Sources/Presentation/Map/View/MapAppButtonBar.swift new file mode 100644 index 00000000..47c5886c --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Map/View/MapAppButtonBar.swift @@ -0,0 +1,106 @@ +// +// MapAppButtonBar.swift +// EATSSU +// +// Created by 황상환 on 8/29/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 상세 시트 하단의 카카오맵 / 네이버지도 이동 버튼 바 +final class MapAppButtonBar: BaseUIView { + + // MARK: - Constants + + enum Layout { + static let height: CGFloat = 56 + /// 시트 컨텐츠와 버튼 바 사이 최소 간격 + static let topSpacing: CGFloat = 6 + /// 버튼 바와 safe area 하단 사이 간격 + static let bottomInset: CGFloat = 10 + /// 시트 높이 계산 시 버튼 바가 차지하는 총 영역 + static var totalArea: CGFloat { topSpacing + height + bottomInset } + } + + // MARK: - Properties + + var onKakaoMapTap: (() -> Void)? + var onNaverMapTap: (() -> Void)? + + // MARK: - UI Components + + private let kakaoMapButton = UIButton() + private let naverMapButton = UIButton() + private let divider = UIView() + + // MARK: - View Setup + + override func configureUI() { + kakaoMapButton.configuration = Self.makeButtonConfiguration( + image: EATSSUDesignAsset.Images.kakaoMapLogo.image, + title: TextLiteral.Map.kakaoMap + ) + kakaoMapButton.addTarget(self, action: #selector(kakaoMapButtonTapped), for: .touchUpInside) + + naverMapButton.configuration = Self.makeButtonConfiguration( + image: EATSSUDesignAsset.Images.naverMapLogo.image, + title: TextLiteral.Map.naverMap + ) + naverMapButton.addTarget(self, action: #selector(naverMapButtonTapped), for: .touchUpInside) + + divider.backgroundColor = EATSSUDesignColors.Color.gray300 + + addSubviews(kakaoMapButton, naverMapButton, divider) + } + + override func setLayout() { + snp.makeConstraints { + $0.height.equalTo(Layout.height) + } + + kakaoMapButton.snp.makeConstraints { + $0.leading.top.bottom.equalToSuperview() + $0.trailing.equalTo(snp.centerX) + } + + naverMapButton.snp.makeConstraints { + $0.trailing.top.bottom.equalToSuperview() + $0.leading.equalTo(snp.centerX) + } + + divider.snp.makeConstraints { + $0.center.equalToSuperview() + $0.width.equalTo(1) + $0.height.equalTo(16) + } + } + + // MARK: - Actions + + @objc private func kakaoMapButtonTapped() { + onKakaoMapTap?() + } + + @objc private func naverMapButtonTapped() { + onNaverMapTap?() + } + + // MARK: - Helpers + + /// 지도 앱 이동 버튼 공통 Configuration 생성 + private static func makeButtonConfiguration(image: UIImage, title: String) -> UIButton.Configuration { + var config = UIButton.Configuration.plain() + config.image = image + config.imagePadding = 6 + config.baseForegroundColor = .label + config.attributedTitle = AttributedString( + title, + attributes: AttributeContainer([.font: UIFont.body2]) + ) + return config + } +} diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift index 782a8b42..0f623612 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift @@ -13,6 +13,7 @@ import SnapKit import EATSSUDesign /// 착한가격업소 마커 선택 시 업소명/업종/주소/대표메뉴/가격/대표이미지를 보여주는 바텀시트 +/// 하단에 카카오맵/네이버지도 이동 버튼 제공 (서버 URL이 없어 카카오 로컬 검색 → 폴백 경로로 연결) final class GoodPriceDetailSheetViewController: BaseViewController { // MARK: - Constants @@ -20,12 +21,12 @@ final class GoodPriceDetailSheetViewController: BaseViewController { private enum Layout { static let horizontalInset: CGFloat = 24 static let imageSize: CGFloat = 120 - static let bottomPadding: CGFloat = 24 } // MARK: - Properties private let store: GoodPriceStoreDTO + private let mapAppLauncher: MapAppLauncher // MARK: - UI Components @@ -37,11 +38,18 @@ final class GoodPriceDetailSheetViewController: BaseViewController { private let menuLabel = UILabel() private let textStackView = UIStackView() private let storeImageView = UIImageView() + private let mapAppButtonBar = MapAppButtonBar() // MARK: - Init init(store: GoodPriceStoreDTO) { self.store = store + // 착한가격업소는 서버가 지도 URL을 주지 않으므로 상호명 + 좌표 기반 검색으로 연결 + self.mapAppLauncher = MapAppLauncher(destination: .init( + name: store.storeName, + latitude: store.latitude, + longitude: store.longitude + )) super.init(nibName: nil, bundle: nil) modalPresentationStyle = .pageSheet } @@ -93,7 +101,10 @@ final class GoodPriceDetailSheetViewController: BaseViewController { storeImageView.layer.cornerRadius = 8 storeImageView.backgroundColor = .gray300 - view.addSubviews(textStackView, storeImageView) + mapAppButtonBar.onKakaoMapTap = { [weak self] in self?.mapAppLauncher.openKakaoMap() } + mapAppButtonBar.onNaverMapTap = { [weak self] in self?.mapAppLauncher.openNaverMap() } + + view.addSubviews(textStackView, storeImageView, mapAppButtonBar) } override func setLayout() { @@ -108,6 +119,14 @@ final class GoodPriceDetailSheetViewController: BaseViewController { $0.leading.equalToSuperview().inset(Layout.horizontalInset) $0.trailing.equalTo(storeImageView.snp.leading).offset(-16) } + + // large로 확장 시에도 버튼 바가 시트 하단에 붙어있도록 bottom 고정 + mapAppButtonBar.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(textStackView.snp.bottom).offset(MapAppButtonBar.Layout.topSpacing) + $0.top.greaterThanOrEqualTo(storeImageView.snp.bottom).offset(MapAppButtonBar.Layout.topSpacing) + $0.leading.trailing.equalToSuperview() + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(MapAppButtonBar.Layout.bottomInset) + } } // MARK: - Life Cycle @@ -194,9 +213,10 @@ final class GoodPriceDetailSheetViewController: BaseViewController { // MARK: - Height /// 텍스트/이미지 중 더 큰 쪽 기준으로 시트 높이 계산 + /// 버튼 바는 시트 하단에 고정이므로 컨텐츠 높이 기준으로 계산 func calculatePreferredHeight() -> CGFloat { view.layoutIfNeeded() let contentBottom = max(textStackView.frame.maxY, storeImageView.frame.maxY) - return contentBottom + Layout.bottomPadding + view.safeAreaInsets.bottom + return contentBottom + MapAppButtonBar.Layout.totalArea + view.safeAreaInsets.bottom } } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index b73bea55..9ec698ed 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -16,6 +16,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { // MARK: - Properties private let partnership: PartnershipDTO + private let mapAppLauncher: MapAppLauncher // MARK: - UI Components @@ -24,15 +25,19 @@ final class PartnershipDetailSheetViewController: BaseViewController { private let typeIconImageView = UIImageView() private let typeTextLabel = UILabel() private let infoListStackView = UIStackView() - private let mapButtonBarView = UIView() - private let kakaoMapButton = UIButton() - private let naverMapButton = UIButton() - private let mapButtonDivider = UIView() + private let mapAppButtonBar = MapAppButtonBar() // MARK: - Init init(partnership: PartnershipDTO) { self.partnership = partnership + self.mapAppLauncher = MapAppLauncher(destination: .init( + name: partnership.storeName, + latitude: partnership.latitude, + longitude: partnership.longitude, + kakaoMapUrl: partnership.kakaoMapUrl, + naverMapUrl: partnership.naverMapUrl + )) super.init(nibName: nil, bundle: nil) modalPresentationStyle = .pageSheet @@ -69,25 +74,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { infoListStackView.isLayoutMarginsRelativeArrangement = true infoListStackView.layoutMargins = .init(top: 10, left: 0, bottom: 10, right: 0) - kakaoMapButton.configuration = makeMapButtonConfiguration( - image: EATSSUDesignAsset.Images.kakaoMapLogo.image, - title: TextLiteral.Map.kakaoMap - ) - kakaoMapButton.addTarget(self, action: #selector(kakaoMapButtonTapped), for: .touchUpInside) - - naverMapButton.configuration = makeMapButtonConfiguration( - image: EATSSUDesignAsset.Images.naverMapLogo.image, - title: TextLiteral.Map.naverMap - ) - naverMapButton.addTarget(self, action: #selector(naverMapButtonTapped), for: .touchUpInside) - - mapButtonDivider.backgroundColor = EATSSUDesignColors.Color.gray300 + mapAppButtonBar.onKakaoMapTap = { [weak self] in self?.mapAppLauncher.openKakaoMap() } + mapAppButtonBar.onNaverMapTap = { [weak self] in self?.mapAppLauncher.openNaverMap() } - [kakaoMapButton, naverMapButton, mapButtonDivider].forEach { - mapButtonBarView.addSubview($0) - } - - [storeNameLabel, typeStackView, infoListStackView, mapButtonBarView].forEach { + [storeNameLabel, typeStackView, infoListStackView, mapAppButtonBar].forEach { view.addSubview($0) } } @@ -109,27 +99,10 @@ final class PartnershipDetailSheetViewController: BaseViewController { } // large로 확장 시에도 버튼 바가 시트 하단에 붙어있도록 bottom 고정 - mapButtonBarView.snp.makeConstraints { - $0.top.greaterThanOrEqualTo(infoListStackView.snp.bottom).offset(6) + mapAppButtonBar.snp.makeConstraints { + $0.top.greaterThanOrEqualTo(infoListStackView.snp.bottom).offset(MapAppButtonBar.Layout.topSpacing) $0.leading.trailing.equalToSuperview() - $0.height.equalTo(56) - $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(10) - } - - kakaoMapButton.snp.makeConstraints { - $0.leading.top.bottom.equalToSuperview() - $0.trailing.equalTo(mapButtonBarView.snp.centerX) - } - - naverMapButton.snp.makeConstraints { - $0.trailing.top.bottom.equalToSuperview() - $0.leading.equalTo(mapButtonBarView.snp.centerX) - } - - mapButtonDivider.snp.makeConstraints { - $0.center.equalToSuperview() - $0.width.equalTo(1) - $0.height.equalTo(16) + $0.bottom.equalTo(view.safeAreaLayoutGuide).inset(MapAppButtonBar.Layout.bottomInset) } } @@ -180,23 +153,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { /// 버튼 바는 시트 하단에 고정이므로 리스트 높이 기준으로 계산 func calculatePreferredHeight() -> CGFloat { view.layoutIfNeeded() - let buttonBarArea: CGFloat = 6 + 56 + 10 - let bottomPadding: CGFloat = view.safeAreaInsets.bottom - - return infoListStackView.frame.maxY + buttonBarArea + bottomPadding - } - - /// 지도 앱 이동 버튼 공통 Configuration 생성 - private func makeMapButtonConfiguration(image: UIImage, title: String) -> UIButton.Configuration { - var config = UIButton.Configuration.plain() - config.image = image - config.imagePadding = 6 - config.baseForegroundColor = .label - config.attributedTitle = AttributedString( - title, - attributes: AttributeContainer([.font: UIFont.body2]) - ) - return config + return infoListStackView.frame.maxY + MapAppButtonBar.Layout.totalArea + view.safeAreaInsets.bottom } /// 제휴 정보 카드 뷰 생성 @@ -255,90 +212,4 @@ final class PartnershipDetailSheetViewController: BaseViewController { return paddedContainer } - - // MARK: - Actions - - /// 카카오맵 장소 상세 페이지로 이동 - /// 서버 제공 URL 우선 → 없으면 로컬 API 매칭 → 실패 시 좌표 핀 폴백 - @objc private func kakaoMapButtonTapped() { - // 서버가 준 place URL(place.map.kakao.com/{id})에서 id를 추출해 앱 스킴으로 연결 - if let urlString = partnership.kakaoMapUrl, - let webURL = URL(string: urlString), - Int(webURL.lastPathComponent) != nil { - let appURL = URL(string: "kakaomap://place?id=\(webURL.lastPathComponent)") - openMapApp(appURL: appURL, fallbackURL: webURL) - return - } - - KakaoLocalService.shared.searchNearestPlace( - keyword: partnership.storeName, - latitude: partnership.latitude, - longitude: partnership.longitude - ) { [weak self] place in - guard let self else { return } - - if let place { - let appURL = URL(string: "kakaomap://place?id=\(place.id)") - self.openMapApp(appURL: appURL, fallbackURL: URL(string: place.placeURL)) - } else { - self.openKakaoMapByCoordinate() - } - } - } - - /// 카카오맵 좌표 핀으로 이동 (미설치 시 카카오맵 웹) - private func openKakaoMapByCoordinate() { - let appURL = URL(string: "kakaomap://look?p=\(partnership.latitude),\(partnership.longitude)") - let encodedName = percentEncodedForMapURL(partnership.storeName) - let webURL = URL( - string: "https://map.kakao.com/link/map/\(encodedName),\(partnership.latitude),\(partnership.longitude)" - ) - openMapApp(appURL: appURL, fallbackURL: webURL) - } - - /// 지도 웹 URL 경로에 안전하게 넣을 수 있도록 경로/구분자 문자까지 인코딩 - private func percentEncodedForMapURL(_ text: String) -> String { - var allowed = CharacterSet.urlPathAllowed - allowed.remove(charactersIn: "/?&,") - return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" - } - - /// 네이버지도 플레이스로 이동 - /// 서버 제공 URL 우선(유니버설 링크로 앱/웹 자동 분기) → 없으면 정제된 상호명 검색 폴백 - @objc private func naverMapButtonTapped() { - if let urlString = partnership.naverMapUrl, - let url = URL(string: urlString) { - UIApplication.shared.open(url) - return - } - - KakaoLocalService.shared.searchNearestPlace( - keyword: partnership.storeName, - latitude: partnership.latitude, - longitude: partnership.longitude - ) { [weak self] place in - guard let self else { return } - self.openNaverMapSearch(query: place?.placeName ?? self.partnership.storeName) - } - } - - private func openNaverMapSearch(query: String) { - var components = URLComponents(string: "nmap://search") - components?.queryItems = [ - URLQueryItem(name: "query", value: query), - URLQueryItem(name: "appname", value: Bundle.main.bundleIdentifier ?? "") - ] - let encodedQuery = percentEncodedForMapURL(query) - let webURL = URL(string: "https://map.naver.com/p/search/\(encodedQuery)") - openMapApp(appURL: components?.url, fallbackURL: webURL) - } - - private func openMapApp(appURL: URL?, fallbackURL: URL?) { - if let appURL, UIApplication.shared.canOpenURL(appURL) { - UIApplication.shared.open(appURL) - } else if let fallbackURL { - UIApplication.shared.open(fallbackURL) - } - } - } diff --git a/EATSSU/App/Sources/Utility/Helper/MapAppLauncher.swift b/EATSSU/App/Sources/Utility/Helper/MapAppLauncher.swift new file mode 100644 index 00000000..aa91f9fa --- /dev/null +++ b/EATSSU/App/Sources/Utility/Helper/MapAppLauncher.swift @@ -0,0 +1,140 @@ +// +// MapAppLauncher.swift +// EATSSU +// +// Created by 황상환 on 8/29/26. +// + +import UIKit + +/// 매장 정보를 카카오맵/네이버지도 앱으로 연결하는 헬퍼 +/// +/// 서버 제공 URL 우선 → 카카오 로컬 검색 매칭 → 좌표/검색어 폴백 순서로 시도한다. +/// 서버 URL이 없는 매장(착한가격업소 등)은 `Destination`의 URL을 nil로 넘기면 +/// 자연스럽게 검색 → 폴백 경로를 탄다. +/// 카카오 로컬 검색 결과는 인스턴스 단위로 캐시해 두 버튼이 공유한다. +final class MapAppLauncher { + + // MARK: - Model + + struct Destination { + let name: String + let latitude: Double + let longitude: Double + var kakaoMapUrl: String? = nil + var naverMapUrl: String? = nil + } + + // MARK: - Properties + + private let destination: Destination + + /// 카카오 로컬 검색 결과 캐시. 실패(nil)는 일시적 네트워크 오류일 수 있어 캐시하지 않는다 + private var cachedPlace: KakaoLocalService.Place? + + // MARK: - Init + + init(destination: Destination) { + self.destination = destination + } + + // MARK: - Kakao Map + + /// 카카오맵 장소 상세 페이지로 이동 + /// 서버 제공 URL 우선 → 없으면 로컬 API 매칭 → 실패 시 좌표 핀 폴백 + func openKakaoMap() { + // 서버가 준 place URL(place.map.kakao.com/{id})에서 id를 추출해 앱 스킴으로 연결 + if let urlString = destination.kakaoMapUrl, + let webURL = URL(string: urlString), + Int(webURL.lastPathComponent) != nil { + let appURL = URL(string: "kakaomap://place?id=\(webURL.lastPathComponent)") + open(appURL: appURL, fallbackURL: webURL) + return + } + + searchPlace { [weak self] place in + guard let self else { return } + + if let place { + let appURL = URL(string: "kakaomap://place?id=\(place.id)") + self.open(appURL: appURL, fallbackURL: URL(string: place.placeURL)) + } else { + self.openKakaoMapByCoordinate() + } + } + } + + /// 카카오맵 좌표 핀으로 이동 (미설치 시 카카오맵 웹) + private func openKakaoMapByCoordinate() { + let appURL = URL(string: "kakaomap://look?p=\(destination.latitude),\(destination.longitude)") + let encodedName = Self.percentEncodedForMapURL(destination.name) + let webURL = URL( + string: "https://map.kakao.com/link/map/\(encodedName),\(destination.latitude),\(destination.longitude)" + ) + open(appURL: appURL, fallbackURL: webURL) + } + + // MARK: - Naver Map + + /// 네이버지도 플레이스로 이동 + /// 서버 제공 URL 우선(유니버설 링크로 앱/웹 자동 분기) → 없으면 정제된 상호명 검색 폴백 + func openNaverMap() { + if let urlString = destination.naverMapUrl, + let url = URL(string: urlString) { + UIApplication.shared.open(url) + return + } + + searchPlace { [weak self] place in + guard let self else { return } + self.openNaverMapSearch(query: place?.placeName ?? self.destination.name) + } + } + + private func openNaverMapSearch(query: String) { + var components = URLComponents(string: "nmap://search") + components?.queryItems = [ + URLQueryItem(name: "query", value: query), + URLQueryItem(name: "appname", value: Bundle.main.bundleIdentifier ?? "") + ] + let encodedQuery = Self.percentEncodedForMapURL(query) + let webURL = URL(string: "https://map.naver.com/p/search/\(encodedQuery)") + open(appURL: components?.url, fallbackURL: webURL) + } + + // MARK: - Search + + /// 카카오 로컬 검색. 성공 결과는 캐시해 이후 호출에서 네트워크 없이 즉시 반환 + private func searchPlace(completion: @escaping (KakaoLocalService.Place?) -> Void) { + if let cachedPlace { + completion(cachedPlace) + return + } + + KakaoLocalService.shared.searchNearestPlace( + keyword: destination.name, + latitude: destination.latitude, + longitude: destination.longitude + ) { [weak self] place in + self?.cachedPlace = place + completion(place) + } + } + + // MARK: - Open + + private func open(appURL: URL?, fallbackURL: URL?) { + if let appURL, UIApplication.shared.canOpenURL(appURL) { + UIApplication.shared.open(appURL) + } else if let fallbackURL { + UIApplication.shared.open(fallbackURL) + } + } + + /// 지도 웹 URL 경로에 안전하게 넣을 수 있도록 경로/구분자 문자까지 인코딩 + private static func percentEncodedForMapURL(_ text: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/?&,") + return text.addingPercentEncoding(withAllowedCharacters: allowed) ?? "" + } +} From 13a03f1a48f061f6ee9b659aaececc77aeca5395 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sun, 30 Aug 2026 19:09:06 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[#435]=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81:=20=ED=95=99=EA=B3=BC=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=A4=91=20=ED=95=84=ED=84=B0=20=ED=83=AD?= =?UTF-8?q?=20=EC=98=A4=ED=8C=90=20=EB=B0=A9=EC=A7=80,=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8B=B1,=20=ED=81=B4=EB=9F=AC=EC=8A=A4=ED=84=B0=20=EC=83=89?= =?UTF-8?q?=20=EA=B3=A0=EC=A0=95,=20=EC=B6=95=EC=A0=9C=20=EC=B9=A9=20?= =?UTF-8?q?=EB=8B=A4=EA=B5=AD=EC=96=B4,=20=EC=B6=95=EC=A0=9C=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=8F=20=EC=9E=94=EC=97=AC=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/en.lproj/Localizable.strings | 4 +- .../Resources/ja.lproj/Localizable.strings | 4 +- .../Resources/ko.lproj/Localizable.strings | 4 +- .../Resources/vi.lproj/Localizable.strings | 4 +- .../Data/Firebase/MapAnalyticsManager.swift | 11 ++---- .../Auth/View/GoodPriceEntryButton.swift | 5 ++- .../GoodPriceDetailSheetViewController.swift | 4 +- .../MainMapViewController+Marker.swift | 5 ++- .../MainMapViewController+Network.swift | 13 ++++++- .../MainMapViewController.swift | 38 ++++++++++++++++--- .../MapClusterMarkerUpdater.swift | 5 ++- .../CustomTabBarContainerController.swift | 4 +- .../Sources/Utility/Literal/TextLiteral.swift | 9 ++--- 13 files changed, 71 insertions(+), 39 deletions(-) diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 12bdb4ae..6d85cb8e 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -177,8 +177,8 @@ "map.map" = "Map"; /// MainMapView - "전체" "map.all" = "All"; -/// MainMapView - "내 제휴" -"map.myPartner" = "My Partners"; +/// "축제" +"map.festival" = "Festival"; /// NoDepartmentSheetVC - "학과를 입력하고\n나만의 제휴를 확인해보세요!" "map.inputDepartment" = "Enter your department\n and check your personalized partnerships!"; /// NoDepartmentSheetVC - "학과 입력하기" diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index 2ef9198e..549ec2a6 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -178,8 +178,8 @@ "map.map" = "マップ"; /// MainMapView - "전체" "map.all" = "すべて"; -/// MainMapView - "내 제휴" -"map.myPartner" = "自分の提携"; +/// "축제" +"map.festival" = "学園祭"; /// NoDepartmentSheetVC - "학과를 입력하고\n나만의 제휴를 확인해보세요!" "map.inputDepartment" = "学科を入力して\n自分だけの提携を確認してみましょう!"; /// NoDepartmentSheetVC - "학과 입력하기" diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 7f449149..5b67593e 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -178,8 +178,8 @@ "map.map" = "지도"; /// MainMapView - "전체" "map.all" = "전체"; -/// MainMapView - "내 제휴" -"map.myPartner" = "내 제휴"; +/// "축제" +"map.festival" = "축제"; /// NoDepartmentSheetVC - "학과를 입력하고\n나만의 제휴를 확인해보세요!" "map.inputDepartment" = "학과를 입력하고\n나만의 제휴를 확인해보세요!"; /// NoDepartmentSheetVC - "학과 입력하기" diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index f91b4bd5..edb04424 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -178,8 +178,8 @@ "map.map" = "Bản đồ"; /// MainMapView - "전체" "map.all" = "Tất cả"; -/// MainMapView - "내 제휴" -"map.myPartner" = "Ưu đãi của tôi"; +/// "축제" +"map.festival" = "Lễ hội"; /// NoDepartmentSheetVC - "학과를 입력하고\n나만의 제휴를 확인해보세요!" "map.inputDepartment" = "Nhập ngành của bạn\nvà kiểm tra ưu đãi dành riêng cho bạn!"; /// NoDepartmentSheetVC - "학과 입력하기" diff --git a/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift b/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift index 46e065e2..882e1dcd 100644 --- a/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift +++ b/EATSSU/App/Sources/Data/Firebase/MapAnalyticsManager.swift @@ -35,24 +35,19 @@ final class MapAnalyticsManager { static let goodPriceStoreId = "good_price_store_id" } - enum MapDefaultType: String { - case general - case festival - } - // MARK: - Logging Methods /** #1 하단 탭바에서 '지도'를 클릭했을 때 호출 - Parameter collegeId: 사용자의 단과대 ID (학과 미설정 시 nil) - Parameter majorId: 사용자의 학과 ID (학과 미설정 시 nil) - - Parameter defaultType: 지도 진입 시 디폴트 화면 + - Note: 지도는 항상 학교 제휴 > 전체 필터로 진입하므로 default_type은 "general" 고정 (대시보드 호환용 파라미터 유지) */ - func logClickMap(collegeId: Int?, majorId: Int?, defaultType: MapDefaultType) { + func logClickMap(collegeId: Int?, majorId: Int?) { AnalyticsService.logEvent( Event.clickMap, parameters: makeParameters(collegeId: collegeId, majorId: majorId, extra: [ - Parameter.defaultType: defaultType.rawValue + Parameter.defaultType: "general" ]) ) } diff --git a/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift b/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift index d78c8f62..6b865602 100644 --- a/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift +++ b/EATSSU/App/Sources/Presentation/Auth/View/GoodPriceEntryButton.swift @@ -17,7 +17,10 @@ final class GoodPriceEntryButton: UIControl { // MARK: - UI Components private let iconBackgroundView = UIView() - private let iconImageView = UIImageView(image: EATSSUDesignAsset.Images.restaurantPin.image) + /// 에셋이 템플릿이 아니라서 tintColor(흰색)가 먹도록 렌더링 모드를 지정 + private let iconImageView = UIImageView( + image: EATSSUDesignAsset.Images.restaurantPin.image.withRenderingMode(.alwaysTemplate) + ) private let titleLabel = UILabel() private let subTitleLabel = UILabel() private let textStackView = UIStackView() diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift index 0f623612..794c0b80 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/GoodPriceDetailSheetViewController.swift @@ -185,9 +185,7 @@ final class GoodPriceDetailSheetViewController: BaseViewController { 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) - } + storeImageView.kfSetImage(url: detail.imageUrl) if #available(iOS 16.0, *) { sheetPresentationController?.animateChanges { diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift index 63714b00..fa5a7ca2 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Marker.swift @@ -29,6 +29,8 @@ extension MainMapViewController { let clusterUpdater = MapClusterMarkerUpdater() clusterUpdater.viewController = self + // 응답 대기 중 필터가 바뀌어도 기존 클러스터는 표시 당시 색을 유지하도록 색을 고정 + clusterUpdater.color = clusterColor builder.clusterMarkerUpdater = clusterUpdater let newClusterer = builder.build() @@ -99,8 +101,7 @@ extension MainMapViewController { // MARK: - Cluster Image /// 클러스터 마커용 원형 이미지 생성 (개수 표시). 줌/팬마다 재호출되므로 (색상, 개수)별로 캐시 - func makeClusterImage(count: Int) -> UIImage { - let color = clusterColor + func makeClusterImage(count: Int, color: UIColor) -> UIImage { let cacheKey = "\(color.hashValue)-\(count)" if let cached = Self.clusterImageCache[cacheKey] { return cached diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index e2fdb770..8ec27bec 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -12,7 +12,14 @@ import Foundation extension MainMapViewController { /// 전체 제휴 데이터를 받아 캐시에 저장하고 축제 제휴 마커를 표시 + /// 캐시가 있으면 재요청 없이 사용한다 (탭바 재탭 시 reloadContent가 캐시를 비움) func refreshAllPartnerships() { + if !cachedAllPartnerships.isEmpty { + _ = beginLoad() + applyPartnershipMarkers(from: cachedAllPartnerships, periodType: .festival) + return + } + let generation = beginLoad() NetworkService.shared.request( PartnershipRouter.getAllPartnerships, @@ -28,12 +35,14 @@ extension MainMapViewController { case .failure(let error): print("제휴 조회 실패: \(error.localizedDescription)") self.cachedAllPartnerships = [] + #if !DEBUG self.displayMarkers([]) self.showStoreLoadFailedToast() + #endif } #if DEBUG - // 서버에 제휴 데이터가 없는 동안 Mock으로 대체 (DEBUG 빌드 전용) + // 서버에 제휴 데이터가 없거나 실패한 동안 Mock으로 대체 (DEBUG 빌드 전용, 실패 토스트는 띄우지 않음) if self.cachedAllPartnerships.isEmpty { self.cachedAllPartnerships = PartnershipMockData.samples self.applyPartnershipMarkers(from: self.cachedAllPartnerships, periodType: .festival) @@ -96,7 +105,7 @@ extension MainMapViewController { } } - func fetchDepartmentAndUpdateButton(completion: (() -> Void)? = nil) { + func fetchDepartment(completion: (() -> Void)? = nil) { NetworkService.shared.request( MyRouter.getDepartment, responseType: GetDepartmentResponse.self, diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index f7299c5e..fe9e1037 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -65,6 +65,15 @@ final class MainMapViewController: BaseViewController { generation == loadGeneration } + /// 학과 조회 상태. 조회가 끝나기 전에 필터를 탭해도 "학과 없음"으로 오판하지 않도록 구분 + private enum DepartmentLoadState { + case idle, loading, loaded + } + private var departmentLoadState: DepartmentLoadState = .idle + + /// 현재 칩 바에 그려진 필터 목록. 탭 시 Remote Config를 다시 읽지 않고 이 스냅샷의 인덱스를 사용 + private var displayedPartnershipFilters: [PartnershipFilter] = [] + // MARK: - State private(set) var currentTab: MapTab @@ -149,8 +158,11 @@ final class MainMapViewController: BaseViewController { private func refreshPartnershipTab() { applyTabUI() let generation = beginLoad() - fetchDepartmentAndUpdateButton { [weak self] in - guard let self, self.isCurrentLoad(generation) else { return } + departmentLoadState = .loading + fetchDepartment { [weak self] in + guard let self else { return } + self.departmentLoadState = .loaded + guard self.isCurrentLoad(generation) else { return } self.loadPartnershipMarkers() } } @@ -198,6 +210,7 @@ final class MainMapViewController: BaseViewController { case .partnership: let filters = visiblePartnershipFilters if !filters.contains(partnershipFilter) { partnershipFilter = .all } + displayedPartnershipFilters = filters root.filterChipBar.highlightColor = clusterColor root.filterChipBar.configure( titles: filters.map { $0.title }, @@ -235,7 +248,7 @@ final class MainMapViewController: BaseViewController { private func didSelectFilter(at index: Int) { switch currentTab { case .partnership: - let filters = visiblePartnershipFilters + let filters = displayedPartnershipFilters guard filters.indices.contains(index) else { return } partnershipFilter = filters[index] root.filterChipBar.highlightColor = clusterColor @@ -249,6 +262,9 @@ final class MainMapViewController: BaseViewController { MapAnalyticsManager.shared.logClickGoodPriceCategory(category: goodPriceCategory) loadGoodPriceMarkers() } + + // 필터가 바뀌면 캠퍼스 주변으로 되돌려 새 마커가 바로 보이게 한다 (필터 전환 전 동작과 동일) + setInitialCameraPosition(animated: true) } /// 축제 → click_map_festival, 그 외(내 학과 제휴 기준) → 학과 있으면 click_map_mine, 없으면 click_map_all @@ -281,9 +297,18 @@ final class MainMapViewController: BaseViewController { } guard hasDepartment else { - displayMarkers([]) - presentNoDepartmentSheetIfNeeded() - return + switch departmentLoadState { + case .loading: + // 조회가 끝나면 refreshPartnershipTab 완료 블록이 현재 필터로 이어서 로드한다 + return + case .idle: + refreshPartnershipTab() + return + case .loaded: + displayMarkers([]) + presentNoDepartmentSheetIfNeeded() + return + } } fetchMyPartnerships() } @@ -303,6 +328,7 @@ final class MainMapViewController: BaseViewController { func reloadContent() { switch currentTab { case .partnership: + cachedAllPartnerships = [] refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift index d23f9d9a..c5135653 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MapClusterMarkerUpdater.swift @@ -13,12 +13,15 @@ final class MapClusterMarkerUpdater: NMCDefaultClusterMarkerUpdater { weak var viewController: MainMapViewController? + /// 클러스터 원 색상. displayMarkers 시점에 고정되어 늦은 응답 동안 필터 색과 섞이지 않는다 + var color: UIColor = .primary + override func updateClusterMarker(_ info: NMCClusterMarkerInfo, _ marker: NMFMarker) { super.updateClusterMarker(info, marker) marker.captionText = "" - guard let image = viewController?.makeClusterImage(count: info.size) else { return } + guard let image = viewController?.makeClusterImage(count: info.size, color: color) else { return } marker.iconImage = NMFOverlayImage(image: image) marker.width = 40 diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index 944eeb40..a70a603a 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -273,11 +273,9 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { // 지도 탭 클릭 시 Firebase 이벤트 호출 (로그인된 상태에서만) if selectedTab == .map { let userInfo = UserInfoManager.shared.getCurrentUserInfo() - // 지도는 항상 학교 제휴 > 전체 필터로 진입 (축제는 칩 선택 시에만) MapAnalyticsManager.shared.logClickMap( collegeId: userInfo?.collegeId, - majorId: userInfo?.departmentId, - defaultType: .general + majorId: userInfo?.departmentId ) } diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index 3c69b3fb..b4cbbe0f 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -526,13 +526,12 @@ enum TextLiteral { Localization.localized("map.all", fallback: "전체") } - /// MainMapView - "내 제휴" - static var myPartner: String { - Localization.localized("map.myPartner", fallback: "내 제휴") - } /// MainMapView - "축제" - static let festival: String = "축제" + /// "축제" + static var festival: String { + Localization.localized("map.festival", fallback: "축제") + } /// NoDepartmentSheetVC - "학과를 입력하고\n나만의 제휴를 확인해보세요!" static var inputDepartment: String {