Repository navigation
[Feat/#472] 찜 탭 메뉴 찜 검색·목록 구현 #475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
08fd90b
[#472] 메뉴 찜 API 연결과 찜 상태 관리 추가
Hrepay c97f5f0
[#472] 찜 탭 메뉴 찜 검색·목록 화면 구현
Hrepay be0b346
[#472] 메뉴명 번역 기준이 되도록 앱 언어를 서버에 동기화
Hrepay fc8784a
[#472] 찜 목록·검색 결과가 비었을 때 안내 문구가 테이블에 가려지지 않도록 수정
Hrepay ba4b47f
[#472] 찜한 메뉴 목록에도 식당명 표시
Hrepay 4da51d1
[#472] 1글자로 검색하면 2글자 이상 입력 안내 토스트 표시
Hrepay ac9f8c4
[#472] 실패한 찜 요청과 초기화 전 요청의 응답이 찜 상태를 바꾸지 않도록 수정
Hrepay 41d52ae
[#472] 첫 찜 목록 조회가 찜 변경과 겹쳐도 목록·하트 상태가 어긋나지 않도록 수정
Hrepay aa2e1f3
[#472] 제휴 찜 편집 화면이 남아 있으면 찜 탭 복귀 시 메뉴 찜으로 바꾸지 않도록 수정
Hrepay e197384
[#472] 메뉴 찜 셀 스타일이 같으면 제약을 다시 만들지 않도록 수정
Hrepay File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)") | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| // | ||
| // 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<Int> = [] | ||
|
|
||
| /// 찜 상태가 바뀔 때마다 증가. 변경 이전에 시작된 목록 조회 응답이 최신 로컬 상태를 덮어쓰지 않게 한다 | ||
| private var stateVersion = 0 | ||
|
|
||
| /// `reset()`마다 증가. 초기화 전에 시작된 찜 요청의 응답이 다음 계정의 상태에 반영되지 않게 한다 | ||
| private var sessionVersion = 0 | ||
|
|
||
| /// 로그아웃·탈퇴·세션 만료 시 호출. 다른 계정의 찜 상태가 남지 않게 비운다 | ||
| func reset() { | ||
| favorites = [] | ||
| hasLoaded = false | ||
| toggledStates = [:] | ||
| pendingMenuIds = [] | ||
| stateVersion += 1 | ||
| sessionVersion += 1 | ||
| } | ||
|
|
||
| // MARK: - Query | ||
|
|
||
| /// 이 세션에서 바꾼 상태 > (목록 확보 후) 찜 목록 > 검색 응답의 isFavorite 순으로 판단한다. | ||
| /// 목록 갱신으로 세션 상태가 비워져도, 화면이 들고 있던 이전 검색 결과의 하트가 되돌아가지 않게 한다 | ||
| func isFavorite(_ menu: MenuSearchDTO) -> Bool { | ||
| 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 { | ||
| 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 { | ||
| // 아직 목록을 한 번도 못 받았으면 로컬에는 방금 바꾼 메뉴만 있으므로 다시 받아온다 | ||
| if self.hasLoaded { | ||
| completion(.success(self.favorites)) | ||
| } else { | ||
| self.refresh(completion: completion) | ||
| } | ||
| 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, Error>) -> Void | ||
| ) { | ||
| guard !pendingMenuIds.contains(menuId) else { return } | ||
| pendingMenuIds.insert(menuId) | ||
|
|
||
| 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, session == self.sessionVersion else { return } | ||
| self.pendingMenuIds.remove(menuId) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
26 changes: 26 additions & 0 deletions
26
EATSSU/App/Sources/Data/Network/DTO/MenuFavorite/MenuFavoriteDTO.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.