diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9f2cabe8..36e52fbde 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -92,6 +92,13 @@ android { val postHogHost: String = p.getProperty("POSTHOG_HOST") buildConfigField("String", "POSTHOG_HOST", "\"$postHogHost\"") + val holidayApiKey: String = p.getProperty("HOLIDAY_API_KEY") ?: "" + buildConfigField( + "String", + "HOLIDAY_API_KEY", + "\"$holidayApiKey\"" + ) + isShrinkResources = true isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") @@ -125,6 +132,13 @@ android { val postHogHost: String = p.getProperty("POSTHOG_HOST") buildConfigField("String", "POSTHOG_HOST", "\"$postHogHost\"") + val holidayApiKey: String = p.getProperty("HOLIDAY_API_KEY") ?: "" + buildConfigField( + "String", + "HOLIDAY_API_KEY", + "\"$holidayApiKey\"" + ) + isMinifyEnabled = false } } diff --git a/app/src/main/java/com/eatssu/android/data/remote/dto/response/PublicHolidayApiResponse.kt b/app/src/main/java/com/eatssu/android/data/remote/dto/response/PublicHolidayApiResponse.kt new file mode 100644 index 000000000..2af2339a3 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/dto/response/PublicHolidayApiResponse.kt @@ -0,0 +1,64 @@ +package com.eatssu.android.data.remote.dto.response + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonTransformingSerializer + +@Serializable +data class PublicHolidayApiResponse( + @SerialName("response") val response: Response? = null, +) { + @Serializable + data class Response( + @SerialName("header") val header: Header? = null, + @SerialName("body") val body: Body? = null, + ) + + @Serializable + data class Header( + @SerialName("resultCode") val resultCode: String? = null, + @SerialName("resultMsg") val resultMsg: String? = null, + ) + + @Serializable + data class Body( + @SerialName("items") val items: Items? = null, + @SerialName("numOfRows") val numOfRows: Int? = null, + @SerialName("pageNo") val pageNo: Int? = null, + @SerialName("totalCount") val totalCount: Int? = null, + ) + + @Serializable + data class Items( + @Serializable(with = PublicHolidayItemListSerializer::class) + @SerialName("item") val item: List = emptyList(), + ) + + @OptIn(ExperimentalSerializationApi::class) + /** + * 공휴일 API는 item이 1개일 때는 Object, 여러 개일 때는 Array로 내려주는 케이스가 있어 + * 역직렬화 시 항상 List 형태로 정규화한다. + */ + object PublicHolidayItemListSerializer : JsonTransformingSerializer>( + ListSerializer(Item.serializer()) + ) { + override fun transformDeserialize(element: JsonElement): JsonElement { + return when (element) { + is JsonObject -> JsonArray(listOf(element)) + else -> element + } + } + } + + @Serializable + data class Item( + @SerialName("locdate") val locdate: Long? = null, + @SerialName("isHoliday") val isHoliday: String? = null, + @SerialName("dateName") val dateName: String? = null, + ) +} diff --git a/app/src/main/java/com/eatssu/android/data/remote/repository/PublicHolidayRepositoryImpl.kt b/app/src/main/java/com/eatssu/android/data/remote/repository/PublicHolidayRepositoryImpl.kt new file mode 100644 index 000000000..bb3205d9f --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/repository/PublicHolidayRepositoryImpl.kt @@ -0,0 +1,99 @@ +package com.eatssu.android.data.remote.repository + +import com.eatssu.android.data.remote.service.PublicHolidayService +import com.eatssu.android.domain.model.PublicHoliday +import com.eatssu.android.domain.repository.PublicHolidayRepository +import timber.log.Timber +import java.net.URLEncoder +import java.time.LocalDate +import java.time.YearMonth +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class PublicHolidayRepositoryImpl @Inject constructor( + private val publicHolidayService: PublicHolidayService, + @Named(PUBLIC_HOLIDAY_SERVICE_KEY_NAME) private val serviceKey: String, +) : PublicHolidayRepository { + + companion object { + const val PUBLIC_HOLIDAY_SERVICE_KEY_NAME: String = "PublicHolidayServiceKey" + } + + /** + * 외부 공휴일 API를 호출해 해당 [YearMonth]의 공휴일 목록을 조회한다. + * + * - `HOLIDAY_API_KEY`가 비어있으면 네트워크 호출 없이 빈 리스트를 반환한다. + * - 네트워크/파싱 실패 또는 비정상 resultCode인 경우 빈 리스트를 반환한다. + * - `isHoliday == "Y"`만 필터링하고, 날짜 기준으로 중복 제거 후 오름차순 정렬한다. + */ + override suspend fun getHolidays(yearMonth: YearMonth): List { + if (serviceKey.isBlank()) { + Timber.w("HOLIDAY_API_KEY is blank; skipping public holiday fetch") + return emptyList() + } + + val normalizedKey = normalizeServiceKey(serviceKey) + if (normalizedKey.isBlank()) return emptyList() + + try { + val response = publicHolidayService.getRestDeInfo( + serviceKey = normalizedKey, + solYear = yearMonth.year.toString(), + solMonth = yearMonth.monthValue.toString().padStart(2, '0'), + ) + + val resultCode = response.response?.header?.resultCode + if (resultCode != "00") { + Timber.w( + "PublicHoliday API returned non-normal resultCode=%s msg=%s", + resultCode, + response.response?.header?.resultMsg, + ) + + return emptyList() + } else { + return response.response + .body + ?.items + ?.item + .orEmpty() + .asSequence() + .filter { it.isHoliday.equals("Y", ignoreCase = true) } + .mapNotNull { item -> + val date = item.locdate?.let(::parseLocalDate) + val name = item.dateName?.trim().orEmpty() + + if (date == null || name.isBlank()) return@mapNotNull null + PublicHoliday(date = date, name = name) + } + .distinctBy { it.date } + .sortedBy { it.date } + .toList() + } + } catch (t: Throwable) { + Timber.w(t, "Failed to fetch public holidays") + return emptyList() + } + } + + private fun parseLocalDate(localDate: Long): LocalDate? { + val s = localDate.toString() + if (s.length != 8) return null + + return runCatching { + val year = s.substring(0, 4).toInt() + val month = s.substring(4, 6).toInt() + val day = s.substring(6, 8).toInt() + LocalDate.of(year, month, day) + }.getOrNull() + } + + private fun normalizeServiceKey(value: String): String { + val trimmed = value.trim() + if (trimmed.isEmpty()) return "" + + return if ('%' in trimmed) trimmed else URLEncoder.encode(trimmed, Charsets.UTF_8.name()) + } +} diff --git a/app/src/main/java/com/eatssu/android/data/remote/service/PublicHolidayService.kt b/app/src/main/java/com/eatssu/android/data/remote/service/PublicHolidayService.kt new file mode 100644 index 000000000..74e98279a --- /dev/null +++ b/app/src/main/java/com/eatssu/android/data/remote/service/PublicHolidayService.kt @@ -0,0 +1,26 @@ +package com.eatssu.android.data.remote.service + +import com.eatssu.android.data.remote.dto.response.PublicHolidayApiResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface PublicHolidayService { + + /** + * data.go.kr 공휴일(OpenAPI) 호출용 API. + * + * - 엔드포인트: SpcdeInfoService/getRestDeInfo + * - ServiceKey는 이미 URL 인코딩된 값으로 전달한다(`encoded = true`). + * - solYear/solMonth는 양력 기준 연/월이다. + * - `_type=json`으로 JSON 응답을 받는다. + */ + @GET("B090041/openapi/service/SpcdeInfoService/getRestDeInfo") + suspend fun getRestDeInfo( + @Query(value = "ServiceKey", encoded = true) serviceKey: String, + @Query("solYear") solYear: String, + @Query("solMonth") solMonth: String, + @Query("numOfRows") numOfRows: Int = 50, + @Query("pageNo") pageNo: Int = 1, + @Query("_type") type: String = "json", + ): PublicHolidayApiResponse +} diff --git a/app/src/main/java/com/eatssu/android/di/DataModule.kt b/app/src/main/java/com/eatssu/android/di/DataModule.kt index 7f295c26f..618645a84 100644 --- a/app/src/main/java/com/eatssu/android/di/DataModule.kt +++ b/app/src/main/java/com/eatssu/android/di/DataModule.kt @@ -7,6 +7,7 @@ import com.eatssu.android.data.remote.repository.MealRepositoryImpl import com.eatssu.android.data.remote.repository.MenuRepositoryImpl import com.eatssu.android.data.remote.repository.OauthRepositoryImpl import com.eatssu.android.data.remote.repository.PartnershipRepositoryImpl +import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl import com.eatssu.android.data.remote.repository.ReportRepositoryImpl import com.eatssu.android.data.remote.repository.ReviewRepositoryImpl import com.eatssu.android.data.remote.repository.UserRepositoryImpl @@ -16,6 +17,7 @@ import com.eatssu.android.domain.repository.MealRepository import com.eatssu.android.domain.repository.MenuRepository import com.eatssu.android.domain.repository.OauthRepository import com.eatssu.android.domain.repository.PartnershipRepository +import com.eatssu.android.domain.repository.PublicHolidayRepository import com.eatssu.android.domain.repository.ReportRepository import com.eatssu.android.domain.repository.ReviewRepository import com.eatssu.android.domain.repository.UserRepository @@ -72,4 +74,9 @@ abstract class DataModule { internal abstract fun bindsFirebaseRemoteConfigRepository( firebaseRemoteConfigRepositoryImpl: FirebaseRemoteConfigRepositoryImpl, ): FirebaseRemoteConfigRepository + + @Binds + internal abstract fun bindsPublicHolidayRepository( + publicHolidayRepositoryImpl: PublicHolidayRepositoryImpl, + ): PublicHolidayRepository } diff --git a/app/src/main/java/com/eatssu/android/di/PublicHolidayModule.kt b/app/src/main/java/com/eatssu/android/di/PublicHolidayModule.kt new file mode 100644 index 000000000..f02ebda0f --- /dev/null +++ b/app/src/main/java/com/eatssu/android/di/PublicHolidayModule.kt @@ -0,0 +1,64 @@ +package com.eatssu.android.di + +import com.eatssu.android.BuildConfig +import com.eatssu.android.data.remote.repository.PublicHolidayRepositoryImpl +import com.eatssu.android.data.remote.service.PublicHolidayService +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import javax.inject.Named +import javax.inject.Qualifier +import javax.inject.Singleton + +@Qualifier +@Retention(AnnotationRetention.BINARY) +/** 공휴일 API 전용 Retrofit 구분자. */ +annotation class PublicHolidayApi + +/** + * 공휴일 API 전용 Retrofit/Service 제공 모듈. + * + * - 인증 토큰이 필요 없는 외부 API이므로 `@NoToken` OkHttpClient를 사용한다. + * - 키는 `BuildConfig.HOLIDAY_API_KEY`로 주입되며, 비어있을 수 있다(로컬 환경 등). + */ +@Module +@InstallIn(SingletonComponent::class) +object PublicHolidayModule { + + private const val PUBLIC_HOLIDAY_BASE_URL = "https://apis.data.go.kr/" + + @Provides + @Singleton + @Named(PublicHolidayRepositoryImpl.PUBLIC_HOLIDAY_SERVICE_KEY_NAME) + fun providePublicHolidayServiceKey(): String { + return BuildConfig.HOLIDAY_API_KEY + } + + @Provides + @Singleton + @PublicHolidayApi + fun providePublicHolidayRetrofit( + @NoToken okHttpClient: OkHttpClient, + json: Json, + ): Retrofit { + return Retrofit.Builder() + .baseUrl(PUBLIC_HOLIDAY_BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + } + + @Provides + @Singleton + fun providePublicHolidayService( + @PublicHolidayApi retrofit: Retrofit, + ): PublicHolidayService { + return retrofit.create(PublicHolidayService::class.java) + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/model/MenuLoadResult.kt b/app/src/main/java/com/eatssu/android/domain/model/MenuLoadResult.kt new file mode 100644 index 000000000..886f88cd8 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/model/MenuLoadResult.kt @@ -0,0 +1,7 @@ +package com.eatssu.android.domain.model + +import com.eatssu.common.enums.Restaurant + +data class MenuLoadResult( + val menuMap: Map>, +) diff --git a/app/src/main/java/com/eatssu/android/domain/model/PublicHoliday.kt b/app/src/main/java/com/eatssu/android/domain/model/PublicHoliday.kt new file mode 100644 index 000000000..798ae5b3e --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/model/PublicHoliday.kt @@ -0,0 +1,8 @@ +package com.eatssu.android.domain.model + +import java.time.LocalDate + +data class PublicHoliday( + val date: LocalDate, + val name: String, +) diff --git a/app/src/main/java/com/eatssu/android/domain/repository/PublicHolidayRepository.kt b/app/src/main/java/com/eatssu/android/domain/repository/PublicHolidayRepository.kt new file mode 100644 index 000000000..74cc8624b --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/repository/PublicHolidayRepository.kt @@ -0,0 +1,9 @@ +package com.eatssu.android.domain.repository + +import com.eatssu.android.domain.model.PublicHoliday +import java.time.YearMonth + +interface PublicHolidayRepository { + + suspend fun getHolidays(yearMonth: YearMonth): List +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/holiday/GetPublicHolidayOfDateUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/holiday/GetPublicHolidayOfDateUseCase.kt new file mode 100644 index 000000000..0d9a07fa1 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/holiday/GetPublicHolidayOfDateUseCase.kt @@ -0,0 +1,18 @@ +package com.eatssu.android.domain.usecase.holiday + +import com.eatssu.android.domain.model.PublicHoliday +import java.time.LocalDate +import java.time.YearMonth +import javax.inject.Inject + +/** + * 특정 날짜가 공휴일이면 해당 공휴일 정보를 반환한다. + */ +class GetPublicHolidayOfDateUseCase @Inject constructor( + private val getPublicHolidaysOfMonthUseCase: GetPublicHolidaysOfMonthUseCase, +) { + suspend operator fun invoke(date: LocalDate): PublicHoliday? { + val holidays = getPublicHolidaysOfMonthUseCase(YearMonth.from(date)) + return holidays.firstOrNull { it.date == date } + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/holiday/GetPublicHolidaysOfMonthUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/holiday/GetPublicHolidaysOfMonthUseCase.kt new file mode 100644 index 000000000..6592fdd8d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/holiday/GetPublicHolidaysOfMonthUseCase.kt @@ -0,0 +1,51 @@ +package com.eatssu.android.domain.usecase.holiday + +import com.eatssu.android.domain.model.PublicHoliday +import com.eatssu.android.domain.repository.PublicHolidayRepository +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.time.YearMonth +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 지정한 [YearMonth]의 공휴일 목록을 조회한다. + * + * 캐싱 정책(월 단위 메모리 캐시)은 usecase가 소유하고, + * repository는 데이터 접근(원격/로컬)에만 집중한다. + */ +@Singleton +class GetPublicHolidaysOfMonthUseCase @Inject constructor( + private val publicHolidayRepository: PublicHolidayRepository, +) { + companion object { + private const val MAX_CACHE_SIZE: Int = 12 + } + + private val mutex = Mutex() + private val cache = object : LinkedHashMap>( + /* initialCapacity = */ MAX_CACHE_SIZE, + /* loadFactor = */ 0.75f, + /* accessOrder = */ true, + ) { + override fun removeEldestEntry( + eldest: MutableMap.MutableEntry>, + ): Boolean { + return size > MAX_CACHE_SIZE + } + } + + suspend operator fun invoke(yearMonth: YearMonth): List { + mutex.withLock { + cache[yearMonth]?.let { return it } + } + + val result = publicHolidayRepository.getHolidays(yearMonth) + + mutex.withLock { + cache[yearMonth] = result + } + + return result + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/holiday/PrefetchPublicHolidaysOfMonthUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/holiday/PrefetchPublicHolidaysOfMonthUseCase.kt new file mode 100644 index 000000000..73feaf7fd --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/holiday/PrefetchPublicHolidaysOfMonthUseCase.kt @@ -0,0 +1,12 @@ +package com.eatssu.android.domain.usecase.holiday + +import java.time.YearMonth +import javax.inject.Inject + +class PrefetchPublicHolidaysOfMonthUseCase @Inject constructor( + private val getPublicHolidaysOfMonthUseCase: GetPublicHolidaysOfMonthUseCase, +) { + suspend operator fun invoke(yearMonth: YearMonth) { + getPublicHolidaysOfMonthUseCase(yearMonth) + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/menu/LoadMenusUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/menu/LoadMenusUseCase.kt new file mode 100644 index 000000000..d6810d81c --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/menu/LoadMenusUseCase.kt @@ -0,0 +1,63 @@ +package com.eatssu.android.domain.usecase.menu + +import com.eatssu.android.domain.model.MenuLoadResult +import com.eatssu.android.domain.usecase.holiday.GetPublicHolidayOfDateUseCase +import com.eatssu.common.enums.Restaurant +import com.eatssu.common.enums.Time +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +class LoadMenusUseCase @Inject constructor( + private val getMenuListUseCase: GetMenuListUseCase, + private val getPublicHolidayOfDateUseCase: GetPublicHolidayOfDateUseCase, +) { + private val menuDateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") + + suspend operator fun invoke(date: LocalDate, time: Time): MenuLoadResult = coroutineScope { + val holiday = runCatching { getPublicHolidayOfDateUseCase(date) }.getOrNull() + val isPublicHoliday = holiday != null + + val restaurantsToLoad = buildList { + addAll(Restaurant.getVariableRestaurantList()) + + if (shouldIncludeFixedRestaurants(date = date, time = time, isPublicHoliday = isPublicHoliday)) { + add(Restaurant.FOOD_COURT) + add(Restaurant.SNACK_CORNER) + } + } + + val menuDate = date.format(menuDateFormatter) + + val deferredMenus = restaurantsToLoad.map { restaurant -> + async { + restaurant to getMenuListUseCase(restaurant, menuDate, time) + } + } + + val menuMap = deferredMenus.awaitAll().toMap() + + MenuLoadResult( + menuMap = menuMap, + ) + } + + private fun shouldIncludeFixedRestaurants( + date: LocalDate, + time: Time, + isPublicHoliday: Boolean, + ): Boolean { + if (time != Time.LUNCH) return false + + val dayOfWeek = date.dayOfWeek + val isWeekday = dayOfWeek != DayOfWeek.SATURDAY && dayOfWeek != DayOfWeek.SUNDAY + if (!isWeekday) return false + + // Core: weekday + lunch + not a public holiday + return !isPublicHoliday + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCase.kt index 94b47a1bb..adb762b0c 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/widget/GetTodayMealUseCase.kt @@ -67,4 +67,3 @@ class GetTodayMealUseCase @Inject constructor( } ) } - diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt index 9509e8595..eda4c9eb6 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt @@ -19,13 +19,10 @@ import com.eatssu.android.presentation.MainViewModel import com.eatssu.android.presentation.cafeteria.info.InfoViewModel import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.UiState -import com.eatssu.common.enums.MenuType -import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.Time import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber -import java.time.DayOfWeek import java.time.format.DateTimeFormatter import javax.inject.Inject @@ -81,26 +78,11 @@ class MenuFragment : Fragment() { fun observeViewModel() { mainViewModel.getData().observe(viewLifecycleOwner) { dataReceived -> - val menuDate = dataReceived.format(DateTimeFormatter.ofPattern("yyyyMMdd")) - val dayOfWeek = dataReceived.dayOfWeek - - // 로딩할 식당 목록 결정 - val restaurantsToLoad = buildList { - // 변동 메뉴 식당 - addAll(Restaurant.getVariableRestaurantList()) - - // 고정 메뉴 식당 (평일 점심만) - if (dayOfWeek != DayOfWeek.SATURDAY && dayOfWeek != DayOfWeek.SUNDAY && time == Time.LUNCH) { - add(Restaurant.FOOD_COURT) - add(Restaurant.SNACK_CORNER) - } - } - - Timber.d("Loading menus for date: $menuDate, time: $time, restaurants: $restaurantsToLoad") + Timber.d("Loading menus for date: $menuDate, time: $time") - // 메뉴 로딩 - menuViewModel.loadMenus(restaurantsToLoad, menuDate, time) + // 메뉴 로딩 (ViewModel이 공휴일 포함하여 식당 목록 결정) + menuViewModel.loadMenus(dataReceived, time) } } diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt index d45d25593..84c4bdb3a 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModel.kt @@ -3,45 +3,40 @@ package com.eatssu.android.presentation.cafeteria.menu import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.domain.model.Menu -import com.eatssu.android.domain.usecase.menu.GetMenuListUseCase +import com.eatssu.android.domain.usecase.menu.LoadMenusUseCase import com.eatssu.common.UiState import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.Time import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject @HiltViewModel class MenuViewModel @Inject constructor( - private val getMenuListUseCase: GetMenuListUseCase, + private val loadMenusUseCase: LoadMenusUseCase, ) : ViewModel() { private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) val uiState: StateFlow> = _uiState.asStateFlow() - // 주어진 식당 리스트에 대해 메뉴 정보를 비동기로 가져와서 UI 상태를 업데이트 - fun loadMenus(restaurants: List, menuDate: String, time: Time) { + fun loadMenus(date: LocalDate, time: Time) { _uiState.value = UiState.Loading viewModelScope.launch { - // async 함수로 Deferred를 만들어 메뉴 정보 한번에 가져오기 - val deferredMenus = restaurants.map { restaurant -> - async { - restaurant to getMenuListUseCase(restaurant, menuDate, time) - } - } - - val menuMap = deferredMenus.awaitAll().toMap() - _uiState.value = UiState.Success(MenuState(menuMap)) + val result = loadMenusUseCase(date, time) + _uiState.value = UiState.Success( + MenuState( + menuMap = result.menuMap, + ) + ) } } } data class MenuState( - val menuMap: Map> = emptyMap() -) \ No newline at end of file + val menuMap: Map> = emptyMap(), +) diff --git a/app/src/main/res/layout/fragment_menu.xml b/app/src/main/res/layout/fragment_menu.xml index 89403d699..17b1df0ed 100644 --- a/app/src/main/res/layout/fragment_menu.xml +++ b/app/src/main/res/layout/fragment_menu.xml @@ -4,8 +4,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/gray100" - android:paddingBottom="@dimen/bottom_nav_height" - android:clipToPadding="false" + android:paddingBottom="@dimen/bottom_nav_height" + android:clipToPadding="false" android:orientation="vertical"> - \ No newline at end of file + diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt index a2bec7ad8..89fe53cdf 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/menu/MenuViewModelBehaviorSpec.kt @@ -1,7 +1,8 @@ package com.eatssu.android.presentation.cafeteria.menu import com.eatssu.android.domain.model.Menu -import com.eatssu.android.domain.usecase.menu.GetMenuListUseCase +import com.eatssu.android.domain.model.MenuLoadResult +import com.eatssu.android.domain.usecase.menu.LoadMenusUseCase import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.common.UiState import com.eatssu.common.enums.Restaurant @@ -13,44 +14,63 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import java.time.LocalDate +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset @OptIn(ExperimentalCoroutinesApi::class) class MenuViewModelBehaviorSpec : AppBehaviorSpec({ given("메뉴 로드") { - val useCase = mockk() + val useCase = mockk() + val clock = Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC) + val date = LocalDate.now(clock) - `when`("식당 목록이 비어있으면") { + `when`("usecase가 빈 결과를 반환하면") { val viewModel = MenuViewModel(useCase) + coEvery { useCase(date, Time.LUNCH) } returns MenuLoadResult( + menuMap = emptyMap(), + ) then("빈 맵으로 성공 상태가 된다") { runTest { - viewModel.loadMenus(emptyList(), "20250101", Time.LUNCH) + viewModel.loadMenus(date, Time.LUNCH) advanceUntilIdle() viewModel.uiState.value shouldBe UiState.Success(MenuState(emptyMap())) - coVerify(exactly = 0) { useCase(any(), any(), any()) } + coVerify(exactly = 1) { useCase(date, Time.LUNCH) } } } } - `when`("여러 식당에 대한 조회가 성공하면") { + `when`("usecase가 식당별 메뉴 맵을 반환하면") { val viewModel = MenuViewModel(useCase) val r1 = Restaurant.FOOD_COURT val r2 = Restaurant.HAKSIK val m1 = listOf(Menu(id = 1, name = "A", price = 1000, rate = 4.0)) val m2 = listOf(Menu(id = 2, name = "B", price = 2000, rate = 3.5)) - coEvery { useCase(r1, "20250101", Time.LUNCH) } returns m1 - coEvery { useCase(r2, "20250101", Time.LUNCH) } returns m2 + coEvery { useCase(date, Time.LUNCH) } returns MenuLoadResult( + menuMap = mapOf( + r1 to m1, + r2 to m2, + ), + ) then("식당별 메뉴 맵으로 성공 상태가 된다") { runTest { - viewModel.loadMenus(listOf(r1, r2), "20250101", Time.LUNCH) + viewModel.loadMenus(date, Time.LUNCH) advanceUntilIdle() - (viewModel.uiState.value is UiState.Success) shouldBe true - coVerify(exactly = 1) { useCase(r1, "20250101", Time.LUNCH) } - coVerify(exactly = 1) { useCase(r2, "20250101", Time.LUNCH) } + viewModel.uiState.value shouldBe UiState.Success( + MenuState( + menuMap = mapOf( + r1 to m1, + r2 to m2, + ), + ), + ) + coVerify(exactly = 1) { useCase(date, Time.LUNCH) } } } } @@ -61,24 +81,27 @@ class MenuViewModelBehaviorSpec : AppBehaviorSpec({ val r2 = Restaurant.HAKSIK val m1 = emptyList() val m2 = listOf(Menu(id = 2, name = "B", price = 2000, rate = 3.5)) - coEvery { useCase(r1, "20250101", Time.DINNER) } returns m1 - coEvery { useCase(r2, "20250101", Time.DINNER) } returns m2 + coEvery { useCase(date, Time.DINNER) } returns MenuLoadResult( + menuMap = mapOf( + r1 to m1, + r2 to m2, + ), + ) then("성공 상태로 식당별 결과를 유지한다") { runTest { - viewModel.loadMenus(listOf(r1, r2), "20250101", Time.DINNER) + viewModel.loadMenus(date, Time.DINNER) advanceUntilIdle() viewModel.uiState.value shouldBe UiState.Success( MenuState( - mapOf( + menuMap = mapOf( r1 to m1, r2 to m2, - ) + ), ) ) - coVerify(exactly = 1) { useCase(r1, "20250101", Time.DINNER) } - coVerify(exactly = 1) { useCase(r2, "20250101", Time.DINNER) } + coVerify(exactly = 1) { useCase(date, Time.DINNER) } } } }