From 08fd90bd4349bb72b5cd1084018fafeb51208c28 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:47:29 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[#472]=20=EB=A9=94=EB=89=B4=20=EC=B0=9C?= =?UTF-8?q?=20API=20=EC=97=B0=EA=B2=B0=EA=B3=BC=20=EC=B0=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Like/MenuFavoriteManager.swift | 167 ++++++++++++++++++ .../Sources/Data/LocalDB/AccountStorage.swift | 1 + .../DTO/MenuFavorite/MenuFavoriteDTO.swift | 26 +++ .../Network/Router/MenuFavoriteRouter.swift | 67 +++++++ .../UnitTests/MenuFavoriteLogicTests.swift | 58 ++++++ 5 files changed, 319 insertions(+) create mode 100644 EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift create mode 100644 EATSSU/App/Sources/Data/Network/DTO/MenuFavorite/MenuFavoriteDTO.swift create mode 100644 EATSSU/App/Sources/Data/Network/Router/MenuFavoriteRouter.swift create mode 100644 EATSSU/Tests/UnitTests/MenuFavoriteLogicTests.swift diff --git a/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift b/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift new file mode 100644 index 00000000..f26265c1 --- /dev/null +++ b/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift @@ -0,0 +1,167 @@ +// +// MenuFavoriteManager.swift +// EATSSU +// +// Created by 황상환 on 10/6/26. +// + +import Foundation + +/// 메뉴 찜 상태의 앱 내 단일 소스 +/// +/// - 찜 목록은 서버가 최근 찜한 순으로 내려주므로 순서를 로컬에 따로 기록하지 않는다. +/// - 검색 결과의 찜 여부는 응답의 `isFavorite`를 쓰되, 이 세션에서 바꾼 메뉴는 바꾼 값을 우선한다. +/// (토글 전에 시작된 검색 응답이 늦게 도착해도 하트가 되돌아가지 않게 한다) +final class MenuFavoriteManager { + + // MARK: - Singleton + + static let shared = MenuFavoriteManager() + + private init() {} + + // MARK: - Constants + + /// 서버 검색 최소 글자 수 (앞뒤 공백 제거 기준). 미만이면 서버가 400(40025)을 반환한다 + static let minimumKeywordLength = 2 + + // MARK: - State + + /// 마지막으로 받아온 찜 목록 (최근 찜한 순) + private(set) var favorites: [FavoriteMenuDTO] = [] + + /// 찜 목록을 한 번이라도 서버에서 받아왔는지 + private(set) var hasLoaded = false + + /// 이 세션에서 찜·해제에 성공한 메뉴의 최신 상태 + private var toggledStates: [Int: Bool] = [:] + + /// 요청 중인 메뉴. 같은 하트를 연타해 요청이 겹치지 않게 한다 + private var pendingMenuIds: Set = [] + + /// 찜 상태가 바뀔 때마다 증가. 변경 이전에 시작된 목록 조회 응답이 최신 로컬 상태를 덮어쓰지 않게 한다 + private var stateVersion = 0 + + /// 로그아웃·탈퇴·세션 만료 시 호출. 다른 계정의 찜 상태가 남지 않게 비운다 + func reset() { + favorites = [] + hasLoaded = false + toggledStates = [:] + pendingMenuIds = [] + stateVersion += 1 + } + + // MARK: - Query + + func isFavorite(_ menu: MenuSearchDTO) -> Bool { + toggledStates[menu.menuId] ?? menu.isFavorite + } + + func isPending(menuId: Int) -> Bool { + pendingMenuIds.contains(menuId) + } + + // MARK: - Fetch + + /// 찜 목록을 서버에서 받아 상태를 갱신한다 + func refresh(completion: @escaping (Result<[FavoriteMenuDTO], Error>) -> Void) { + let version = stateVersion + NetworkService.shared.request( + MenuFavoriteRouter.getFavorites, + responseType: [FavoriteMenuDTO].self, + useAuth: true + ) { [weak self] result in + guard let self else { return } + switch result { + case .success(let favorites): + // 조회 중에 찜 상태가 바뀌었으면 이 응답은 이미 낡은 것이므로 로컬 상태를 유지한다 + guard version == self.stateVersion else { + completion(.success(self.favorites)) + return + } + self.favorites = favorites + self.toggledStates = [:] + self.hasLoaded = true + completion(.success(favorites)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// 메뉴 검색. 결과는 화면 표시 순서(`sortedForDisplay`)로 정렬해 돌려준다 + func search(keyword: String, completion: @escaping (Result<[MenuSearchDTO], Error>) -> Void) { + NetworkService.shared.request( + MenuFavoriteRouter.search(keyword: keyword), + responseType: [MenuSearchDTO].self, + useAuth: true + ) { result in + completion(result.map { Self.sortedForDisplay($0, keyword: keyword) }) + } + } + + // MARK: - Update + + /// 찜·해제. 서버 성공 후 로컬 상태(목록, 세션 상태)를 반영한다 + func setFavorite( + _ isFavorite: Bool, + menuId: Int, + menuName: String, + restaurant: String, + completion: @escaping (Result) -> Void + ) { + guard !pendingMenuIds.contains(menuId) else { return } + pendingMenuIds.insert(menuId) + // 요청 시작 시점에 버전을 올려, 이보다 먼저 시작된 목록 조회 응답이 변경 전 상태를 되살리지 못하게 한다 + stateVersion += 1 + + let target: MenuFavoriteRouter = isFavorite ? .add(menuId: menuId) : .remove(menuId: menuId) + NetworkService.shared.request(target, responseType: Bool.self, useAuth: true) { [weak self] result in + guard let self else { return } + self.pendingMenuIds.remove(menuId) + switch result { + case .success: + self.stateVersion += 1 + self.toggledStates[menuId] = isFavorite + self.favorites.removeAll { $0.menuId == menuId } + if isFavorite { + let favorite = FavoriteMenuDTO( + menuId: menuId, + menuName: menuName, + restaurant: restaurant, + isDiscontinued: false + ) + self.favorites.insert(favorite, at: 0) + } + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // MARK: - Sorting + + /// 검색 결과 표시 순서 (기능 명세) + /// 1. 메뉴명이 검색어로 시작하는 메뉴 먼저 + /// 2. 같은 그룹 안에서는 메뉴명 가나다순 + /// 3. 메뉴명이 같으면(식당만 다른 경우) 서버 순서 유지 + static func sortedForDisplay(_ menus: [MenuSearchDTO], keyword: String) -> [MenuSearchDTO] { + let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + let korean = Locale(identifier: "ko_KR") + + func startsWithKeyword(_ name: String) -> Bool { + name.range(of: keyword, options: [.caseInsensitive, .anchored]) != nil + } + + return menus.enumerated().sorted { lhs, rhs in + let lhsPrefix = startsWithKeyword(lhs.element.menuName) + let rhsPrefix = startsWithKeyword(rhs.element.menuName) + if lhsPrefix != rhsPrefix { return lhsPrefix } + + let order = lhs.element.menuName.compare(rhs.element.menuName, locale: korean) + if order != .orderedSame { return order == .orderedAscending } + return lhs.offset < rhs.offset + }.map(\.element) + } +} diff --git a/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift b/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift index 95340016..befa46b2 100644 --- a/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift +++ b/EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift @@ -14,5 +14,6 @@ enum AccountStorage { UserInfoManager.shared.clear() // 찜 목록·순서 PartnershipLikeManager.shared.reset() + MenuFavoriteManager.shared.reset() } } diff --git a/EATSSU/App/Sources/Data/Network/DTO/MenuFavorite/MenuFavoriteDTO.swift b/EATSSU/App/Sources/Data/Network/DTO/MenuFavorite/MenuFavoriteDTO.swift new file mode 100644 index 00000000..ab4b6a5a --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/DTO/MenuFavorite/MenuFavoriteDTO.swift @@ -0,0 +1,26 @@ +// +// MenuFavoriteDTO.swift +// EATSSU +// +// Created by 황상환 on 10/6/26. +// + +import Foundation + +/// 찜한 메뉴 (`GET /menu-favorites`, 최근 찜한 순) +struct FavoriteMenuDTO: Codable, Equatable { + let menuId: Int + /// 서버에 저장된 유저 언어 기준. 번역이 없으면 한국어 + let menuName: String + /// 식당 코드 (`DODAM`, `DORMITORY`, `HAKSIK`, `FACULTY`, `SNACK_CORNER`, `FOOD_COURT`) + let restaurant: String + let isDiscontinued: Bool +} + +/// 찜할 메뉴 검색 결과 (`GET /menu-favorites/search`). 같은 메뉴명이라도 식당이 다르면 따로 내려온다 +struct MenuSearchDTO: Codable, Equatable { + let menuId: Int + let menuName: String + let restaurant: String + let isFavorite: Bool +} diff --git a/EATSSU/App/Sources/Data/Network/Router/MenuFavoriteRouter.swift b/EATSSU/App/Sources/Data/Network/Router/MenuFavoriteRouter.swift new file mode 100644 index 00000000..e1d90107 --- /dev/null +++ b/EATSSU/App/Sources/Data/Network/Router/MenuFavoriteRouter.swift @@ -0,0 +1,67 @@ +// +// MenuFavoriteRouter.swift +// EATSSU +// +// Created by 황상환 on 10/6/26. +// + +import Foundation + +import Moya + +/// 메뉴 찜 API (모두 로그인 필요) +enum MenuFavoriteRouter { + /// 찜할 메뉴 검색. 앞뒤 공백 제거 후 2글자 미만이면 서버가 400을 반환한다 + case search(keyword: String) + /// 찜한 메뉴 목록 (최근 찜한 순) + case getFavorites + /// 찜하기. 이미 찜한 메뉴여도 성공 + case add(menuId: Int) + /// 찜 취소. 찜하지 않은 메뉴여도 성공 + case remove(menuId: Int) +} + +extension MenuFavoriteRouter: TargetType { + var baseURL: URL { + URL(string: Config.baseURL)! + } + + var path: String { + switch self { + case .search: + "/menu-favorites/search" + case .getFavorites: + "/menu-favorites" + case let .add(menuId), let .remove(menuId): + "/menu-favorites/\(menuId)" + } + } + + var method: Moya.Method { + switch self { + case .search, .getFavorites: + .get + case .add: + .post + case .remove: + .delete + } + } + + var task: Moya.Task { + switch self { + case let .search(keyword): + .requestParameters(parameters: ["keyword": keyword], encoding: URLEncoding.queryString) + case .getFavorites, .add, .remove: + .requestPlain + } + } + + var headers: [String: String]? { + ["Content-Type": "application/json"] + } + + var validationType: ValidationType { + .successCodes + } +} diff --git a/EATSSU/Tests/UnitTests/MenuFavoriteLogicTests.swift b/EATSSU/Tests/UnitTests/MenuFavoriteLogicTests.swift new file mode 100644 index 00000000..d777c933 --- /dev/null +++ b/EATSSU/Tests/UnitTests/MenuFavoriteLogicTests.swift @@ -0,0 +1,58 @@ +// +// MenuFavoriteLogicTests.swift +// EATSSUUnitTests +// +// Created by 황상환 on 10/6/26. +// + +import XCTest + +@testable import EATSSU_DEV + +/// 메뉴 찜 검색 결과 정렬 검증 (기능 명세: 검색어로 시작하는 메뉴 우선, 나머지는 가나다순) +final class MenuFavoriteLogicTests: XCTestCase { + + // MARK: - Helpers + + private func makeMenu(_ id: Int, _ name: String, restaurant: String = "HAKSIK") -> MenuSearchDTO { + MenuSearchDTO(menuId: id, menuName: name, restaurant: restaurant, isFavorite: false) + } + + private func sortedNames(_ names: [String], keyword: String) -> [String] { + let menus = names.enumerated().map { makeMenu($0.offset, $0.element) } + return MenuFavoriteManager.sortedForDisplay(menus, keyword: keyword).map(\.menuName) + } + + // MARK: - Tests + + func test_검색어로_시작하는_메뉴가_먼저_나오고_나머지는_가나다순() { + let result = sortedNames(["포기김치", "뚝배기목살김치찌개", "배추김치", "김치닭볶음탕"], keyword: "김치") + XCTAssertEqual(result, ["김치닭볶음탕", "뚝배기목살김치찌개", "배추김치", "포기김치"]) + } + + func test_검색어로_시작하는_메뉴끼리도_가나다순() { + let result = sortedNames(["김치찌개", "김치", "김치볶음밥"], keyword: "김치") + XCTAssertEqual(result, ["김치", "김치볶음밥", "김치찌개"]) + } + + func test_영문은_대소문자를_구분하지_않고_앞에_오는지_판단() { + let result = sortedNames(["Grilled Meat", "Meatball", "meat pie"], keyword: "me") + XCTAssertEqual(result.prefix(2).sorted(), ["Meatball", "meat pie"].sorted()) + XCTAssertEqual(result.last, "Grilled Meat") + } + + func test_검색어_앞뒤_공백은_무시() { + let result = sortedNames(["배추김치", "김치전"], keyword: " 김치 ") + XCTAssertEqual(result, ["김치전", "배추김치"]) + } + + func test_같은_메뉴명이면_서버_순서_유지() { + let menus = [ + makeMenu(1, "김치찌개", restaurant: "DODAM"), + makeMenu(2, "김치찌개", restaurant: "HAKSIK"), + makeMenu(3, "김치볶음밥", restaurant: "DORMITORY") + ] + let result = MenuFavoriteManager.sortedForDisplay(menus, keyword: "김치").map(\.menuId) + XCTAssertEqual(result, [3, 1, 2]) + } +} From c97f5f0983c429bffbf8b88a511596d694f0c2d8 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:47:32 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[#472]=20=EC=B0=9C=20=ED=83=AD=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=B0=9C=20=EA=B2=80=EC=83=89=C2=B7?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/en.lproj/Localizable.strings | 24 +- .../Resources/ja.lproj/Localizable.strings | 24 +- .../Resources/ko.lproj/Localizable.strings | 24 +- .../Resources/vi.lproj/Localizable.strings | 24 +- .../Like/View/MenuFavoriteCell.swift | 164 +++++++++ .../Like/View/MenuSearchBar.swift | 80 +++++ .../ViewController/LikeViewController.swift | 10 +- .../MenuLikePlaceholderViewController.swift | 29 -- .../MenuLikeViewController.swift | 332 ++++++++++++++++++ .../CustomTabBarContainerController.swift | 6 + .../Sources/Utility/Literal/TextLiteral.swift | 52 ++- 11 files changed, 715 insertions(+), 54 deletions(-) create mode 100644 EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/View/MenuSearchBar.swift delete mode 100644 EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikePlaceholderViewController.swift create mode 100644 EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 8224339e..5dd7c201 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -704,7 +704,23 @@ "like.emptyTitle" = "No liked partners yet."; /// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" "like.emptySubtitle" = "Like the partners you're interested in for quick access!"; -/// "메뉴 찜은 준비 중이에요." -"like.menuComingSoonTitle" = "Liked menus are coming soon."; -/// "곧 만나볼 수 있어요!" -"like.menuComingSoonSubtitle" = "Stay tuned!"; +/// "찜하고 싶은 메뉴를 검색하세요." +"like.menuSearchPlaceholder" = "Search menus to add to favorites."; +/// "아직 찜한 메뉴가 없어요." +"like.menuEmptyTitle" = "No favorite menus yet."; +/// "좋아하는 메뉴를 검색해서 등록해 보세요!" +"like.menuEmptySubtitle" = "Find and save menus you like!"; +/// "내가 찜한 메뉴" +"like.menuListTitle" = "My Favorites"; +/// "찜한 메뉴에 추가했어요." +"like.menuAddedToast" = "Added to favorites."; +/// "찜한 메뉴에서 삭제했어요." +"like.menuRemovedToast" = "Removed from favorites."; +/// "처리하지 못했어요. 잠시 후 다시 시도해주세요." +"like.menuUpdateFailed" = "Something went wrong. Please try again."; +/// "메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요." +"like.menuLoadFailed" = "Couldn't load the menus.\nPlease try again."; +/// "일치하는 메뉴가 없어요." +"like.menuSearchEmptyTitle" = "No matching menu items found."; +/// "다른 메뉴명으로 검색해주세요!" +"like.menuSearchEmptySubtitle" = "Try searching for a different menu name!"; diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index 916416f4..9c5af959 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -705,7 +705,23 @@ "like.emptyTitle" = "まだお気に入りの提携店がありません。"; /// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" "like.emptySubtitle" = "気になる提携店をお気に入りに追加して、すぐに確認しましょう!"; -/// "메뉴 찜은 준비 중이에요." -"like.menuComingSoonTitle" = "メニューのお気に入りは準備中です。"; -/// "곧 만나볼 수 있어요!" -"like.menuComingSoonSubtitle" = "もうすぐ利用できます!"; +/// "찜하고 싶은 메뉴를 검색하세요." +"like.menuSearchPlaceholder" = "お気に入りに追加するメニューを検索。"; +/// "아직 찜한 메뉴가 없어요." +"like.menuEmptyTitle" = "お気に入りのメニューはまだありません。"; +/// "좋아하는 메뉴를 검색해서 등록해 보세요!" +"like.menuEmptySubtitle" = "好きなメニューを検索して追加しましょう!"; +/// "내가 찜한 메뉴" +"like.menuListTitle" = "お気に入りのメニュー"; +/// "찜한 메뉴에 추가했어요." +"like.menuAddedToast" = "お気に入りに追加しました。"; +/// "찜한 메뉴에서 삭제했어요." +"like.menuRemovedToast" = "お気に入りから削除しました。"; +/// "처리하지 못했어요. 잠시 후 다시 시도해주세요." +"like.menuUpdateFailed" = "処理できませんでした。もう一度お試しください。"; +/// "메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요." +"like.menuLoadFailed" = "メニューを読み込めませんでした。\nもう一度お試しください。"; +/// "일치하는 메뉴가 없어요." +"like.menuSearchEmptyTitle" = "一致するメニューがありません。"; +/// "다른 메뉴명으로 검색해주세요!" +"like.menuSearchEmptySubtitle" = "別のメニュー名で検索してください!"; diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 70abfcc4..88372640 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -705,7 +705,23 @@ "like.emptyTitle" = "아직 찜한 제휴가 없어요."; /// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" "like.emptySubtitle" = "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!"; -/// "메뉴 찜은 준비 중이에요." -"like.menuComingSoonTitle" = "메뉴 찜은 준비 중이에요."; -/// "곧 만나볼 수 있어요!" -"like.menuComingSoonSubtitle" = "곧 만나볼 수 있어요!"; +/// "찜하고 싶은 메뉴를 검색하세요." +"like.menuSearchPlaceholder" = "찜하고 싶은 메뉴를 검색하세요."; +/// "아직 찜한 메뉴가 없어요." +"like.menuEmptyTitle" = "아직 찜한 메뉴가 없어요."; +/// "좋아하는 메뉴를 검색해서 등록해 보세요!" +"like.menuEmptySubtitle" = "좋아하는 메뉴를 검색해서 등록해 보세요!"; +/// "내가 찜한 메뉴" +"like.menuListTitle" = "내가 찜한 메뉴"; +/// "찜한 메뉴에 추가했어요." +"like.menuAddedToast" = "찜한 메뉴에 추가했어요."; +/// "찜한 메뉴에서 삭제했어요." +"like.menuRemovedToast" = "찜한 메뉴에서 삭제했어요."; +/// "처리하지 못했어요. 잠시 후 다시 시도해주세요." +"like.menuUpdateFailed" = "처리하지 못했어요. 잠시 후 다시 시도해주세요."; +/// "메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요." +"like.menuLoadFailed" = "메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요."; +/// "일치하는 메뉴가 없어요." +"like.menuSearchEmptyTitle" = "일치하는 메뉴가 없어요."; +/// "다른 메뉴명으로 검색해주세요!" +"like.menuSearchEmptySubtitle" = "다른 메뉴명으로 검색해주세요!"; diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index ae70223c..8128112c 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -705,7 +705,23 @@ "like.emptyTitle" = "Chưa có ưu đãi yêu thích."; /// "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!" "like.emptySubtitle" = "Hãy thêm ưu đãi bạn quan tâm để xem nhanh!"; -/// "메뉴 찜은 준비 중이에요." -"like.menuComingSoonTitle" = "Món ăn yêu thích đang được chuẩn bị."; -/// "곧 만나볼 수 있어요!" -"like.menuComingSoonSubtitle" = "Sắp ra mắt!"; +/// "찜하고 싶은 메뉴를 검색하세요." +"like.menuSearchPlaceholder" = "Tìm món để thêm vào mục yêu thích."; +/// "아직 찜한 메뉴가 없어요." +"like.menuEmptyTitle" = "Chưa có món ăn yêu thích."; +/// "좋아하는 메뉴를 검색해서 등록해 보세요!" +"like.menuEmptySubtitle" = "Tìm và lưu món ăn bạn thích!"; +/// "내가 찜한 메뉴" +"like.menuListTitle" = "Món ăn yêu thích của tôi"; +/// "찜한 메뉴에 추가했어요." +"like.menuAddedToast" = "Đã thêm vào mục yêu thích."; +/// "찜한 메뉴에서 삭제했어요." +"like.menuRemovedToast" = "Đã xóa khỏi mục yêu thích."; +/// "처리하지 못했어요. 잠시 후 다시 시도해주세요." +"like.menuUpdateFailed" = "Không thể xử lý. Vui lòng thử lại."; +/// "메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요." +"like.menuLoadFailed" = "Không thể tải danh sách món ăn.\nVui lòng thử lại."; +/// "일치하는 메뉴가 없어요." +"like.menuSearchEmptyTitle" = "Không tìm thấy món ăn phù hợp."; +/// "다른 메뉴명으로 검색해주세요!" +"like.menuSearchEmptySubtitle" = "Hãy thử tìm bằng tên món ăn khác!"; diff --git a/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift b/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift new file mode 100644 index 00000000..2b6d3c26 --- /dev/null +++ b/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift @@ -0,0 +1,164 @@ +// +// MenuFavoriteCell.swift +// EATSSU +// +// Created by 황상환 on 10/6/26. +// + +import UIKit + +import SnapKit + +import EATSSUDesign + +/// 메뉴 찜 한 줄: 메뉴명 · (검색 결과) 식당명 · 하트 +final class MenuFavoriteCell: UITableViewCell { + + // MARK: - Types + + enum Style { + /// 내가 찜한 메뉴 목록. 디자인: 좌우 26, 행 높이 36, Medium 14 + case favorite + /// 검색 결과. 디자인: 좌우 24, 행 높이 34, Regular 14 + 검색어 Bold 14 + case search(keyword: String) + } + + // MARK: - Constants + + static let identifier = "MenuFavoriteCell" + + private enum Layout { + static let favoriteInset: CGFloat = 26 + static let searchInset: CGFloat = 24 + // 14pt 한 줄(약 17)에 위아래 여백을 더해 디자인 행 높이(36 / 34)를 맞춘다 + static let favoriteVerticalInset: CGFloat = 9.5 + static let searchVerticalInset: CGFloat = 8.5 + /// 하트 에셋은 24pt 캔버스에 18pt 하트가 그려져 있다 (디자인 하트 18×16) + static let heartSize: CGFloat = 24 + /// 하트 터치 영역 + static let heartTouchSize: CGFloat = 44 + static let textHeartSpacing: CGFloat = 12 + } + + // MARK: - Properties + + var onTapHeart: (() -> Void)? + + // MARK: - UI Components + + private let nameLabel = UILabel() + private let restaurantLabel = UILabel() + private let textStackView = UIStackView() + private let heartButton = UIButton(type: .custom) + + // MARK: - Init + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + configureUI() + setLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + onTapHeart = nil + } + + // MARK: - View Setup + + private func configureUI() { + selectionStyle = .none + backgroundColor = .white + + nameLabel.textColor = .black + nameLabel.numberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + + // 같은 메뉴명이 식당별로 따로 내려와 구분용으로 표시 (디자인 확정 전 임시) + restaurantLabel.font = .caption2 + restaurantLabel.textColor = .gray500 + + textStackView.axis = .vertical + textStackView.alignment = .leading + textStackView.spacing = 2 + textStackView.addArrangedSubview(nameLabel) + textStackView.addArrangedSubview(restaurantLabel) + + heartButton.tintColor = .gray700 + heartButton.addTarget(self, action: #selector(didTapHeart), for: .touchUpInside) + + contentView.addSubviews(textStackView, heartButton) + } + + private func setLayout() { + applyLayout(inset: Layout.favoriteInset, verticalInset: Layout.favoriteVerticalInset) + } + + private func applyLayout(inset: CGFloat, verticalInset: CGFloat) { + // 24pt 아이콘이 디자인 위치에 오도록 44pt 터치 영역의 중심을 아이콘 중심에 맞춘다 + let heartCenterTrailing = inset + Layout.heartSize / 2 + heartButton.snp.remakeConstraints { + $0.centerY.equalToSuperview() + $0.centerX.equalTo(contentView.snp.trailing).offset(-heartCenterTrailing) + $0.width.height.equalTo(Layout.heartTouchSize) + } + textStackView.snp.remakeConstraints { + $0.leading.equalToSuperview().inset(inset) + $0.trailing.lessThanOrEqualToSuperview().inset(inset + Layout.heartSize + Layout.textHeartSpacing) + $0.top.bottom.equalToSuperview().inset(verticalInset) + } + } + + // MARK: - Configuration + + func configure(name: String, restaurant: String?, isFavorite: Bool, style: Style) { + switch style { + case .favorite: + // 검색 결과로 쓰였던 셀이면 attributedText 속성이 남지 않게 초기화한다 + nameLabel.attributedText = nil + nameLabel.font = .body2 + nameLabel.textColor = .black + nameLabel.text = name + applyLayout(inset: Layout.favoriteInset, verticalInset: Layout.favoriteVerticalInset) + case .search(let keyword): + nameLabel.attributedText = Self.highlighted(name, keyword: keyword) + applyLayout(inset: Layout.searchInset, verticalInset: Layout.searchVerticalInset) + } + + restaurantLabel.text = restaurant + restaurantLabel.isHidden = (restaurant ?? "").isEmpty + + let image = isFavorite ? EATSSUDesignAsset.Images.icLikeFilled.image : EATSSUDesignAsset.Images.icLikeLine.image + heartButton.setImage(image.withRenderingMode(.alwaysTemplate), for: .normal) + heartButton.accessibilityLabel = name + heartButton.accessibilityTraits = isFavorite ? [.button, .selected] : .button + } + + /// 검색어와 일치하는 부분만 Bold 14, 나머지는 Regular 14 (대소문자 무시) + static func highlighted(_ name: String, keyword: String) -> NSAttributedString { + let attributed = NSMutableAttributedString( + string: name, + attributes: [.font: UIFont.body3, .foregroundColor: UIColor.black] + ) + let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !keyword.isEmpty else { return attributed } + + var searchRange = name.startIndex..() + private var cancellables = Set() + + /// 검색 요청마다 증가. 검색어를 바꾼 뒤 도착한 이전 검색 응답을 버린다 + private var searchGeneration = 0 + + private var currentKeyword: String { + (searchBar.textField.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - UI Components + + private let searchBar = MenuSearchBar() + private let tableView = UITableView(frame: .zero, style: .plain) + private let emptyView = EmptyStateView() + private lazy var favoritesHeaderView = makeFavoritesHeaderView() + private let searchResultsHeaderView = UIView( + frame: CGRect(x: 0, y: 0, width: 0, height: Layout.searchResultsTop) + ) + + // MARK: - View Setup + + override func configureUI() { + view.backgroundColor = .white + + tableView.register(MenuFavoriteCell.self, forCellReuseIdentifier: MenuFavoriteCell.identifier) + tableView.dataSource = self + tableView.separatorStyle = .none + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 36 + tableView.showsVerticalScrollIndicator = false + tableView.keyboardDismissMode = .onDrag + tableView.tableFooterView = UIView() + + emptyView.isHidden = true + emptyView.isUserInteractionEnabled = false + + searchBar.textField.delegate = self + + // 빈 영역을 누르면 키보드를 내린다 (하트 등 버튼 터치는 그대로 전달) + let tapGesture = UITapGestureRecognizer(target: view, action: #selector(UIView.endEditing(_:))) + tapGesture.cancelsTouchesInView = false + view.addGestureRecognizer(tapGesture) + + view.addSubviews(emptyView, tableView, searchBar) + } + + override func setLayout() { + searchBar.snp.makeConstraints { + $0.top.equalToSuperview().offset(Layout.searchBarTop) + $0.leading.trailing.equalToSuperview().inset(Layout.horizontalInset) + } + + tableView.snp.makeConstraints { + $0.top.equalTo(searchBar.snp.bottom) + $0.leading.trailing.bottom.equalToSuperview() + } + + // 디자인의 안내 문구 위치는 탭 밑줄 아래 전체 영역 기준 약 45% 지점 + emptyView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + override func setButtonEvent() { + searchBar.textField.addTarget(self, action: #selector(textDidChange), for: .editingChanged) + + keywordSubject + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .sink { [weak self] keyword in + // 디바운스 사이에 검색어가 바뀌었으면(2글자 미만으로 지운 경우 포함) 검색하지 않는다 + guard let self, keyword == self.currentKeyword else { return } + self.search(keyword: keyword) + } + .store(in: &cancellables) + } + + // MARK: - Life Cycle + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + reload() + } + + // MARK: - Public + + /// 찜 목록을 다시 받고, 검색 중이면 같은 검색어로 다시 검색한다 + func reload() { + render() + MenuFavoriteManager.shared.refresh { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.render() + case .failure(let error): + print("메뉴 찜 목록 조회 실패: \(error.localizedDescription)") + if !MenuFavoriteManager.shared.hasLoaded { + self.showToast(message: TextLiteral.Like.menuLoadFailed, type: .danger) + } + } + } + if case .search(let keyword, _, _) = mode, keyword == currentKeyword { + search(keyword: keyword) + } + } + + // MARK: - Search + + @objc private func textDidChange() { + let keyword = currentKeyword + guard keyword.count >= MenuFavoriteManager.minimumKeywordLength else { + // 2글자 미만: 진행 중인 검색 응답을 무시하고 바로 찜 목록으로 돌아간다 + searchGeneration += 1 + mode = .favorites + render() + return + } + keywordSubject.send(keyword) + } + + private func search(keyword: String) { + searchGeneration += 1 + let generation = searchGeneration + MenuFavoriteManager.shared.search(keyword: keyword) { [weak self] result in + guard let self, generation == self.searchGeneration, keyword == self.currentKeyword else { return } + switch result { + case .success(let menus): + self.mode = .search(keyword: keyword, menus: menus, didFail: false) + case .failure(let error): + print("메뉴 검색 실패: \(error.localizedDescription)") + self.mode = .search(keyword: keyword, menus: [], didFail: true) + self.showToast(message: TextLiteral.Like.menuLoadFailed, type: .danger) + } + self.render() + } + } + + // MARK: - Render + + private func render() { + switch mode { + case .favorites: + let favorites = MenuFavoriteManager.shared.favorites + let showsEmpty = MenuFavoriteManager.shared.hasLoaded && favorites.isEmpty + setTableHeader(favorites.isEmpty ? nil : favoritesHeaderView) + emptyView.configure(title: TextLiteral.Like.menuEmptyTitle, subtitle: TextLiteral.Like.menuEmptySubtitle) + emptyView.isHidden = !showsEmpty + case .search(_, let menus, let didFail): + setTableHeader(searchResultsHeaderView) + emptyView.configure( + title: TextLiteral.Like.menuSearchEmptyTitle, + subtitle: TextLiteral.Like.menuSearchEmptySubtitle + ) + emptyView.isHidden = !(menus.isEmpty && !didFail) + } + tableView.reloadData() + } + + private func setTableHeader(_ header: UIView?) { + guard tableView.tableHeaderView !== header else { return } + tableView.tableHeaderView = header + } + + private func makeFavoritesHeaderView() -> UIView { + let header = UIView(frame: CGRect(x: 0, y: 0, width: 0, height: Layout.favoritesHeaderHeight)) + + let titleLabel = UILabel() + titleLabel.text = TextLiteral.Like.menuListTitle + titleLabel.font = .subtitle2 + titleLabel.textColor = .gray700 + + let separator = UIView() + separator.backgroundColor = .gray200 + + header.addSubviews(titleLabel, separator) + titleLabel.snp.makeConstraints { + $0.leading.equalToSuperview().inset(Layout.favoritesInset) + $0.centerY.equalTo(header.snp.top).offset(Layout.favoritesTitleCenterY) + } + separator.snp.makeConstraints { + $0.top.equalToSuperview().offset(Layout.favoritesSeparatorTop) + $0.leading.trailing.equalToSuperview().inset(Layout.favoritesInset) + $0.height.equalTo(1) + } + return header + } + + // MARK: - Favorite + + private func toggleFavorite(at indexPath: IndexPath) { + let manager = MenuFavoriteManager.shared + let item: (menuId: Int, menuName: String, restaurant: String, toBe: Bool) + switch mode { + case .favorites: + guard manager.favorites.indices.contains(indexPath.row) else { return } + let menu = manager.favorites[indexPath.row] + item = (menu.menuId, menu.menuName, menu.restaurant, false) + case .search(_, let menus, _): + guard menus.indices.contains(indexPath.row) else { return } + let menu = menus[indexPath.row] + item = (menu.menuId, menu.menuName, menu.restaurant, !manager.isFavorite(menu)) + } + guard !manager.isPending(menuId: item.menuId) else { return } + + manager.setFavorite( + item.toBe, + menuId: item.menuId, + menuName: item.menuName, + restaurant: item.restaurant + ) { [weak self] result in + guard let self else { return } + switch result { + case .success: + // 찜 목록에서 해제한 메뉴는 목록에서 바로 사라진다 + self.render() + self.showToast( + message: item.toBe ? TextLiteral.Like.menuAddedToast : TextLiteral.Like.menuRemovedToast, + type: .success + ) + case .failure(let error): + print("메뉴 찜 변경 실패 menuId=\(item.menuId): \(error.localizedDescription)") + self.showToast(message: TextLiteral.Like.menuUpdateFailed, type: .danger) + } + } + } + + /// 서버 식당 코드 → 현재 언어의 식당 이름. 앱에 없는 식당(FOOD_COURT 등)은 표시하지 않는다 + private func restaurantTitle(for code: String) -> String? { + Restaurant.allCases.first { $0.identifier == code }?.title + } +} + +// MARK: - UITableViewDataSource + +extension MenuLikeViewController: UITableViewDataSource { + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch mode { + case .favorites: + MenuFavoriteManager.shared.favorites.count + case .search(_, let menus, _): + menus.count + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell( + withIdentifier: MenuFavoriteCell.identifier, + for: indexPath + ) as? MenuFavoriteCell else { return UITableViewCell() } + + switch mode { + case .favorites: + let menu = MenuFavoriteManager.shared.favorites[indexPath.row] + cell.configure(name: menu.menuName, restaurant: nil, isFavorite: true, style: .favorite) + case .search(let keyword, let menus, _): + let menu = menus[indexPath.row] + cell.configure( + name: menu.menuName, + restaurant: restaurantTitle(for: menu.restaurant), + isFavorite: MenuFavoriteManager.shared.isFavorite(menu), + style: .search(keyword: keyword) + ) + } + cell.onTapHeart = { [weak self, weak cell, weak tableView] in + guard let self, let cell, let indexPath = tableView?.indexPath(for: cell) else { return } + self.toggleFavorite(at: indexPath) + } + return cell + } +} + +// MARK: - UITextFieldDelegate + +extension MenuLikeViewController: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + func textFieldShouldClear(_ textField: UITextField) -> Bool { + // X 버튼으로 지운 경우에도 editingChanged와 같은 흐름을 타도록 지워진 뒤 반영한다 + DispatchQueue.main.async { [weak self] in + self?.textDidChange() + } + return true + } +} diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index 94de24f1..5e22c3e9 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -269,6 +269,12 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { ) } + // 다른 탭에서 찜 탭으로 오면 메뉴 찜부터 보여준다 (지도의 플로팅 하트는 showLikedPartnerships로 따로 진입) + if selectedTab == .like, index != selectedIndex, + let likeVC = (viewController as? UINavigationController)?.viewControllers.first as? LikeViewController { + likeVC.prepare(tab: .menu, showsBackToMap: false) + } + // 같은 탭 다시 클릭 시 처리 if let navController = viewController as? UINavigationController, index == selectedIndex { switch selectedTab { diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index a894ba96..af0bddc4 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -1430,14 +1430,54 @@ enum TextLiteral { Localization.localized("like.emptySubtitle", fallback: "관심 있는 제휴를 찜하고 빠르게 확인해 보세요!") } - /// "메뉴 찜은 준비 중이에요." - static var menuComingSoonTitle: String { - Localization.localized("like.menuComingSoonTitle", fallback: "메뉴 찜은 준비 중이에요.") + /// "찜하고 싶은 메뉴를 검색하세요." + static var menuSearchPlaceholder: String { + Localization.localized("like.menuSearchPlaceholder", fallback: "찜하고 싶은 메뉴를 검색하세요.") } - /// "곧 만나볼 수 있어요!" - static var menuComingSoonSubtitle: String { - Localization.localized("like.menuComingSoonSubtitle", fallback: "곧 만나볼 수 있어요!") + /// "아직 찜한 메뉴가 없어요." + static var menuEmptyTitle: String { + Localization.localized("like.menuEmptyTitle", fallback: "아직 찜한 메뉴가 없어요.") + } + + /// "좋아하는 메뉴를 검색해서 등록해 보세요!" + static var menuEmptySubtitle: String { + Localization.localized("like.menuEmptySubtitle", fallback: "좋아하는 메뉴를 검색해서 등록해 보세요!") + } + + /// "내가 찜한 메뉴" + static var menuListTitle: String { + Localization.localized("like.menuListTitle", fallback: "내가 찜한 메뉴") + } + + /// "찜한 메뉴에 추가했어요." + static var menuAddedToast: String { + Localization.localized("like.menuAddedToast", fallback: "찜한 메뉴에 추가했어요.") + } + + /// "찜한 메뉴에서 삭제했어요." + static var menuRemovedToast: String { + Localization.localized("like.menuRemovedToast", fallback: "찜한 메뉴에서 삭제했어요.") + } + + /// "처리하지 못했어요. 잠시 후 다시 시도해주세요." + static var menuUpdateFailed: String { + Localization.localized("like.menuUpdateFailed", fallback: "처리하지 못했어요. 잠시 후 다시 시도해주세요.") + } + + /// "메뉴를 불러오지 못했어요. 잠시 후 다시 시도해주세요." + static var menuLoadFailed: String { + Localization.localized("like.menuLoadFailed", fallback: "메뉴를 불러오지 못했어요.\n잠시 후 다시 시도해주세요.") + } + + /// "일치하는 메뉴가 없어요." + static var menuSearchEmptyTitle: String { + Localization.localized("like.menuSearchEmptyTitle", fallback: "일치하는 메뉴가 없어요.") + } + + /// "다른 메뉴명으로 검색해주세요!" + static var menuSearchEmptySubtitle: String { + Localization.localized("like.menuSearchEmptySubtitle", fallback: "다른 메뉴명으로 검색해주세요!") } } } From be0b3461698b6ead4aebe688d0fb75f13278a49e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:47:32 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[#472]=20=EB=A9=94=EB=89=B4=EB=AA=85=20?= =?UTF-8?q?=EB=B2=88=EC=97=AD=20=EA=B8=B0=EC=A4=80=EC=9D=B4=20=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=95=B1=20=EC=96=B8=EC=96=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=97=90=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/Language/ServerLanguageSync.swift | 28 +++++++++++++++++++ .../Data/Network/Router/MyRouter.swift | 9 ++++++ .../LanguageSettingViewController.swift | 1 + .../CustomTabBarContainerController.swift | 2 ++ 4 files changed, 40 insertions(+) create mode 100644 EATSSU/App/Sources/Data/Language/ServerLanguageSync.swift diff --git a/EATSSU/App/Sources/Data/Language/ServerLanguageSync.swift b/EATSSU/App/Sources/Data/Language/ServerLanguageSync.swift new file mode 100644 index 00000000..15e0c3ee --- /dev/null +++ b/EATSSU/App/Sources/Data/Language/ServerLanguageSync.swift @@ -0,0 +1,28 @@ +// +// ServerLanguageSync.swift +// EATSSU +// +// Created by 황상환 on 10/6/26. +// + +import Foundation + +/// 서버의 유저 언어 설정을 앱 언어와 맞춘다 +/// +/// 메뉴 찜 검색·목록의 메뉴명은 요청 파라미터가 아니라 서버에 저장된 유저 언어(기본 KO) 기준으로 번역된다. +/// 앱 언어는 로컬에만 저장되므로, 로그인 상태로 진입할 때마다 현재 값을 보내 어긋나지 않게 한다. +enum ServerLanguageSync { + static func sync(_ language: AppLanguage = AppLanguageManager.shared.currentLanguage) { + guard TokenStore.hasAccessToken else { return } + NetworkService.shared.request( + MyRouter.updateLanguage(language: language.serverCode), + responseType: Bool.self, + useAuth: true + ) { result in + // 실패해도 다음 진입 때 다시 맞추므로 사용자에게 알리지 않는다 + if case .failure(let error) = result { + print("서버 언어 동기화 실패: \(error.localizedDescription)") + } + } + } +} diff --git a/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift b/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift index 73f287da..925e643f 100644 --- a/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift +++ b/EATSSU/App/Sources/Data/Network/Router/MyRouter.swift @@ -23,6 +23,8 @@ enum MyRouter { case getLikedPartnerships case colleges case departments(collegeId: Int) + /// 유저 언어 설정 변경. 메뉴 찜 검색·목록의 메뉴명 번역 기준 (KO/EN/JA/VI) + case updateLanguage(language: String) } extension MyRouter: TargetType { @@ -50,6 +52,8 @@ extension MyRouter: TargetType { "/users/lookup/colleges" case .departments: "/users/lookup/departments" + case .updateLanguage: + "/users/language" } } @@ -63,6 +67,8 @@ extension MyRouter: TargetType { .delete case .inquiry: .post + case .updateLanguage: + .patch } } @@ -102,6 +108,9 @@ extension MyRouter: TargetType { case let .departments(collegeId): .requestParameters(parameters: ["collegeId": collegeId], encoding: URLEncoding.queryString) + case let .updateLanguage(language): + .requestParameters(parameters: ["language": language], + encoding: JSONEncoding.default) } } diff --git a/EATSSU/App/Sources/Presentation/MyPage/ViewController/LanguageSettingViewController.swift b/EATSSU/App/Sources/Presentation/MyPage/ViewController/LanguageSettingViewController.swift index d4e66213..ab22ce68 100644 --- a/EATSSU/App/Sources/Presentation/MyPage/ViewController/LanguageSettingViewController.swift +++ b/EATSSU/App/Sources/Presentation/MyPage/ViewController/LanguageSettingViewController.swift @@ -120,6 +120,7 @@ final class LanguageSettingViewController: BaseViewController { } AppLanguageManager.shared.changeLanguage(to: language) + ServerLanguageSync.sync(language) didChangeLanguage = true AnalyticsService.logEvent("change_language", parameters: ["language": language.rawValue]) diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index 5e22c3e9..17425c9f 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -42,6 +42,8 @@ final class CustomTabBarContainerController: UITabBarController { setupTabBar() setupViewControllers() delegate = self + // 로그인 상태의 모든 진입(앱 실행·로그인·가입·언어 변경 후 재구성)이 이 화면을 거치므로 여기서 서버 언어를 맞춘다 + ServerLanguageSync.sync() } // MARK: - Setup From fc8784a1e91ee121fa4a514eec4d8820881813ea Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:03:17 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[#472]=20=EC=B0=9C=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=C2=B7=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC=EA=B0=80=20?= =?UTF-8?q?=EB=B9=84=EC=97=88=EC=9D=84=20=EB=95=8C=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=EA=B0=80=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=EC=97=90=20=EA=B0=80=EB=A0=A4=EC=A7=80=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Like/ViewController/MenuLikeViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift index 581b6c23..431ec2fd 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift @@ -196,6 +196,8 @@ final class MenuLikeViewController: BaseViewController { ) emptyView.isHidden = !(menus.isEmpty && !didFail) } + // 안내 문구는 흰 배경 테이블 뒤에 있으므로, 보일 때는 테이블을 숨긴다 (제휴 찜 목록과 같은 방식) + tableView.isHidden = !emptyView.isHidden tableView.reloadData() } From ba4b47f40b788745b574c458f76e5d2a41be2369 Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:21:16 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[#472]=20=EC=B0=9C=ED=95=9C=20=EB=A9=94?= =?UTF-8?q?=EB=89=B4=20=EB=AA=A9=EB=A1=9D=EC=97=90=EB=8F=84=20=EC=8B=9D?= =?UTF-8?q?=EB=8B=B9=EB=AA=85=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Like/View/MenuFavoriteCell.swift | 2 +- .../Like/ViewController/MenuLikeViewController.swift | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift b/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift index 2b6d3c26..07aa007f 100644 --- a/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift +++ b/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift @@ -11,7 +11,7 @@ import SnapKit import EATSSUDesign -/// 메뉴 찜 한 줄: 메뉴명 · (검색 결과) 식당명 · 하트 +/// 메뉴 찜 한 줄: 메뉴명 · 식당명 · 하트 final class MenuFavoriteCell: UITableViewCell { // MARK: - Types diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift index 431ec2fd..17772c1b 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift @@ -297,7 +297,12 @@ extension MenuLikeViewController: UITableViewDataSource { switch mode { case .favorites: let menu = MenuFavoriteManager.shared.favorites[indexPath.row] - cell.configure(name: menu.menuName, restaurant: nil, isFavorite: true, style: .favorite) + cell.configure( + name: menu.menuName, + restaurant: restaurantTitle(for: menu.restaurant), + isFavorite: true, + style: .favorite + ) case .search(let keyword, let menus, _): let menu = menus[indexPath.row] cell.configure( From 4da51d124411d63ce21aaa0bc4b4c77ca4dd5aab Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:35:04 +0900 Subject: [PATCH 06/10] =?UTF-8?q?[#472]=201=EA=B8=80=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=ED=95=98=EB=A9=B4=202=EA=B8=80=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=20=EC=9E=85=EB=A0=A5=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=ED=86=A0=EC=8A=A4=ED=8A=B8=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- EATSSU/App/Resources/en.lproj/Localizable.strings | 2 ++ EATSSU/App/Resources/ja.lproj/Localizable.strings | 2 ++ EATSSU/App/Resources/ko.lproj/Localizable.strings | 2 ++ EATSSU/App/Resources/vi.lproj/Localizable.strings | 2 ++ .../Like/ViewController/MenuLikeViewController.swift | 5 +++++ EATSSU/App/Sources/Utility/Literal/TextLiteral.swift | 5 +++++ 6 files changed, 18 insertions(+) diff --git a/EATSSU/App/Resources/en.lproj/Localizable.strings b/EATSSU/App/Resources/en.lproj/Localizable.strings index 5dd7c201..61ea5310 100644 --- a/EATSSU/App/Resources/en.lproj/Localizable.strings +++ b/EATSSU/App/Resources/en.lproj/Localizable.strings @@ -724,3 +724,5 @@ "like.menuSearchEmptyTitle" = "No matching menu items found."; /// "다른 메뉴명으로 검색해주세요!" "like.menuSearchEmptySubtitle" = "Try searching for a different menu name!"; +/// "2글자 이상 입력해주세요." +"like.menuSearchTooShort" = "Please enter at least 2 characters."; diff --git a/EATSSU/App/Resources/ja.lproj/Localizable.strings b/EATSSU/App/Resources/ja.lproj/Localizable.strings index 9c5af959..85d0f374 100644 --- a/EATSSU/App/Resources/ja.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ja.lproj/Localizable.strings @@ -725,3 +725,5 @@ "like.menuSearchEmptyTitle" = "一致するメニューがありません。"; /// "다른 메뉴명으로 검색해주세요!" "like.menuSearchEmptySubtitle" = "別のメニュー名で検索してください!"; +/// "2글자 이상 입력해주세요." +"like.menuSearchTooShort" = "2文字以上入力してください。"; diff --git a/EATSSU/App/Resources/ko.lproj/Localizable.strings b/EATSSU/App/Resources/ko.lproj/Localizable.strings index 88372640..20e7f796 100644 --- a/EATSSU/App/Resources/ko.lproj/Localizable.strings +++ b/EATSSU/App/Resources/ko.lproj/Localizable.strings @@ -725,3 +725,5 @@ "like.menuSearchEmptyTitle" = "일치하는 메뉴가 없어요."; /// "다른 메뉴명으로 검색해주세요!" "like.menuSearchEmptySubtitle" = "다른 메뉴명으로 검색해주세요!"; +/// "2글자 이상 입력해주세요." +"like.menuSearchTooShort" = "2글자 이상 입력해주세요."; diff --git a/EATSSU/App/Resources/vi.lproj/Localizable.strings b/EATSSU/App/Resources/vi.lproj/Localizable.strings index 8128112c..5afb46ea 100644 --- a/EATSSU/App/Resources/vi.lproj/Localizable.strings +++ b/EATSSU/App/Resources/vi.lproj/Localizable.strings @@ -725,3 +725,5 @@ "like.menuSearchEmptyTitle" = "Không tìm thấy món ăn phù hợp."; /// "다른 메뉴명으로 검색해주세요!" "like.menuSearchEmptySubtitle" = "Hãy thử tìm bằng tên món ăn khác!"; +/// "2글자 이상 입력해주세요." +"like.menuSearchTooShort" = "Vui lòng nhập ít nhất 2 ký tự."; diff --git a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift index 17772c1b..40b9f2b5 100644 --- a/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift +++ b/EATSSU/App/Sources/Presentation/Like/ViewController/MenuLikeViewController.swift @@ -326,6 +326,11 @@ extension MenuLikeViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.resignFirstResponder() + // 1글자로 검색을 시도하면 2글자부터 검색된다는 것을 알린다 (빈 검색어는 키보드만 내린다) + let length = currentKeyword.count + if length > 0, length < MenuFavoriteManager.minimumKeywordLength { + showToast(message: TextLiteral.Like.menuSearchTooShort, type: .danger) + } return true } diff --git a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift index af0bddc4..91c680e1 100644 --- a/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift +++ b/EATSSU/App/Sources/Utility/Literal/TextLiteral.swift @@ -1479,5 +1479,10 @@ enum TextLiteral { static var menuSearchEmptySubtitle: String { Localization.localized("like.menuSearchEmptySubtitle", fallback: "다른 메뉴명으로 검색해주세요!") } + + /// "2글자 이상 입력해주세요." + static var menuSearchTooShort: String { + Localization.localized("like.menuSearchTooShort", fallback: "2글자 이상 입력해주세요.") + } } } From ac9f8c49686a7dccc176f355b1b77cf3cc479e6c Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:51:30 +0900 Subject: [PATCH 07/10] =?UTF-8?q?[#472]=20=EC=8B=A4=ED=8C=A8=ED=95=9C=20?= =?UTF-8?q?=EC=B0=9C=20=EC=9A=94=EC=B2=AD=EA=B3=BC=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=A0=84=20=EC=9A=94=EC=B2=AD=EC=9D=98=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=B4=20=EC=B0=9C=20=EC=83=81=ED=83=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/Data/Like/MenuFavoriteManager.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift b/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift index f26265c1..fd4c03c1 100644 --- a/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift +++ b/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift @@ -42,6 +42,9 @@ final class MenuFavoriteManager { /// 찜 상태가 바뀔 때마다 증가. 변경 이전에 시작된 목록 조회 응답이 최신 로컬 상태를 덮어쓰지 않게 한다 private var stateVersion = 0 + /// `reset()`마다 증가. 초기화 전에 시작된 찜 요청의 응답이 다음 계정의 상태에 반영되지 않게 한다 + private var sessionVersion = 0 + /// 로그아웃·탈퇴·세션 만료 시 호출. 다른 계정의 찜 상태가 남지 않게 비운다 func reset() { favorites = [] @@ -49,6 +52,7 @@ final class MenuFavoriteManager { toggledStates = [:] pendingMenuIds = [] stateVersion += 1 + sessionVersion += 1 } // MARK: - Query @@ -112,15 +116,16 @@ final class MenuFavoriteManager { ) { guard !pendingMenuIds.contains(menuId) else { return } pendingMenuIds.insert(menuId) - // 요청 시작 시점에 버전을 올려, 이보다 먼저 시작된 목록 조회 응답이 변경 전 상태를 되살리지 못하게 한다 - stateVersion += 1 + let session = sessionVersion let target: MenuFavoriteRouter = isFavorite ? .add(menuId: menuId) : .remove(menuId: menuId) NetworkService.shared.request(target, responseType: Bool.self, useAuth: true) { [weak self] result in - guard let self else { return } + // 요청 중에 로그아웃 등으로 초기화됐으면 이전 계정의 응답이므로 버린다 + guard let self, session == self.sessionVersion else { return } self.pendingMenuIds.remove(menuId) switch result { case .success: + // 서버 변경이 성공한 시점에 버전을 올린다. 실패한 요청은 진행 중인 목록 조회를 무효화하지 않는다 self.stateVersion += 1 self.toggledStates[menuId] = isFavorite self.favorites.removeAll { $0.menuId == menuId } From 41d52ae22125df537755a15bb90380302f0de29b Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:32:31 +0900 Subject: [PATCH 08/10] =?UTF-8?q?[#472]=20=EC=B2=AB=20=EC=B0=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EC=B0=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EA=B3=BC=20=EA=B2=B9=EC=B3=90=EB=8F=84=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=C2=B7=ED=95=98=ED=8A=B8=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EA=B0=80=20=EC=96=B4=EA=B8=8B=EB=82=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Sources/Data/Like/MenuFavoriteManager.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift b/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift index fd4c03c1..e988c40c 100644 --- a/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift +++ b/EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift @@ -57,8 +57,12 @@ final class MenuFavoriteManager { // MARK: - Query + /// 이 세션에서 바꾼 상태 > (목록 확보 후) 찜 목록 > 검색 응답의 isFavorite 순으로 판단한다. + /// 목록 갱신으로 세션 상태가 비워져도, 화면이 들고 있던 이전 검색 결과의 하트가 되돌아가지 않게 한다 func isFavorite(_ menu: MenuSearchDTO) -> Bool { - toggledStates[menu.menuId] ?? menu.isFavorite + if let toggled = toggledStates[menu.menuId] { return toggled } + guard hasLoaded else { return menu.isFavorite } + return favorites.contains { $0.menuId == menu.menuId } } func isPending(menuId: Int) -> Bool { @@ -80,7 +84,12 @@ final class MenuFavoriteManager { case .success(let favorites): // 조회 중에 찜 상태가 바뀌었으면 이 응답은 이미 낡은 것이므로 로컬 상태를 유지한다 guard version == self.stateVersion else { - completion(.success(self.favorites)) + // 아직 목록을 한 번도 못 받았으면 로컬에는 방금 바꾼 메뉴만 있으므로 다시 받아온다 + if self.hasLoaded { + completion(.success(self.favorites)) + } else { + self.refresh(completion: completion) + } return } self.favorites = favorites From aa2e1f3c550d18e4cc9a7b6013b75d6795ad2d6d Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:32:31 +0900 Subject: [PATCH 09/10] =?UTF-8?q?[#472]=20=EC=A0=9C=ED=9C=B4=20=EC=B0=9C?= =?UTF-8?q?=20=ED=8E=B8=EC=A7=91=20=ED=99=94=EB=A9=B4=EC=9D=B4=20=EB=82=A8?= =?UTF-8?q?=EC=95=84=20=EC=9E=88=EC=9C=BC=EB=A9=B4=20=EC=B0=9C=20=ED=83=AD?= =?UTF-8?q?=20=EB=B3=B5=EA=B7=80=20=EC=8B=9C=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EC=B0=9C=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TabBar/CustomTabBarContainerController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift index 17425c9f..92cca80c 100644 --- a/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift +++ b/EATSSU/App/Sources/Presentation/TabBar/CustomTabBarContainerController.swift @@ -272,8 +272,11 @@ extension CustomTabBarContainerController: UITabBarControllerDelegate { } // 다른 탭에서 찜 탭으로 오면 메뉴 찜부터 보여준다 (지도의 플로팅 하트는 showLikedPartnerships로 따로 진입) + // 제휴 찜 편집처럼 위에 push된 화면이 남아 있으면 그 흐름을 유지한다 if selectedTab == .like, index != selectedIndex, - let likeVC = (viewController as? UINavigationController)?.viewControllers.first as? LikeViewController { + let navController = viewController as? UINavigationController, + navController.viewControllers.count == 1, + let likeVC = navController.viewControllers.first as? LikeViewController { likeVC.prepare(tab: .menu, showsBackToMap: false) } From e197384a39d0e6c5b6f5e28186516ee665b1880e Mon Sep 17 00:00:00 2001 From: Hwang Sang Hwan <137605270+Hrepay@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:32:31 +0900 Subject: [PATCH 10/10] =?UTF-8?q?[#472]=20=EB=A9=94=EB=89=B4=20=EC=B0=9C?= =?UTF-8?q?=20=EC=85=80=20=EC=8A=A4=ED=83=80=EC=9D=BC=EC=9D=B4=20=EA=B0=99?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EC=A0=9C=EC=95=BD=EC=9D=84=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=A7=8C=EB=93=A4=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Presentation/Like/View/MenuFavoriteCell.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift b/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift index 07aa007f..0b73dc14 100644 --- a/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift +++ b/EATSSU/App/Sources/Presentation/Like/View/MenuFavoriteCell.swift @@ -44,6 +44,9 @@ final class MenuFavoriteCell: UITableViewCell { var onTapHeart: (() -> Void)? + /// 마지막으로 적용한 여백. 같으면 제약을 다시 만들지 않는다 + private var appliedInsets: (inset: CGFloat, vertical: CGFloat)? + // MARK: - UI Components private let nameLabel = UILabel() @@ -100,6 +103,9 @@ final class MenuFavoriteCell: UITableViewCell { } private func applyLayout(inset: CGFloat, verticalInset: CGFloat) { + // 스타일이 바뀔 때만 제약을 다시 만든다 (재사용 셀은 대부분 같은 스타일) + guard appliedInsets?.inset != inset || appliedInsets?.vertical != verticalInset else { return } + appliedInsets = (inset, verticalInset) // 24pt 아이콘이 디자인 위치에 오도록 44pt 터치 영역의 중심을 아이콘 중심에 맞춘다 let heartCenterTrailing = inset + Layout.heartSize / 2 heartButton.snp.remakeConstraints {