Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions EATSSU/App/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,25 @@
"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!";
/// "2글자 이상 입력해주세요."
"like.menuSearchTooShort" = "Please enter at least 2 characters.";
26 changes: 22 additions & 4 deletions EATSSU/App/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,25 @@
"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" = "別のメニュー名で検索してください!";
/// "2글자 이상 입력해주세요."
"like.menuSearchTooShort" = "2文字以上入力してください。";
26 changes: 22 additions & 4 deletions EATSSU/App/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,25 @@
"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" = "다른 메뉴명으로 검색해주세요!";
/// "2글자 이상 입력해주세요."
"like.menuSearchTooShort" = "2글자 이상 입력해주세요.";
26 changes: 22 additions & 4 deletions EATSSU/App/Resources/vi.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,25 @@
"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!";
/// "2글자 이상 입력해주세요."
"like.menuSearchTooShort" = "Vui lòng nhập ít nhất 2 ký tự.";
28 changes: 28 additions & 0 deletions EATSSU/App/Sources/Data/Language/ServerLanguageSync.swift
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)")
}
}
}
}
181 changes: 181 additions & 0 deletions EATSSU/App/Sources/Data/Like/MenuFavoriteManager.swift
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
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)
}
}
1 change: 1 addition & 0 deletions EATSSU/App/Sources/Data/LocalDB/AccountStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ enum AccountStorage {
UserInfoManager.shared.clear()
// 찜 목록·순서
PartnershipLikeManager.shared.reset()
MenuFavoriteManager.shared.reset()
}
}
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
}
Loading
Loading