diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e8480d34..998f7540 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -24,8 +24,17 @@ jobs: xcodebuild -version # 러너 이미지에는 기본 Xcode용 런타임만 있어, 고정한 Xcode에 맞는 iOS 시뮬레이터 런타임을 내려받는다 + # 러너의 일시적 다운로드 실패("Unable to connect to simulator")가 있어 재시도한다 - name: Download iOS simulator runtime - run: sudo xcodebuild -downloadPlatform iOS + run: | + for attempt in 1 2 3; do + if sudo xcodebuild -downloadPlatform iOS; then + exit 0 + fi + echo "downloadPlatform failed (attempt $attempt)" + [ "$attempt" -lt 3 ] && sleep 30 + done + exit 1 - name: Create secret xcconfig files env: diff --git a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift index e72e7c28..1ad36bb3 100644 --- a/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift +++ b/EATSSU/App/Sources/Data/Like/PartnershipLikeManager.swift @@ -85,12 +85,14 @@ final class PartnershipLikeManager { ) { [weak self] result in guard let self else { return } switch result { - case .success(let stores): + case .success(let rawStores): // 조회 중에 토글이 있었으면 이 응답은 이미 낡은 것이므로 로컬 상태를 유지한다 guard version == self.stateVersion else { completion(.success(self.likedStores)) return } + // 서버가 같은 업소를 찜 항목 단위로 여러 줄 반환할 수 있어 업소 단위로 병합한다 + let stores = Self.mergedByStore(rawStores) self.toggledStates = [:] self.likedPartnershipIds = Set(stores.flatMap(Self.likedIds(in:))) self.likedStores = self.sortedByRecent(stores) @@ -140,6 +142,9 @@ final class PartnershipLikeManager { case .success: toggledIds.append(id) case .failure(let error): + #if DEBUG + print("찜 토글 실패 partnershipId=\(id):", error) + #endif if firstError == nil { firstError = error } } group.leave() @@ -199,6 +204,32 @@ final class PartnershipLikeManager { .map(\.id) } + /// 같은 업소(storeKey)가 여러 줄로 온 응답을 한 줄로 병합. 첫 등장 순서를 유지하고 제휴 항목은 id 기준으로 합친다 + static func mergedByStore(_ stores: [PartnershipDTO]) -> [PartnershipDTO] { + var order: [String] = [] + var byKey: [String: PartnershipDTO] = [:] + for store in stores { + guard let existing = byKey[store.storeKey] else { + order.append(store.storeKey) + byKey[store.storeKey] = store + continue + } + let knownIds = Set(existing.partnershipInfos.map(\.id)) + let mergedInfos = existing.partnershipInfos + + store.partnershipInfos.filter { !knownIds.contains($0.id) } + byKey[store.storeKey] = PartnershipDTO( + storeName: existing.storeName, + longitude: existing.longitude, + latitude: existing.latitude, + restaurantType: existing.restaurantType, + naverMapUrl: existing.naverMapUrl ?? store.naverMapUrl, + kakaoMapUrl: existing.kakaoMapUrl ?? store.kakaoMapUrl, + partnershipInfos: mergedInfos + ) + } + return order.compactMap { byKey[$0] } + } + /// 찜 목록 응답에서 실제 찜된 항목 id. 플래그가 하나도 없으면(구버전 응답) 소속 항목 전부로 간주 static func likedIds(in store: PartnershipDTO) -> [Int] { let flagged = store.partnershipInfos.filter(\.isLiked).map(\.id) diff --git a/EATSSU/App/Sources/Presentation/Home/Model/ChangeMenuTableResponse+Display.swift b/EATSSU/App/Sources/Presentation/Home/Model/ChangeMenuTableResponse+Display.swift index b15f8ebe..2c8db11e 100644 --- a/EATSSU/App/Sources/Presentation/Home/Model/ChangeMenuTableResponse+Display.swift +++ b/EATSSU/App/Sources/Presentation/Home/Model/ChangeMenuTableResponse+Display.swift @@ -14,10 +14,11 @@ extension ChangeMenuTableResponse { AppLanguageManager.shared.currentLanguage.supportsMealTranslation } - /// 변동식단(`/meals`, `menus-info`) 조회 시 서버에 넘길 `language` 파라미터. 번역 미지원 언어는 nil(한국어 응답) + /// 변동식단(`/meals`, `menus-info`) 조회 시 서버에 넘길 `language` 파라미터. + /// 서버가 영어 번역만 제공하므로 비한국어 언어는 모두 EN을 요청한다 (한국어는 nil) static var mealLanguageParameter: String? { let language = AppLanguageManager.shared.currentLanguage - return language.supportsMealTranslation ? language.serverCode : nil + return language.supportsMealTranslation ? AppLanguage.english.serverCode : nil } /// 고정메뉴(`/menus`) 조회 시 서버에 넘길 `language` 파라미터. 한국어 외 전부 전달 (EN/JA 번역, VI는 서버가 영어 폴백) diff --git a/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift index f3e92a6c..8973e40e 100644 --- a/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift +++ b/EATSSU/App/Sources/Presentation/Like/View/LikedPartnershipCell.swift @@ -29,8 +29,9 @@ final class LikedPartnershipCell: UITableViewCell { private enum Layout { static let horizontalInset: CGFloat = 24 static let iconSize: CGFloat = 36 - static let checkSize: CGFloat = 18 - static let checkLeading: CGFloat = 27 + // 에셋은 24pt 캔버스에 원 20pt(여백 2pt)라, 22pt로 렌더해야 디자인의 18pt 원 크기가 된다 + static let checkSize: CGFloat = 22 + static let checkLeading: CGFloat = 25 static let textSpacing: CGFloat = 12 static let checkTextSpacing: CGFloat = 15 static let topInset: CGFloat = 18 diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift index ec123230..fbb52f18 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/LikedPartnershipEditViewController.swift @@ -16,11 +16,14 @@ final class LikedPartnershipEditViewController: BaseViewController { // MARK: - Constants - /// 디자인: 전체 선택 행 52, 체크 18 (leading 35), 구분선 2pt, 삭제 버튼 52/r8, 하단 safe area + 6 + /// 디자인: 전체 선택 행 52, 구분선 2pt, 삭제 버튼 52/r8, 하단 safe area + 6 + /// 체크 아이콘은 셀 체크(centerX 36)와 일직선이 되도록 leading을 맞춘다 private enum Layout { static let horizontalInset: CGFloat = 24 static let headerHeight: CGFloat = 52 - static let headerLeading: CGFloat = 35 + static let headerLeading: CGFloat = 25 + /// 셀과 동일 (에셋 24pt 캔버스의 원 20pt → 22pt 렌더 시 디자인 18pt) + static let checkIconWidth: CGFloat = 22 static let buttonHeight: CGFloat = 52 static let buttonCornerRadius: CGFloat = 8 static let buttonBottomInset: CGFloat = 6 @@ -50,7 +53,8 @@ final class LikedPartnershipEditViewController: BaseViewController { // MARK: - Init init(stores: [PartnershipDTO]) { - self.stores = stores + // 목록이 중복 행을 담고 있어도 전체 선택 판정이 어긋나지 않도록 업소 단위로 병합해 받는다 + self.stores = PartnershipLikeManager.mergedByStore(stores) super.init(nibName: nil, bundle: nil) } @@ -64,7 +68,7 @@ final class LikedPartnershipEditViewController: BaseViewController { view.backgroundColor = .white var config = UIButton.Configuration.plain() - config.image = EATSSUDesignAsset.Images.icUncheck.image.resize(newWidth: 18) + config.image = EATSSUDesignAsset.Images.icUncheck.image.resize(newWidth: Layout.checkIconWidth) config.imagePadding = 15 config.baseForegroundColor = .label config.contentInsets = .zero @@ -134,7 +138,7 @@ final class LikedPartnershipEditViewController: BaseViewController { navigationItem.hidesBackButton = true // 선택 중이어도 확인 없이 즉시 닫는다 (기획) let closeItem = UIBarButtonItem( - image: EATSSUDesignAsset.Images.icClose.image.resize(newWidth: 24), + image: EATSSUDesignAsset.Images.icClose.image.resize(newWidth: 12), // 디자인 12x12 (stroke gray500) style: .plain, target: self, action: #selector(didTapClose) @@ -155,7 +159,7 @@ final class LikedPartnershipEditViewController: BaseViewController { private func updateSelectAllButton() { selectAllButton.configuration?.image = (isAllSelected ? EATSSUDesignAsset.Images.icCheck.image - : EATSSUDesignAsset.Images.icUncheck.image).resize(newWidth: 18) + : EATSSUDesignAsset.Images.icUncheck.image).resize(newWidth: Layout.checkIconWidth) } // MARK: - Actions @@ -182,7 +186,10 @@ final class LikedPartnershipEditViewController: BaseViewController { case .success: self.navigationController?.popViewController(animated: true) self.onDidDelete?() - case .failure: + case .failure(let error): + #if DEBUG + print("찜 삭제 실패:", error) + #endif self.deleteButton.isEnabled = true self.showToast(message: TextLiteral.Like.updateFailed, type: .danger) } diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Location.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Location.swift index 0fe12767..22b6edaa 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Location.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Location.swift @@ -8,6 +8,8 @@ import UIKit import CoreLocation +import NMapsMap + // MARK: - Location Management extension MainMapViewController: CLLocationManagerDelegate { @@ -55,10 +57,40 @@ extension MainMapViewController: CLLocationManagerDelegate { return nil } + /// 착한가격 지도에서 현위치로 카메라 이동 (진입·탭 전환 공통) + /// 권한 미결정이면 요청하고, 거부 상태면 숭실대 상권으로 이동한다 (설정 유도 알럿은 띄우지 않음) + func moveToCurrentLocationIfAvailable(animated: Bool = false) { + switch locationManager.authorizationStatus { + case .authorizedWhenInUse, .authorizedAlways: + root.mapView.mapView.positionMode = .direction + if let location = locationManager.location { + moveCamera(to: NMGLatLng(lat: location.coordinate.latitude, lng: location.coordinate.longitude), animated: animated) + } else { + wantsInitialCurrentLocation = true + locationManager.requestLocation() + } + case .notDetermined: + wantsInitialCurrentLocation = true + locationManager.requestWhenInUseAuthorization() + default: + setInitialCameraPosition(animated: animated) + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + // 착한가격 지도를 보고 있을 때만 이동 (다른 탭으로 간 뒤 늦게 온 응답이 카메라를 덮지 않게) + guard wantsInitialCurrentLocation, currentTab == .goodPrice, let location = locations.last else { return } + wantsInitialCurrentLocation = false + moveCamera(to: NMGLatLng(lat: location.coordinate.latitude, lng: location.coordinate.longitude), animated: true) + } + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { switch manager.authorizationStatus { case .authorizedWhenInUse, .authorizedAlways: root.mapView.mapView.positionMode = .direction + if wantsInitialCurrentLocation { + locationManager.requestLocation() + } case .denied, .restricted: if hasRequestedLocationPermission { @@ -96,6 +128,8 @@ extension MainMapViewController: CLLocationManagerDelegate { } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + // 진입 시 현위치 조회 실패는 조용히 포기하고 기본 위치(숭실대)를 유지한다 + wantsInitialCurrentLocation = false if let clError = error as? CLError { switch clError.code { case .denied: @@ -110,6 +144,17 @@ extension MainMapViewController: CLLocationManagerDelegate { } } +// MARK: - NMFMapViewCameraDelegate + +extension MainMapViewController: NMFMapViewCameraDelegate { + /// 사용자가 지도를 직접 움직이면 진입 시 걸어둔 현위치 이동을 취소한다 (늦은 응답이 조작을 덮지 않게) + func mapView(_ mapView: NMFMapView, cameraWillChangeByReason reason: Int, animated: Bool) { + if reason == NMFMapChangedByGesture { + wantsInitialCurrentLocation = false + } + } +} + // MARK: - UIGestureRecognizerDelegate extension MainMapViewController: UIGestureRecognizerDelegate { diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift index c0bd0e53..2f370f3d 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController+Network.swift @@ -74,7 +74,9 @@ extension MainMapViewController { switch result { case .success(let partnerships): self.cachedMyPartnerships = partnerships + self.hasFetchedMyPartnerships = true self.applyPartnershipMarkers(from: partnerships, periodType: .normal) + self.presentPendingDetailIfNeeded() case .failure(let error): print("내 제휴 조회 실패: \(error.localizedDescription)") diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift index dc8bab9f..884862e8 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/MainMapViewController.swift @@ -23,6 +23,8 @@ final class MainMapViewController: BaseViewController { static let initialLatitude = 37.4960 static let initialLongitude = 126.9555 static let initialZoom: Double = 14.7 + /// 특정 업체를 보여줄 때의 줌. 이웃 마커와 클러스터로 뭉치지 않을 만큼 당긴다 + static let detailZoom: Double = 17 static let animationDuration: TimeInterval = 0.3 } @@ -43,6 +45,8 @@ final class MainMapViewController: BaseViewController { var currentDepartmentId: Int? var currentCollegeId: Int? var hasRequestedLocationPermission = false + /// 비로그인 진입 시 현위치로 이동하기 위해 권한/위치 응답을 기다리는 중인지 + var wantsInitialCurrentLocation = false var clusterer: NMCClusterer? @@ -50,6 +54,9 @@ final class MainMapViewController: BaseViewController { var cachedAllPartnerships: [PartnershipDTO] = [] /// 내 학과 제휴 캐시 (업종 칩 필터용). 탭바 재탭·학과 변경 시 비움 var cachedMyPartnerships: [PartnershipDTO] = [] + /// 내 제휴를 한 번이라도 받았는지 (찜 → 상세 진입 시 전체 단과대 원본이 노출되지 않게 대기 판단용) + var hasFetchedMyPartnerships = false + private var isLoadingMyPartnershipsForDetail = false /// 착한가격업소 전체 목록 캐시 (카테고리 필터링용) var cachedGoodPriceStores: [GoodPriceStoreDTO] = [] @@ -157,9 +164,10 @@ final class MainMapViewController: BaseViewController { super.viewDidLoad() locationManager.delegate = self + root.mapView.mapView.addCameraDelegate(delegate: self) configureNavigationBar() - setInitialCameraPosition(animated: false) + setEntryCameraPosition() setupLocationButtonObserver() setupMarkerTapHandler() applyTabUI() @@ -208,7 +216,7 @@ final class MainMapViewController: BaseViewController { presentPendingDetailIfNeeded() } - private func presentPendingDetailIfNeeded() { + func presentPendingDetailIfNeeded() { guard let store = pendingDetailStore, viewIfLoaded?.window != nil, presentedViewController == nil else { return } @@ -217,9 +225,41 @@ final class MainMapViewController: BaseViewController { if departmentLoadState == .loaded { pendingDetailStore = nil } return } + // 내 제휴 응답 전이면 받아온 뒤 연다 (전체 단과대 원본 시트가 잠깐 노출되는 것 방지) + if !hasFetchedMyPartnerships, cachedMyPartnerships.isEmpty { + loadMyPartnershipsForPendingDetail() + return + } pendingDetailStore = nil - moveCamera(to: NMGLatLng(lat: store.latitude, lng: store.longitude), animated: false) - showPartnershipDetail(for: store) + moveCamera( + to: NMGLatLng(lat: store.latitude, lng: store.longitude), + zoom: CameraConstants.detailZoom, + animated: false + ) + // 찜 원본 DTO는 모든 단과대 제휴를 담고 있어, 지도 마커와 동일하게 내 제휴 데이터로 표시한다 + // (내 제휴에 없으면 — 학과 변경 등 — 원본으로 폴백, 시트에서 내용 기준 중복 제거) + let display = cachedMyPartnerships.first { $0.storeKey == store.storeKey } ?? store + showPartnershipDetail(for: display, likeTarget: store) + } + + /// 찜 → 상세 진입용 내 제휴 확보. 마커 로드 세대에 영향을 주지 않도록 캐시만 채운다 + /// 실패해도 완료 표시 후 다시 호출해, 시트가 원본(중복 제거) 폴백으로라도 열리게 한다 + private func loadMyPartnershipsForPendingDetail() { + guard !isLoadingMyPartnershipsForDetail else { return } + isLoadingMyPartnershipsForDetail = true + NetworkService.shared.request( + MyRouter.getMyPartnerships, + responseType: [PartnershipDTO].self, + useAuth: true + ) { [weak self] result in + guard let self else { return } + self.isLoadingMyPartnershipsForDetail = false + if case .success(let partnerships) = result, self.cachedMyPartnerships.isEmpty { + self.cachedMyPartnerships = partnerships + } + self.hasFetchedMyPartnerships = true + self.presentPendingDetailIfNeeded() + } } /// 찜 목록에서 넘어온 경우에만 뒤로가기(찜 탭 복귀) 버튼을 보여준다 @@ -332,7 +372,15 @@ final class MainMapViewController: BaseViewController { private func switchTab(to tab: MapTab) { guard currentTab != tab else { return } currentTab = tab - setInitialCameraPosition(animated: true) + switch tab { + case .partnership: + // 학교 제휴는 숭실대 상권 기준. 착한가격에서 걸어둔 현위치 이동 대기도 취소한다 + wantsInitialCurrentLocation = false + setInitialCameraPosition(animated: true) + case .goodPrice: + // 착한가격은 위치 권한이 있으면 항상 현위치 기준, 없으면 숭실대 + moveToCurrentLocationIfAvailable(animated: true) + } switch tab { case .partnership: @@ -365,9 +413,7 @@ final class MainMapViewController: BaseViewController { MapAnalyticsManager.shared.logClickGoodPriceCategory(category: goodPriceCategory) loadGoodPriceMarkers() } - - // 필터가 바뀌면 캠퍼스 주변으로 되돌려 새 마커가 바로 보이게 한다 (필터 전환 전 동작과 동일) - setInitialCameraPosition(animated: true) + // 필터 전환 시 카메라는 보고 있던 위치를 그대로 유지한다 (QA) } /// 축제 → click_map_festival, 그 외 → click_map_mine @@ -433,6 +479,7 @@ final class MainMapViewController: BaseViewController { case .partnership: cachedAllPartnerships = [] cachedMyPartnerships = [] + hasFetchedMyPartnerships = false refreshPartnershipTab() case .goodPrice: cachedGoodPriceStores = [] @@ -440,6 +487,13 @@ final class MainMapViewController: BaseViewController { } } + /// 진입 시 카메라 위치. 로그인(탭 지도)은 숭실대 상권, 비로그인(단독 착한가격)은 현위치(권한 없으면 숭실대) + private func setEntryCameraPosition() { + setInitialCameraPosition(animated: false) + guard mode == .standaloneGoodPrice else { return } + moveToCurrentLocationIfAvailable() + } + func setInitialCameraPosition(animated: Bool) { moveCamera( to: NMGLatLng(lat: CameraConstants.initialLatitude, lng: CameraConstants.initialLongitude), @@ -447,9 +501,9 @@ final class MainMapViewController: BaseViewController { ) } - /// 지정 좌표로 카메라 이동 (줌은 초기값 고정) - func moveCamera(to position: NMGLatLng, animated: Bool) { - let cameraUpdate = NMFCameraUpdate(scrollTo: position, zoomTo: CameraConstants.initialZoom) + /// 지정 좌표로 카메라 이동 + func moveCamera(to position: NMGLatLng, zoom: Double = CameraConstants.initialZoom, animated: Bool) { + let cameraUpdate = NMFCameraUpdate(scrollTo: position, zoomTo: zoom) if animated { cameraUpdate.animation = .easeIn diff --git a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift index 5c4aeb3f..3b854b38 100644 --- a/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift +++ b/EATSSU/App/Sources/Presentation/Map/ViewController/PartnershipDetailSheetViewController.swift @@ -72,7 +72,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { typeIconImageView.snp.makeConstraints { $0.width.height.equalTo(18) } typeTextLabel.font = .body2 - typeTextLabel.textColor = .gray + typeTextLabel.textColor = .gray500 // 디자인 #9D9D9D (시스템 .gray는 더 어두움) typeStackView.axis = .horizontal typeStackView.alignment = .center @@ -164,8 +164,17 @@ final class PartnershipDetailSheetViewController: BaseViewController { .first { $0.restaurantType == partnership.restaurantType }? .title ?? partnership.restaurantType - for (index, info) in partnership.partnershipInfos.enumerated() { - let isLast = index == partnership.partnershipInfos.count - 1 + // 서버 데이터에 같은 제휴가 다른 id로 중복 존재할 수 있어 내용 기준으로 걸러 표시한다 + var seen = Set() + let displayInfos = partnership.partnershipInfos.filter { info in + let key = [ + info.collegeName ?? "", info.departmentName ?? "", + info.description, info.startDate, info.endDate + ].joined(separator: "|") + return seen.insert(key).inserted + } + for (index, info) in displayInfos.enumerated() { + let isLast = index == displayInfos.count - 1 let card = makeInfoCard(info: info, isLast: isLast) infoListStackView.addArrangedSubview(card) } @@ -256,7 +265,7 @@ final class PartnershipDetailSheetViewController: BaseViewController { attrText.addAttributes([ .font: UIFont.caption2, - .foregroundColor: EATSSUDesignColors.Color.gray700, + .foregroundColor: EATSSUDesignColors.Color.gray500, // 디자인 #9D9D9D .baselineOffset: +1 ], range: dateRange) @@ -264,14 +273,20 @@ final class PartnershipDetailSheetViewController: BaseViewController { titleDateLabel.attributedText = attrText let descriptionLabel = UILabel() - descriptionLabel.font = .body3 - descriptionLabel.textColor = EATSSUDesignColors.Color.gray700 + descriptionLabel.textColor = EATSSUDesignColors.Color.gray600 // 디자인 #565656 descriptionLabel.numberOfLines = 0 - descriptionLabel.text = info.description + // 여러 줄 설명은 줄간격을 벌려 가독성을 확보한다 (피그마 수치 확정 시 조정) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineSpacing = 6 + descriptionLabel.attributedText = NSAttributedString( + string: info.description, + attributes: [.paragraphStyle: paragraphStyle, .font: UIFont.body3] + ) let contentStack = UIStackView(arrangedSubviews: [titleDateLabel, descriptionLabel]) contentStack.axis = .vertical - contentStack.spacing = 4 + // 설명 내부 줄간격(6)보다 확실히 넓혀 단과대 제목 블록이 구분되게 한다 (Frame 43540 기준 + 여백 보강) + contentStack.spacing = 12 let separator = UIView() separator.backgroundColor = EATSSUDesignColors.Color.gray300 @@ -282,12 +297,12 @@ final class PartnershipDetailSheetViewController: BaseViewController { let container = UIStackView(arrangedSubviews: [contentStack, separator]) container.axis = .vertical - container.spacing = 10 + container.spacing = 12 let paddedContainer = UIView() paddedContainer.addSubview(container) container.snp.makeConstraints { - $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 0, bottom: 10, right: 0)) + $0.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 0, bottom: 12, right: 0)) } return paddedContainer diff --git a/EATSSU/App/Sources/Utility/Literal/AppLanguage.swift b/EATSSU/App/Sources/Utility/Literal/AppLanguage.swift index 6f6bbdb5..dbcba1da 100644 --- a/EATSSU/App/Sources/Utility/Literal/AppLanguage.swift +++ b/EATSSU/App/Sources/Utility/Literal/AppLanguage.swift @@ -31,7 +31,8 @@ enum AppLanguage: String, CaseIterable { /// 미지원 언어를 보내면 서버가 한국어를 돌려주긴 하지만, 대표메뉴만 표시하는 규칙이 잘못 켜지지 않도록 /// 식단 API는 이 조건으로만 언어를 전달한다. 서버가 지원을 넓히면 여기만 수정한다. var supportsMealTranslation: Bool { - self == .english + // 변동식단은 서버가 영어 번역만 제공하므로, 비한국어 언어는 모두 영어 표기를 사용한다 (QA) + self != .korean } var title: String { diff --git a/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift b/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift index 9604b9ec..7968a878 100644 --- a/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift +++ b/EATSSU/App/Sources/Utility/UIComponent/FilterChipBar.swift @@ -55,7 +55,8 @@ final class FilterChipBar: BaseUIView { scrollView.showsHorizontalScrollIndicator = false scrollView.alwaysBounceHorizontal = true - scrollView.clipsToBounds = false + // 우측에 고정 요소(편집 버튼, 찜 하트)가 있는 화면에서 칩이 그 위로 넘쳐 그려지지 않도록 잘라낸다 + scrollView.clipsToBounds = true scrollView.contentInset = UIEdgeInsets(top: 0, left: horizontalInset, bottom: 0, right: horizontalInset) stackView.axis = .horizontal diff --git a/EATSSU/Tests/UnitTests/MealMenuDisplayTests.swift b/EATSSU/Tests/UnitTests/MealMenuDisplayTests.swift index 2e097555..1efde501 100644 --- a/EATSSU/Tests/UnitTests/MealMenuDisplayTests.swift +++ b/EATSSU/Tests/UnitTests/MealMenuDisplayTests.swift @@ -34,6 +34,12 @@ final class MealMenuDisplayTests: XCTestCase { ) } + func test_일본어에서도_대표메뉴만_표시한다() { + languageSandbox.set(.japanese) + let meal = makeMeal([("Pork Cutlet", true), ("김치", false)]) + XCTAssertEqual(meal.displayMenus.map(\.name), ["Pork Cutlet"]) + } + func test_영어에서는_대표메뉴만_표시한다() { languageSandbox.set(.english) let meal = makeMeal([("Pork Cutlet", true), ("김치", false), ("밥", false)]) @@ -53,12 +59,16 @@ final class MealMenuDisplayTests: XCTestCase { XCTAssertEqual(meal.displayMenus.count, 2) } - func test_변동식단_언어_파라미터는_영어만_전달한다() { + func test_변동식단_언어_파라미터는_비한국어_전부_EN을_전달한다() { + // 서버가 변동식단 번역을 영어로만 제공하므로 ja/vi도 EN을 요청한다 (QA) languageSandbox.set(.english) XCTAssertEqual(ChangeMenuTableResponse.mealLanguageParameter, "EN") languageSandbox.set(.japanese) - XCTAssertNil(ChangeMenuTableResponse.mealLanguageParameter) + XCTAssertEqual(ChangeMenuTableResponse.mealLanguageParameter, "EN") + + languageSandbox.set(.vietnamese) + XCTAssertEqual(ChangeMenuTableResponse.mealLanguageParameter, "EN") languageSandbox.set(.korean) XCTAssertNil(ChangeMenuTableResponse.mealLanguageParameter) diff --git a/EATSSU/Tests/UnitTests/PartnershipLikeLogicTests.swift b/EATSSU/Tests/UnitTests/PartnershipLikeLogicTests.swift index 053fd313..0f01beb4 100644 --- a/EATSSU/Tests/UnitTests/PartnershipLikeLogicTests.swift +++ b/EATSSU/Tests/UnitTests/PartnershipLikeLogicTests.swift @@ -106,6 +106,39 @@ final class PartnershipLikeLogicTests: XCTestCase { XCTAssertEqual(sorted.map(\.storeName), ["기록됨", "미기록1", "미기록2"]) } + // MARK: - mergedByStore (같은 업소가 여러 줄로 온 응답 병합) + + func test_같은_업소가_여러_줄로_오면_한_줄로_병합된다() { + let row1 = makeStore(name: "A", infos: [makeInfo(id: 1, isLiked: true)]) + let row2 = makeStore(name: "A", infos: [makeInfo(id: 2, isLiked: true)]) + let other = makeStore(name: "B", infos: [makeInfo(id: 3)]) + + let merged = PartnershipLikeManager.mergedByStore([row1, other, row2]) + + XCTAssertEqual(merged.map(\.storeName), ["A", "B"]) + XCTAssertEqual(merged[0].partnershipIds, [1, 2]) + } + + func test_병합시_중복_항목_id는_한_번만_남는다() { + let row1 = makeStore(name: "A", infos: [makeInfo(id: 1), makeInfo(id: 2)]) + let row2 = makeStore(name: "A", infos: [makeInfo(id: 2), makeInfo(id: 3)]) + + let merged = PartnershipLikeManager.mergedByStore([row1, row2]) + + XCTAssertEqual(merged.count, 1) + XCTAssertEqual(merged[0].partnershipIds, [1, 2, 3]) + } + + func test_좌표가_다르면_다른_업소로_병합하지_않는다() { + let a = makeStore(name: "A", infos: [makeInfo(id: 1)]) + let b = PartnershipDTO( + storeName: "A", longitude: 127.0, latitude: 37.5, restaurantType: "RESTAURANT", + naverMapUrl: nil, kakaoMapUrl: nil, partnershipInfos: [makeInfo(id: 2)] + ) + + XCTAssertEqual(PartnershipLikeManager.mergedByStore([a, b]).count, 2) + } + // MARK: - storeKey func test_업체_키는_이름과_좌표로_구성된다() {