From 3f5f7ece8e9be6239dd2a96af1a0767e2ef49173 Mon Sep 17 00:00:00 2001 From: "Jihee.Han" Date: Mon, 23 Feb 2026 04:13:30 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[NDGL-104]=20feat:=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20-=20=EC=97=AC=ED=96=89=20=EB=82=A0?= =?UTF-8?q?=EC=94=A8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/ndgl/data/core/di/NetworkModule.kt | 27 ++ .../AndroidCredentialInterceptor.kt | 36 ++ .../interceptor/ApiKeyQueryInterceptor.kt | 13 + data/travel/build.gradle.kts | 1 + .../yapp/ndgl/data/travel/api/GeocodingApi.kt | 12 + .../yapp/ndgl/data/travel/api/WeatherApi.kt | 14 + .../data/travel/di/TravelNetworkModule.kt | 46 +++ .../data/travel/model/GeocodingResponse.kt | 18 + .../travel/model/UpcomingTravelResponse.kt | 3 + .../travel/model/WeatherForecastResponse.kt | 33 ++ .../travel/repository/WeatherRepository.kt | 38 ++ .../travelhelper/TravelHelperScreen.kt | 38 -- .../travelhelper/TravelHelperViewModel.kt | 8 - .../main/CurrencyCalculatorSection.kt | 131 ++++++ .../travelhelper/main/TravelHelperContract.kt | 89 +++++ .../travelhelper/main/TravelHelperScreen.kt | 273 +++++++++++++ .../main/TravelHelperViewModel.kt | 212 ++++++++++ .../main/UpcomingTravelCardSection.kt | 375 ++++++++++++++++++ .../travelhelper/main/WeatherSection.kt | 225 +++++++++++ .../navigation/TravelHelperEntry.kt | 6 +- .../feature/travelhelper/util/ResourceUtil.kt | 22 + .../src/main/res/values/strings.xml | 25 ++ 22 files changed, 1597 insertions(+), 48 deletions(-) create mode 100644 data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt create mode 100644 data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/ApiKeyQueryInterceptor.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/api/GeocodingApi.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/api/WeatherApi.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GeocodingResponse.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/WeatherForecastResponse.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/WeatherRepository.kt delete mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt delete mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperViewModel.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/WeatherSection.kt create mode 100644 feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/util/ResourceUtil.kt create mode 100644 feature/travel-helper/src/main/res/values/strings.xml diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt index ee1fa745..af6951e7 100644 --- a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt @@ -4,7 +4,9 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import com.yapp.ndgl.data.core.BuildConfig import com.yapp.ndgl.data.core.adapter.NDGLCallAdapterFactory import com.yapp.ndgl.data.core.authenticator.NDGLAuthenticator +import com.yapp.ndgl.data.core.interceptor.AndroidCredentialInterceptor import com.yapp.ndgl.data.core.interceptor.ApiKeyInterceptor +import com.yapp.ndgl.data.core.interceptor.ApiKeyQueryInterceptor import com.yapp.ndgl.data.core.interceptor.NDGLInterceptor import com.yapp.ndgl.data.core.interceptor.RouteInterceptor import dagger.Module @@ -121,6 +123,19 @@ object NetworkModule { .addInterceptor(httpLoggingInterceptor) .build() } + + @WeatherClient + @Singleton + @Provides + fun provideWeatherOkHttpClient( + @WeatherApiKey apiKey: String, + androidCredentialInterceptor: AndroidCredentialInterceptor, + httpLoggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(ApiKeyQueryInterceptor(apiKey)) + .addInterceptor(androidCredentialInterceptor) + .addInterceptor(httpLoggingInterceptor) + .build() } @Qualifier @@ -146,3 +161,15 @@ annotation class RouteBaseUrl @Qualifier @Retention(AnnotationRetention.BINARY) annotation class RouteClient + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class WeatherApiKey + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class WeatherClient + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GeocodingClient diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt new file mode 100644 index 00000000..706ff560 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt @@ -0,0 +1,36 @@ +package com.yapp.ndgl.data.core.interceptor + +import android.content.Context +import android.content.pm.PackageManager +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.Interceptor +import okhttp3.Response +import java.security.MessageDigest +import javax.inject.Inject + +@Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") +class AndroidCredentialInterceptor @Inject constructor( + @ApplicationContext private val context: Context, +) : Interceptor { + private val packageName: String = context.packageName + private val sha1Cert: String = computeSha1(context) + + override fun intercept(chain: Interceptor.Chain): Response { + val newRequest = chain.request().newBuilder() + .addHeader("X-Android-Package", packageName) + .addHeader("X-Android-Cert", sha1Cert) + .build() + return chain.proceed(newRequest) + } + + companion object { + private fun computeSha1(context: Context): String = try { + val signatures = context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES) + .signingInfo?.apkContentsSigners + MessageDigest.getInstance("SHA-1") + .digest(signatures?.getOrNull(0)?.toByteArray()) + .joinToString("") { "%02x".format(it) } + } catch (e: Exception) { "" } + } +} diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/ApiKeyQueryInterceptor.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/ApiKeyQueryInterceptor.kt new file mode 100644 index 00000000..3fca8267 --- /dev/null +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/ApiKeyQueryInterceptor.kt @@ -0,0 +1,13 @@ +package com.yapp.ndgl.data.core.interceptor + +import okhttp3.Interceptor +import okhttp3.Response + +class ApiKeyQueryInterceptor(private val apiKey: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val newUrl = chain.request().url.newBuilder() + .addQueryParameter("key", apiKey) + .build() + return chain.proceed(chain.request().newBuilder().url(newUrl).build()) + } +} diff --git a/data/travel/build.gradle.kts b/data/travel/build.gradle.kts index d3b6e9e6..89ac9dce 100644 --- a/data/travel/build.gradle.kts +++ b/data/travel/build.gradle.kts @@ -13,6 +13,7 @@ android { } buildConfigField("String", "PLACE_API_KEY", "\"${localProperties.getProperty("PLACE_API_KEY", "")}\"") buildConfigField("String", "ROUTE_API_KEY", "\"${localProperties.getProperty("ROUTE_API_KEY", "")}\"") + buildConfigField("String", "WEATHER_API_KEY", "\"${localProperties.getProperty("WEATHER_API_KEY", "")}\"") } buildFeatures { diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/GeocodingApi.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/GeocodingApi.kt new file mode 100644 index 00000000..b82ff0c3 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/GeocodingApi.kt @@ -0,0 +1,12 @@ +package com.yapp.ndgl.data.travel.api + +import com.yapp.ndgl.data.travel.model.GeocodingResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface GeocodingApi { + @GET("maps/api/geocode/json") + suspend fun geocode( + @Query("address") address: String, + ): GeocodingResponse +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/WeatherApi.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/WeatherApi.kt new file mode 100644 index 00000000..2ed4bfc8 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/WeatherApi.kt @@ -0,0 +1,14 @@ +package com.yapp.ndgl.data.travel.api + +import com.yapp.ndgl.data.travel.model.WeatherForecastResponse +import retrofit2.http.GET +import retrofit2.http.Query + +interface WeatherApi { + @GET("v1/forecast/days:lookup") + suspend fun getDailyForecast( + @Query("location.latitude") latitude: Double, + @Query("location.longitude") longitude: Double, + @Query("days") days: Int, + ): WeatherForecastResponse +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt index 376eb587..07ba7554 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt @@ -4,15 +4,20 @@ import android.content.Context import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.net.PlacesClient import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.yapp.ndgl.data.core.di.GeocodingClient import com.yapp.ndgl.data.core.di.RouteApiKey import com.yapp.ndgl.data.core.di.RouteBaseUrl import com.yapp.ndgl.data.core.di.RouteClient +import com.yapp.ndgl.data.core.di.WeatherApiKey +import com.yapp.ndgl.data.core.di.WeatherClient import com.yapp.ndgl.data.travel.BuildConfig +import com.yapp.ndgl.data.travel.api.GeocodingApi import com.yapp.ndgl.data.travel.api.PlaceApi import com.yapp.ndgl.data.travel.api.RouteApi import com.yapp.ndgl.data.travel.api.TravelProgramApi import com.yapp.ndgl.data.travel.api.TravelTemplateApi import com.yapp.ndgl.data.travel.api.UserTravelApi +import com.yapp.ndgl.data.travel.api.WeatherApi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -28,6 +33,8 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object TravelNetworkModule { private const val ROUTES_BASE_URL = "https://routes.googleapis.com/" + private const val WEATHER_BASE_URL = "https://weather.googleapis.com/" + private const val GEOCODING_BASE_URL = "https://maps.googleapis.com/" @Provides @Singleton @@ -45,6 +52,45 @@ object TravelNetworkModule { @Singleton fun provideRouteApiKey(): String = BuildConfig.ROUTE_API_KEY + @WeatherApiKey + @Provides + @Singleton + fun provideWeatherApiKey(): String = BuildConfig.WEATHER_API_KEY + + @WeatherClient + @Provides + @Singleton + fun provideWeatherRetrofit( + @WeatherClient okHttpClient: OkHttpClient, + json: Json, + ): Retrofit = Retrofit.Builder() + .baseUrl(WEATHER_BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideWeatherApi(@WeatherClient retrofit: Retrofit): WeatherApi = + retrofit.create(WeatherApi::class.java) + + @GeocodingClient + @Provides + @Singleton + fun provideGeocodingRetrofit( + @WeatherClient okHttpClient: OkHttpClient, + json: Json, + ): Retrofit = Retrofit.Builder() + .baseUrl(GEOCODING_BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideGeocodingApi(@GeocodingClient retrofit: Retrofit): GeocodingApi = + retrofit.create(GeocodingApi::class.java) + @RouteBaseUrl @Provides @Singleton diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GeocodingResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GeocodingResponse.kt new file mode 100644 index 00000000..137ae668 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/GeocodingResponse.kt @@ -0,0 +1,18 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.Serializable + +@Serializable +data class GeocodingResponse( + val results: List = emptyList(), + val status: String = "", +) { + @Serializable + data class Result(val geometry: Geometry) + + @Serializable + data class Geometry(val location: Location) + + @Serializable + data class Location(val lat: Double, val lng: Double) +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelResponse.kt index ba776ecb..31317918 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelResponse.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelResponse.kt @@ -1,3 +1,5 @@ +@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) + package com.yapp.ndgl.data.travel.model import com.yapp.ndgl.data.core.serializer.LocalDateSerializer @@ -16,5 +18,6 @@ data class UpcomingTravelResponse( val endDate: LocalDate, val nights: Int, val days: Int, + val thumbnail: String?, val upcomingUserTravelPlace: UserTravelPlace? = null, ) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/WeatherForecastResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/WeatherForecastResponse.kt new file mode 100644 index 00000000..c229b2d1 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/WeatherForecastResponse.kt @@ -0,0 +1,33 @@ +@file:OptIn(kotlinx.serialization.InternalSerializationApi::class) + +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.Serializable + +@Serializable +data class WeatherForecastResponse( + val forecastDays: List = emptyList(), +) { + @Serializable + data class ForecastDay( + val displayDate: DisplayDate, + val daytimeForecast: DaytimeForecast? = null, + val maxTemperature: Temperature? = null, + val minTemperature: Temperature? = null, + ) + + @Serializable + data class DisplayDate(val year: Int, val month: Int, val day: Int) + + @Serializable + data class DaytimeForecast(val weatherCondition: WeatherCondition? = null) + + @Serializable + data class WeatherCondition( + val type: String? = null, + val iconBaseUri: String? = null, + ) + + @Serializable + data class Temperature(val degrees: Double, val unit: String = "CELSIUS") +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/WeatherRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/WeatherRepository.kt new file mode 100644 index 00000000..fdaac25b --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/WeatherRepository.kt @@ -0,0 +1,38 @@ +package com.yapp.ndgl.data.travel.repository + +import com.yapp.ndgl.data.travel.api.GeocodingApi +import com.yapp.ndgl.data.travel.api.WeatherApi +import com.yapp.ndgl.data.travel.model.WeatherForecastResponse +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class WeatherRepository @Inject constructor( + private val geocodingApi: GeocodingApi, + private val weatherApi: WeatherApi, +) { + suspend fun getWeatherForecast( + city: String, + country: String, + startDate: LocalDate, + endDate: LocalDate, + ): WeatherForecastResponse? { + val address = "$city,$country" + val location = geocodingApi.geocode(address).results.firstOrNull()?.geometry?.location + ?: return null + + val today = LocalDate.now() + val days = maxOf(ChronoUnit.DAYS.between(today, endDate).toInt() + 2, 1) + + val response = weatherApi.getDailyForecast(location.lat, location.lng, days) + + val filterStart = if (today.isAfter(startDate)) today else startDate + val filtered = response.forecastDays.filter { day -> + val date = LocalDate.of(day.displayDate.year, day.displayDate.month, day.displayDate.day) + !date.isBefore(filterStart) && !date.isAfter(endDate) + } + return response.copy(forecastDays = filtered) + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt deleted file mode 100644 index 74aa91bb..00000000 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperScreen.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.yapp.ndgl.feature.travelhelper - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel - -@Composable -internal fun TravelHelperRoute( - viewModel: TravelHelperViewModel = hiltViewModel(), -) { - TravelHelperScreen() -} - -@Composable -private fun TravelHelperScreen() { - LazyColumn( - modifier = Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - item { - Text(text = "Travel Helper Screen") - } - } -} - -@Preview(showBackground = true) -@Composable -private fun TravelHelperScreenPreview() { - TravelHelperScreen() -} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperViewModel.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperViewModel.kt deleted file mode 100644 index 9c89c5be..00000000 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/TravelHelperViewModel.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.yapp.ndgl.feature.travelhelper - -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class TravelHelperViewModel @Inject constructor() : ViewModel() diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt new file mode 100644 index 00000000..71b28275 --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt @@ -0,0 +1,131 @@ +package com.yapp.ndgl.feature.travelhelper.main + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.feature.travelhelper.R +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.ExchangeRateInfo + +@Composable +internal fun CurrencyCalculatorSection( + exchangeRateInfo: ExchangeRateInfo, + currencyInput: String, + convertedAmount: Double?, + onInputChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.travel_helper_currency_calculator_title), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black900, + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(NDGLTheme.colors.black50) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${exchangeRateInfo.foreignCurrencyCode} (${exchangeRateInfo.foreignCurrencyName})", + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black600, + ) + BasicTextField( + value = currencyInput, + onValueChange = onInputChange, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + textStyle = NDGLTheme.typography.subtitleMdSemiBold.copy( + color = NDGLTheme.colors.black900, + textAlign = TextAlign.End, + ), + cursorBrush = SolidColor(NDGLTheme.colors.green500), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterEnd) { + if (currencyInput.isEmpty()) { + Text( + text = "0", + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black300, + textAlign = TextAlign.End, + ) + } + innerTextField() + } + }, + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(NDGLTheme.colors.black200), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.travel_helper_currency_krw), + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black600, + ) + Text( + text = convertedAmount?.let { + String.format("%,.0f", it) + } ?: "-", + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black900, + ) + } + + Text( + text = stringResource( + R.string.travel_helper_currency_rate_info, + exchangeRateInfo.foreignCurrencyCode, + String.format("%,.2f", exchangeRateInfo.rateToKrw), + ), + style = NDGLTheme.typography.bodySmRegular, + color = NDGLTheme.colors.black400, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.End, + ) + } + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt new file mode 100644 index 00000000..eda83677 --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt @@ -0,0 +1,89 @@ +package com.yapp.ndgl.feature.travelhelper.main + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import com.yapp.ndgl.core.base.UiIntent +import com.yapp.ndgl.core.base.UiSideEffect +import com.yapp.ndgl.core.base.UiState +import com.yapp.ndgl.data.travel.model.PlaceCategory +import kotlinx.collections.immutable.ImmutableList +import java.time.LocalDate + +@Stable +data class TravelHelperState( + val travelUiState: TravelUiState = TravelUiState.Loading, + val currencyInput: String = "1", + val convertedAmount: Double? = null, +) : UiState { + sealed interface TravelUiState { + data object Loading : TravelUiState + + data object NoTravel : TravelUiState + + data class UpcomingTravel( + val id: Long, + val title: String, + val city: String, + val startDate: LocalDate, + val endDate: LocalDate, + val thumbnail: String?, + val dDay: Long, + val weatherState: WeatherUiState, + val exchangeRateInfo: ExchangeRateInfo, + ) : TravelUiState + + data class OngoingTravel( + val id: Long, + val title: String, + val city: String, + val startDate: LocalDate, + val endDate: LocalDate, + val thumbnail: String?, + val dayCount: Long, + val weatherState: WeatherUiState, + val currentPlace: TravelPlace?, + val exchangeRateInfo: ExchangeRateInfo, + ) : TravelUiState + } + + @Stable + sealed interface WeatherUiState { + data object NotAvailable : WeatherUiState + data class Available( + val forecasts: ImmutableList, + ) : WeatherUiState + } + + @Immutable + data class WeatherForecastUiInfo( + val date: LocalDate, + val dayOfWeek: String, + val iconUrl: String?, + val highTempCelsius: Int, + val lowTempCelsius: Int, + ) + + data class TravelPlace( + val name: String, + val category: PlaceCategory, + val estimatedDuration: Int, + val thumbnailUrl: String?, + ) + + data class ExchangeRateInfo( + val foreignCurrencyCode: String, + val foreignCurrencyName: String, + val rateToKrw: Double, + val rateDate: String, + ) +} + +sealed interface TravelHelperIntent : UiIntent { + data object ClickSearch : TravelHelperIntent + data class UpdateCurrencyInput(val input: String) : TravelHelperIntent + data object SwapCurrency : TravelHelperIntent +} + +sealed interface TravelHelperSideEffect : UiSideEffect { + data object NavigateToSearch : TravelHelperSideEffect +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt new file mode 100644 index 00000000..9c2e995b --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt @@ -0,0 +1,273 @@ +package com.yapp.ndgl.feature.travelhelper.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar +import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr +import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationIcon +import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.feature.travelhelper.R +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelUiState +import com.yapp.ndgl.core.ui.R as CoreR + +@Composable +internal fun TravelHelperRoute( + navigateToSearch: () -> Unit, + viewModel: TravelHelperViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + + TravelHelperScreen( + state = state, + onSearchClick = { viewModel.onIntent(TravelHelperIntent.ClickSearch) }, + onNewTravelFindClick = {}, + onTravelClick = {}, + onPlaceClick = {}, + onCurrencyInputChange = { viewModel.onIntent(TravelHelperIntent.UpdateCurrencyInput(it)) }, + ) + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + TravelHelperSideEffect.NavigateToSearch -> navigateToSearch() + } + } +} + +@Composable +private fun TravelHelperScreen( + state: TravelHelperState, + onSearchClick: () -> Unit, + onNewTravelFindClick: () -> Unit, + onTravelClick: (Long) -> Unit, + onPlaceClick: (Long) -> Unit, + onCurrencyInputChange: (String) -> Unit, +) { + Scaffold( + topBar = { + NDGLNavigationBar( + textAlignType = NDGLNavigationBarAttr.TextAlignType.START, + modifier = Modifier + .fillMaxWidth() + .background(color = NDGLTheme.colors.white) + .statusBarsPadding(), + trailingContents = { + NDGLNavigationIcon( + icon = CoreR.drawable.ic_28_search, + onClick = onSearchClick, + ) + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(top = 20.dp, bottom = 100.dp), + verticalArrangement = Arrangement.spacedBy(40.dp), + ) { + when (val travelUiState = state.travelUiState) { + TravelUiState.Loading -> { + item { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = NDGLTheme.colors.green500) + } + } + } + + TravelUiState.NoTravel -> noTravelContent( + onSearchClick = onSearchClick, + onNewTravelFindClick = onNewTravelFindClick, + ) + + is TravelUiState.UpcomingTravel -> upcomingTravelContent( + travel = travelUiState, + currencyInput = state.currencyInput, + convertedAmount = state.convertedAmount, + onTravelClick = onTravelClick, + onCurrencyInputChange = onCurrencyInputChange, + ) + + is TravelUiState.OngoingTravel -> inProgressTravelContent( + travel = travelUiState, + currencyInput = state.currencyInput, + convertedAmount = state.convertedAmount, + onTravelClick = onTravelClick, + onPlaceClick = onPlaceClick, + onCurrencyInputChange = onCurrencyInputChange, + ) + } + } + } +} + +private fun LazyListScope.noTravelContent( + onSearchClick: () -> Unit, + onNewTravelFindClick: () -> Unit, +) { + item { + EmptyTravelCard( + modifier = Modifier, + onCardClick = onSearchClick, + ) + } + item { + EmptyTravel( + onNewTravelFindClick = onNewTravelFindClick, + ) + } +} + +@Composable +private fun EmptyTravel( + onNewTravelFindClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(CoreR.drawable.img_empty_suitcase), + contentDescription = null, + modifier = Modifier.size(100.dp), + ) + Text( + text = stringResource(R.string.travel_helper_empty_travel_title), + modifier = Modifier.padding(top = 16.dp), + color = NDGLTheme.colors.black500, + style = NDGLTheme.typography.subtitleMdSemiBold, + ) + Text( + text = stringResource(R.string.travel_helper_empty_travel_description), + modifier = Modifier.padding(top = 4.dp), + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodyLgRegular, + ) + FindNewTravelCtaButton( + modifier = Modifier.padding(top = 12.dp), + onClick = onNewTravelFindClick, + ) + } +} + +@Composable +private fun FindNewTravelCtaButton( + modifier: Modifier, + onClick: () -> Unit, +) { + Row( + modifier = modifier + .wrapContentSize() + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .background(color = NDGLTheme.colors.black200) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.travel_helper_find_new_travel), + color = NDGLTheme.colors.black800, + style = NDGLTheme.typography.bodyMdSemiBold, + ) + Icon( + imageVector = ImageVector.vectorResource(CoreR.drawable.ic_20_search), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = NDGLTheme.colors.black600, + ) + } +} + +private fun LazyListScope.upcomingTravelContent( + travel: TravelUiState.UpcomingTravel, + currencyInput: String, + convertedAmount: Double?, + onTravelClick: (Long) -> Unit, + onCurrencyInputChange: (String) -> Unit, +) { + item { + UpcomingTravelCard( + modifier = Modifier, + travel = travel, + onCardClick = { onTravelClick(travel.id) }, + ) + } + item { + WeatherSection(weatherState = travel.weatherState) + } + item { + CurrencyCalculatorSection( + exchangeRateInfo = travel.exchangeRateInfo, + currencyInput = currencyInput, + convertedAmount = convertedAmount, + onInputChange = onCurrencyInputChange, + ) + } +} + +private fun LazyListScope.inProgressTravelContent( + travel: TravelUiState.OngoingTravel, + currencyInput: String, + convertedAmount: Double?, + onTravelClick: (Long) -> Unit, + onPlaceClick: (Long) -> Unit, + onCurrencyInputChange: (String) -> Unit, +) { + item { + InProgressTravelCard( + travel = travel, + onCardClick = { onTravelClick(travel.id) }, + onPlaceClick = { onPlaceClick(travel.id) }, + modifier = Modifier, + ) + } + item { + WeatherSection(weatherState = travel.weatherState) + } + item { + CurrencyCalculatorSection( + exchangeRateInfo = travel.exchangeRateInfo, + currencyInput = currencyInput, + convertedAmount = convertedAmount, + onInputChange = onCurrencyInputChange, + ) + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt new file mode 100644 index 00000000..08739a67 --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt @@ -0,0 +1,212 @@ +package com.yapp.ndgl.feature.travelhelper.main + +import androidx.lifecycle.viewModelScope +import com.yapp.ndgl.core.base.BaseViewModel +import com.yapp.ndgl.core.util.suspendRunCatching +import com.yapp.ndgl.data.travel.model.WeatherForecastResponse +import com.yapp.ndgl.data.travel.repository.UserTravelRepository +import com.yapp.ndgl.data.travel.repository.WeatherRepository +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.ExchangeRateInfo +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelPlace +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelUiState +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.WeatherForecastUiInfo +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.WeatherUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber +import java.time.LocalDate +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.util.Locale +import javax.inject.Inject +import kotlin.math.roundToInt + +@HiltViewModel +class TravelHelperViewModel @Inject constructor( + private val userTravelRepository: UserTravelRepository, + private val weatherRepository: WeatherRepository, +) : BaseViewModel( + initialState = TravelHelperState(), +) { + init { + loadUpcomingTravel() + } + + private fun loadUpcomingTravel() { + viewModelScope.launch { + suspendRunCatching { userTravelRepository.getUpcomingTravel() } + .onSuccess { travel -> + if (travel == null) { + reduce { copy(travelUiState = TravelUiState.NoTravel) } + return@onSuccess + } + + val today = LocalDate.now() + val (currencyCode, currencyDisplayName) = countryToCurrency[travel.country] + ?: ("USD" to "달러") + val rate = krwRates[currencyCode] ?: 1340.0 + val exchangeRateInfo = ExchangeRateInfo( + foreignCurrencyCode = currencyCode, + foreignCurrencyName = currencyDisplayName, + rateToKrw = rate, + rateDate = "2025-01-01", + ) + + val travelUiState = when { + today < travel.startDate -> { + val dDay = ChronoUnit.DAYS.between(today, travel.startDate) + TravelUiState.UpcomingTravel( + id = travel.userTravelId, + title = travel.title, + city = travel.city, + startDate = travel.startDate, + endDate = travel.endDate, + thumbnail = travel.thumbnail, + dDay = dDay, + weatherState = WeatherUiState.NotAvailable, + exchangeRateInfo = exchangeRateInfo, + ) + } + + today <= travel.endDate -> { + val dayNumber = ChronoUnit.DAYS.between(travel.startDate, today) + 1 + val place = travel.upcomingUserTravelPlace + TravelUiState.OngoingTravel( + id = travel.userTravelId, + title = travel.title, + city = travel.city, + startDate = travel.startDate, + endDate = travel.endDate, + thumbnail = travel.thumbnail, + dayCount = dayNumber, + weatherState = WeatherUiState.NotAvailable, + currentPlace = place?.place?.let { + TravelPlace( + name = it.name, + category = it.category, + estimatedDuration = place.estimatedDuration, + thumbnailUrl = it.thumbnail, + ) + }, + exchangeRateInfo = exchangeRateInfo, + ) + } + + else -> TravelUiState.NoTravel + } + + reduce { + copy( + travelUiState = travelUiState, + convertedAmount = calculateConvertedAmount(currencyInput, rate), + ) + } + + loadWeather(travel.city, travel.country, travel.startDate, travel.endDate) + } + .onFailure { + Timber.e("Failed to load upcoming travel: $it") + reduce { copy(travelUiState = TravelUiState.NoTravel) } + } + } + } + + private fun loadWeather( + city: String, + country: String, + startDate: LocalDate, + endDate: LocalDate, + ) { + viewModelScope.launch { + suspendRunCatching { + weatherRepository.getWeatherForecast( + city, + country, + startDate, + endDate, + ) + } + .onSuccess { response -> + if (response == null) return@onSuccess + val forecasts = response.forecastDays + .map { it.toWeatherForecastUiInfo() } + .toImmutableList() + val weatherState = WeatherUiState.Available(forecasts) + reduce { + copy( + travelUiState = when (val ts = travelUiState) { + is TravelUiState.UpcomingTravel -> ts.copy(weatherState = weatherState) + is TravelUiState.OngoingTravel -> ts.copy(weatherState = weatherState) + else -> ts + }, + ) + } + } + .onFailure { + Timber.e("Failed to load weather: $it") + } + } + } + + override suspend fun handleIntent(intent: TravelHelperIntent) { + when (intent) { + TravelHelperIntent.ClickSearch -> postSideEffect(TravelHelperSideEffect.NavigateToSearch) + is TravelHelperIntent.UpdateCurrencyInput -> { + val rate = when (val ts = state.value.travelUiState) { + is TravelUiState.UpcomingTravel -> ts.exchangeRateInfo.rateToKrw + is TravelUiState.OngoingTravel -> ts.exchangeRateInfo.rateToKrw + else -> 1.0 + } + val converted = calculateConvertedAmount(intent.input, rate) + reduce { copy(currencyInput = intent.input, convertedAmount = converted) } + } + + TravelHelperIntent.SwapCurrency -> Unit + } + } + + private fun calculateConvertedAmount(input: String, rateToKrw: Double): Double? { + val amount = input.toDoubleOrNull() ?: return null + return amount * rateToKrw + } + + private fun WeatherForecastResponse.ForecastDay.toWeatherForecastUiInfo(): WeatherForecastUiInfo { + val date = LocalDate.of(displayDate.year, displayDate.month, displayDate.day) + return WeatherForecastUiInfo( + date = date, + dayOfWeek = date.dayOfWeek.getDisplayName(TextStyle.FULL, Locale.KOREAN), + iconUrl = daytimeForecast?.weatherCondition?.iconBaseUri?.let { url -> "$url.png" }, + highTempCelsius = maxTemperature?.degrees?.roundToInt() ?: 0, + lowTempCelsius = minTemperature?.degrees?.roundToInt() ?: 0, + ) + } + + companion object { + private val countryToCurrency = mapOf( + "IT" to ("EUR" to "유로"), + "FR" to ("EUR" to "유로"), + "DE" to ("EUR" to "유로"), + "ES" to ("EUR" to "유로"), + "JP" to ("JPY" to "엔"), + "US" to ("USD" to "달러"), + "GB" to ("GBP" to "파운드"), + "TH" to ("THB" to "밧"), + "VN" to ("VND" to "동"), + "SG" to ("SGD" to "달러"), + "AU" to ("AUD" to "달러"), + "CN" to ("CNY" to "위안"), + ) + private val krwRates = mapOf( + "EUR" to 1460.0, + "JPY" to 9.5, + "USD" to 1340.0, + "GBP" to 1700.0, + "THB" to 38.0, + "VND" to 0.054, + "SGD" to 1000.0, + "AUD" to 870.0, + "CNY" to 185.0, + ) + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt new file mode 100644 index 00000000..cd50b638 --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt @@ -0,0 +1,375 @@ +package com.yapp.ndgl.feature.travelhelper.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.core.util.formatString +import com.yapp.ndgl.data.travel.model.PlaceCategory +import com.yapp.ndgl.feature.travelhelper.R +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelPlace +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelUiState +import com.yapp.ndgl.feature.travelhelper.util.toDisplayNameRes +import com.yapp.ndgl.feature.travelhelper.util.toDrawableRes +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import kotlin.time.Duration.Companion.minutes +import com.yapp.ndgl.core.ui.R as CoreR + +@Composable +internal fun EmptyTravelCard( + modifier: Modifier, + onCardClick: () -> Unit, +) { + CardContainer( + modifier = modifier, + onCardClick = onCardClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(R.string.travel_helper_card_empty_title), + style = NDGLTheme.typography.bodyLgSemiBold, + color = NDGLTheme.colors.black700, + ) + Text( + text = stringResource(R.string.travel_helper_card_empty_description), + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black400, + ) + } + Image( + painter = painterResource(CoreR.drawable.img_empty_calendar), + contentDescription = null, + modifier = Modifier.size(76.dp), + ) + } + } +} + +@Composable +internal fun UpcomingTravelCard( + modifier: Modifier, + travel: TravelUiState.UpcomingTravel, + onCardClick: () -> Unit, +) { + CardContainer( + modifier = modifier, + onCardClick = onCardClick, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = travel.thumbnail, + contentDescription = travel.title, + modifier = Modifier + .size(64.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DayTag(dDay = travel.dDay) + Text( + text = travel.title, + color = NDGLTheme.colors.black700, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = NDGLTheme.typography.subtitleMdSemiBold, + ) + } + val dateFormatter = DateTimeFormatter.ofPattern( + stringResource(R.string.travel_helper_card_date_format), + ) + Text( + text = stringResource( + R.string.travel_helper_card_travel_duration, + travel.startDate.format(dateFormatter), + travel.endDate.format(dateFormatter), + ), + style = NDGLTheme.typography.bodyMdRegular, + color = NDGLTheme.colors.black600, + ) + } + } + } +} + +@Composable +private fun DayTag( + dDay: Long, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .background( + color = NDGLTheme.colors.black100, + shape = RoundedCornerShape(999.dp), + ) + .padding(horizontal = 12.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (dDay <= 0) { + stringResource(R.string.travel_helper_card_d_day_minus, dDay) + } else { + stringResource(R.string.travel_helper_card_d_day_plus, dDay) + }, + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black400, + ) + } +} + +@Composable +internal fun InProgressTravelCard( + travel: TravelUiState.OngoingTravel, + onCardClick: () -> Unit, + onPlaceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + CardContainer( + modifier = modifier, + onCardClick = onCardClick, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = travel.title, + modifier = Modifier.weight(1f, fill = false), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(R.string.travel_helper_card_in_progress_day_count, travel.dayCount), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black700, + maxLines = 1, + ) + } + val dateFormatter = DateTimeFormatter.ofPattern( + stringResource(R.string.travel_helper_card_date_format), + ) + Text( + text = stringResource( + R.string.travel_helper_card_travel_duration, + travel.startDate.format(dateFormatter), + travel.endDate.format(dateFormatter), + ), + style = NDGLTheme.typography.bodyMdRegular, + color = NDGLTheme.colors.black500, + ) + } + + if (travel.currentPlace != null) { + PlaceInfoCard( + place = travel.currentPlace, + onPlaceClick = onPlaceClick, + ) + } + } + } +} + +@Composable +private fun PlaceInfoCard( + place: TravelPlace, + onPlaceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(NDGLTheme.colors.white) + .clickable(onClick = onPlaceClick) + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = ImageVector.vectorResource(place.category.toDrawableRes()), + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = NDGLTheme.colors.black400, + ) + Text( + text = stringResource(place.category.toDisplayNameRes()), + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodySmMedium, + ) + Text( + text = stringResource(CoreR.string.common_dot_separator), + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodyMdMedium, + ) + Text( + text = stringResource( + CoreR.string.estimated_duration_format, + place.estimatedDuration.minutes.formatString(), + ), + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodySmMedium, + ) + } + Text( + text = place.name, + color = NDGLTheme.colors.black900, + style = NDGLTheme.typography.bodyLgSemiBold, + ) + } + + AsyncImage( + model = place.thumbnailUrl, + contentDescription = place.name, + modifier = Modifier + .size(56.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop, + ) + } +} + +@Composable +private fun CardContainer( + modifier: Modifier, + onCardClick: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .padding(horizontal = 24.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(NDGLTheme.colors.black50) + .clickable(onClick = onCardClick), + content = content, + ) +} + +@Preview(showBackground = true) +@Composable +private fun EmptyTravelCardPreview() { + NDGLTheme { + EmptyTravelCard( + modifier = Modifier, + onCardClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun UpcomingTravelCardPreview() { + NDGLTheme { + UpcomingTravelCard( + modifier = Modifier, + travel = TravelUiState.UpcomingTravel( + id = 1L, + title = "도쿄 여행", + city = "도쿄", + startDate = LocalDate.of(2025, 2, 15), + endDate = LocalDate.of(2025, 2, 20), + thumbnail = null, + dDay = 7L, + weatherState = TravelHelperState.WeatherUiState.NotAvailable, + exchangeRateInfo = TravelHelperState.ExchangeRateInfo("JPY", "엔", 9.5, "2025-01-01"), + ), + onCardClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun InProgressTravelCardPreview() { + NDGLTheme { + InProgressTravelCard( + travel = TravelUiState.OngoingTravel( + id = 1L, + title = "인도 여행", + city = "뭄바이", + startDate = LocalDate.of(2025, 2, 1), + endDate = LocalDate.of(2025, 2, 10), + thumbnail = null, + dayCount = 3L, + weatherState = TravelHelperState.WeatherUiState.NotAvailable, + currentPlace = TravelPlace( + name = "인도 국제 공항", + category = PlaceCategory.TRANSPORT, + estimatedDuration = 60, + thumbnailUrl = null, + ), + exchangeRateInfo = TravelHelperState.ExchangeRateInfo("USD", "달러", 1340.0, "2025-01-01"), + ), + onCardClick = {}, + onPlaceClick = {}, + ) + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/WeatherSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/WeatherSection.kt new file mode 100644 index 00000000..38c96b32 --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/WeatherSection.kt @@ -0,0 +1,225 @@ +package com.yapp.ndgl.feature.travelhelper.main + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.yapp.ndgl.core.ui.theme.NDGLTheme +import com.yapp.ndgl.feature.travelhelper.R +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.WeatherForecastUiInfo +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.WeatherUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import com.yapp.ndgl.core.ui.R as CoreR + +@Composable +internal fun WeatherSection( + weatherState: WeatherUiState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = stringResource(R.string.travel_helper_weather_section_title), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + style = NDGLTheme.typography.subtitleLgSemiBold, + color = NDGLTheme.colors.black700, + ) + when (weatherState) { + WeatherUiState.NotAvailable -> WeatherEmptyState() + is WeatherUiState.Available -> WeatherCardRow(forecasts = weatherState.forecasts) + } + } +} + +@Composable +private fun WeatherEmptyState( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(CoreR.drawable.img_empty_suitcase), + contentDescription = null, + modifier = Modifier.size(100.dp), + ) + Text( + text = stringResource(R.string.travel_helper_weather_not_available), + modifier = Modifier.fillMaxWidth(), + color = NDGLTheme.colors.black400, + textAlign = TextAlign.Center, + style = NDGLTheme.typography.bodyMdRegular, + ) + } +} + +@Composable +private fun WeatherCardRow( + forecasts: ImmutableList, + modifier: Modifier = Modifier, +) { + LazyRow( + modifier = modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + items( + items = forecasts, + key = { it.date }, + ) { forecast -> + WeatherCard(forecast = forecast) + } + } +} + +@Composable +private fun WeatherCard( + forecast: WeatherForecastUiInfo, + modifier: Modifier = Modifier, +) { + val dateFormatter = remember { DateTimeFormatter.ofPattern("MM.dd") } + + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(NDGLTheme.colors.white) + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = forecast.date.format(dateFormatter), + style = NDGLTheme.typography.bodyLgSemiBold, + color = NDGLTheme.colors.black700, + ) + Text( + text = forecast.dayOfWeek, + style = NDGLTheme.typography.bodySmMedium, + color = NDGLTheme.colors.black400, + ) + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AsyncImage( + model = forecast.iconUrl, + contentDescription = null, + modifier = Modifier + .padding(horizontal = 12.dp) + .size(56.dp), + contentScale = ContentScale.Fit, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${forecast.highTempCelsius}° /", + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black500, + ) + Text( + text = "${forecast.lowTempCelsius}°", + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black400, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WeatherSectionNotAvailablePreview() { + NDGLTheme { + WeatherSection( + weatherState = WeatherUiState.NotAvailable, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun WeatherSectionAvailablePreview() { + NDGLTheme { + WeatherSection( + weatherState = WeatherUiState.Available( + forecasts = persistentListOf( + WeatherForecastUiInfo( + date = LocalDate.of(2025, 3, 1), + dayOfWeek = "토요일", + iconUrl = null, + highTempCelsius = 18, + lowTempCelsius = 10, + ), + WeatherForecastUiInfo( + date = LocalDate.of(2025, 3, 2), + dayOfWeek = "일요일", + iconUrl = null, + highTempCelsius = 20, + lowTempCelsius = 12, + ), + WeatherForecastUiInfo( + date = LocalDate.of(2025, 3, 3), + dayOfWeek = "월요일", + iconUrl = null, + highTempCelsius = 15, + lowTempCelsius = 8, + ), + WeatherForecastUiInfo( + date = LocalDate.of(2025, 3, 4), + dayOfWeek = "화요일", + iconUrl = null, + highTempCelsius = 13, + lowTempCelsius = 7, + ), + WeatherForecastUiInfo( + date = LocalDate.of(2025, 3, 5), + dayOfWeek = "수요일", + iconUrl = null, + highTempCelsius = 16, + lowTempCelsius = 9, + ), + ), + ), + ) + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt index eef99c8c..db19d459 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt @@ -2,12 +2,14 @@ package com.yapp.ndgl.feature.travelhelper.navigation import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey -import com.yapp.ndgl.feature.travelhelper.TravelHelperRoute +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperRoute import com.yapp.ndgl.navigation.Navigator import com.yapp.ndgl.navigation.Route fun EntryProviderScope.travelHelperEntry(navigator: Navigator) { entry { - TravelHelperRoute() + TravelHelperRoute( + navigateToSearch = { navigator.navigate(Route.TemplateSearch) }, + ) } } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/util/ResourceUtil.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/util/ResourceUtil.kt new file mode 100644 index 00000000..45225b6d --- /dev/null +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/util/ResourceUtil.kt @@ -0,0 +1,22 @@ +package com.yapp.ndgl.feature.travelhelper.util + +import com.yapp.ndgl.data.travel.model.PlaceCategory +import com.yapp.ndgl.core.ui.R as CoreR + +fun PlaceCategory.toDisplayNameRes() = when (this) { + PlaceCategory.AIRPORT -> CoreR.string.place_type_airport + PlaceCategory.TRANSPORT -> CoreR.string.place_type_transport + PlaceCategory.ATTRACTION -> CoreR.string.place_type_attraction + PlaceCategory.RESTAURANT -> CoreR.string.place_type_restaurant + PlaceCategory.CAFE -> CoreR.string.place_type_cafe + PlaceCategory.ACCOMMODATION -> CoreR.string.place_type_accommodation +} + +fun PlaceCategory.toDrawableRes() = when (this) { + PlaceCategory.AIRPORT -> CoreR.drawable.ic_14_airplane + PlaceCategory.TRANSPORT -> CoreR.drawable.ic_14_car + PlaceCategory.ATTRACTION -> CoreR.drawable.ic_14_flag + PlaceCategory.RESTAURANT -> CoreR.drawable.ic_14_restaurant + PlaceCategory.CAFE -> CoreR.drawable.ic_14_coffee + PlaceCategory.ACCOMMODATION -> CoreR.drawable.ic_14_home +} diff --git a/feature/travel-helper/src/main/res/values/strings.xml b/feature/travel-helper/src/main/res/values/strings.xml new file mode 100644 index 00000000..343b1a04 --- /dev/null +++ b/feature/travel-helper/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + + 아직 등록된 여행지가 없어요 + 새 여행 일정을 만들어 보세요! + %s~%s + M월 d일 + %d일차 입니다! + D-%d + D+%d + + + 아직 예정된 여행이 없어요. + 따라가기 영상을 담아두면 여행 준비가 쉬워져요. + 새로운 여행지 찾아보기 + + + 여행 중 날씨 + 아직 날씨 정보를 준비하고 있어요. + + + 환율 계산기 + KRW (원) + 1 %1$s = %2$s KRW + From 4569b2eaffd291ad28c12ab478c17d84fb7aed7b Mon Sep 17 00:00:00 2001 From: "Jihee.Han" Date: Tue, 24 Feb 2026 03:49:33 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[NDGL-104]=20feat:=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20-=20=ED=99=98=EC=9C=A8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/ndgl/data/core/di/NetworkModule.kt | 17 + data/travel/build.gradle.kts | 1 + .../ndgl/data/travel/api/ExchangeRateApi.kt | 13 + .../data/travel/di/TravelNetworkModule.kt | 27 ++ .../data/travel/model/ExchangeRateResponse.kt | 12 + .../repository/ExchangeRateRepository.kt | 21 + .../data/travel/util/CurrencyInfoResolver.kt | 34 ++ .../main/CurrencyCalculatorSection.kt | 436 +++++++++++++++--- .../travelhelper/main/TravelHelperContract.kt | 26 +- .../travelhelper/main/TravelHelperScreen.kt | 50 ++ .../main/TravelHelperViewModel.kt | 223 ++++++--- .../main/UpcomingTravelCardSection.kt | 39 +- .../src/main/res/drawable/ic_24_updown.xml | 12 + .../src/main/res/values/strings.xml | 2 + 14 files changed, 771 insertions(+), 142 deletions(-) create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/api/ExchangeRateApi.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ExchangeRateResponse.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ExchangeRateRepository.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/util/CurrencyInfoResolver.kt create mode 100644 feature/travel-helper/src/main/res/drawable/ic_24_updown.xml diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt index af6951e7..249515df 100644 --- a/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/di/NetworkModule.kt @@ -136,6 +136,15 @@ object NetworkModule { .addInterceptor(androidCredentialInterceptor) .addInterceptor(httpLoggingInterceptor) .build() + + @ExchangeRateClient + @Singleton + @Provides + fun provideExchangeRateOkHttpClient( + httpLoggingInterceptor: HttpLoggingInterceptor, + ): OkHttpClient = OkHttpClient.Builder() + .addInterceptor(httpLoggingInterceptor) + .build() } @Qualifier @@ -173,3 +182,11 @@ annotation class WeatherClient @Qualifier @Retention(AnnotationRetention.BINARY) annotation class GeocodingClient + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ExchangeRateApiKey + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ExchangeRateClient diff --git a/data/travel/build.gradle.kts b/data/travel/build.gradle.kts index 89ac9dce..f36a5c7e 100644 --- a/data/travel/build.gradle.kts +++ b/data/travel/build.gradle.kts @@ -14,6 +14,7 @@ android { buildConfigField("String", "PLACE_API_KEY", "\"${localProperties.getProperty("PLACE_API_KEY", "")}\"") buildConfigField("String", "ROUTE_API_KEY", "\"${localProperties.getProperty("ROUTE_API_KEY", "")}\"") buildConfigField("String", "WEATHER_API_KEY", "\"${localProperties.getProperty("WEATHER_API_KEY", "")}\"") + buildConfigField("String", "EXCHANGE_RATE_API_KEY", "\"${localProperties.getProperty("EXCHANGE_RATE_API_KEY", "")}\"") } buildFeatures { diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/ExchangeRateApi.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/ExchangeRateApi.kt new file mode 100644 index 00000000..1ba2730b --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/ExchangeRateApi.kt @@ -0,0 +1,13 @@ +package com.yapp.ndgl.data.travel.api + +import com.yapp.ndgl.data.travel.model.ExchangeRateResponse +import retrofit2.http.GET +import retrofit2.http.Path + +interface ExchangeRateApi { + @GET("v6/{apiKey}/latest/{base}") + suspend fun getLatestRate( + @Path("apiKey") apiKey: String, + @Path("base") base: String, + ): ExchangeRateResponse +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt index 07ba7554..41bbe31c 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/di/TravelNetworkModule.kt @@ -4,6 +4,8 @@ import android.content.Context import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.net.PlacesClient import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.yapp.ndgl.data.core.di.ExchangeRateApiKey +import com.yapp.ndgl.data.core.di.ExchangeRateClient import com.yapp.ndgl.data.core.di.GeocodingClient import com.yapp.ndgl.data.core.di.RouteApiKey import com.yapp.ndgl.data.core.di.RouteBaseUrl @@ -11,6 +13,7 @@ import com.yapp.ndgl.data.core.di.RouteClient import com.yapp.ndgl.data.core.di.WeatherApiKey import com.yapp.ndgl.data.core.di.WeatherClient import com.yapp.ndgl.data.travel.BuildConfig +import com.yapp.ndgl.data.travel.api.ExchangeRateApi import com.yapp.ndgl.data.travel.api.GeocodingApi import com.yapp.ndgl.data.travel.api.PlaceApi import com.yapp.ndgl.data.travel.api.RouteApi @@ -35,6 +38,7 @@ object TravelNetworkModule { private const val ROUTES_BASE_URL = "https://routes.googleapis.com/" private const val WEATHER_BASE_URL = "https://weather.googleapis.com/" private const val GEOCODING_BASE_URL = "https://maps.googleapis.com/" + private const val EXCHANGE_RATE_BASE_URL = "https://v6.exchangerate-api.com/" @Provides @Singleton @@ -140,4 +144,27 @@ object TravelNetworkModule { fun provideRouteApi( @RouteClient retrofit: Retrofit, ): RouteApi = retrofit.create(RouteApi::class.java) + + @ExchangeRateApiKey + @Provides + @Singleton + fun provideExchangeRateApiKey(): String = BuildConfig.EXCHANGE_RATE_API_KEY + + @ExchangeRateClient + @Provides + @Singleton + fun provideExchangeRateRetrofit( + @ExchangeRateClient okHttpClient: OkHttpClient, + json: Json, + ): Retrofit = Retrofit.Builder() + .baseUrl(EXCHANGE_RATE_BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + @Provides + @Singleton + fun provideExchangeRateApi( + @ExchangeRateClient retrofit: Retrofit, + ): ExchangeRateApi = retrofit.create(ExchangeRateApi::class.java) } diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ExchangeRateResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ExchangeRateResponse.kt new file mode 100644 index 00000000..52efd934 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ExchangeRateResponse.kt @@ -0,0 +1,12 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ExchangeRateResponse( + @SerialName("result") val result: String, + @SerialName("base_code") val baseCode: String, + @SerialName("time_last_update_utc") val timeLastUpdateUtc: String, + @SerialName("conversion_rates") val conversionRates: Map, +) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ExchangeRateRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ExchangeRateRepository.kt new file mode 100644 index 00000000..b1a39dd0 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/ExchangeRateRepository.kt @@ -0,0 +1,21 @@ +package com.yapp.ndgl.data.travel.repository + +import com.yapp.ndgl.data.core.di.ExchangeRateApiKey +import com.yapp.ndgl.data.travel.api.ExchangeRateApi +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ExchangeRateRepository @Inject constructor( + private val exchangeRateApi: ExchangeRateApi, + @ExchangeRateApiKey private val apiKey: String, +) { + suspend fun getKrwRate(currencyCode: String): Double? { + return exchangeRateApi.getLatestRate(apiKey, currencyCode) + .conversionRates["KRW"] + } + + suspend fun getSupportedCurrencyCodes(): List { + return exchangeRateApi.getLatestRate(apiKey, "USD").conversionRates.keys.toList() + } +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/util/CurrencyInfoResolver.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/util/CurrencyInfoResolver.kt new file mode 100644 index 00000000..8407de62 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/util/CurrencyInfoResolver.kt @@ -0,0 +1,34 @@ +package com.yapp.ndgl.data.travel.util + +import java.util.Currency +import java.util.Locale + +object CurrencyInfoResolver { + private val countryCodeOverrides = mapOf("EUR" to "EU") + + fun getCountryCode(currencyCode: String): String? { + countryCodeOverrides[currencyCode]?.let { return it } + return Locale.getAvailableLocales() + .firstOrNull { locale -> + locale.country.isNotEmpty() && + runCatching { Currency.getInstance(locale)?.currencyCode == currencyCode }.getOrDefault(false) + }?.country + } + + fun getCountryName(currencyCode: String): String { + val code = getCountryCode(currencyCode) ?: return currencyCode + return Locale("", code).getDisplayCountry(Locale.KOREAN) + } + + fun getCurrencyCode(countryCode: String): String { + return runCatching { + Currency.getInstance(Locale("", countryCode)).currencyCode + }.getOrDefault("USD") + } + + fun getKoreanName(currencyCode: String): String { + return runCatching { + Currency.getInstance(currencyCode).getDisplayName(Locale.KOREAN) + }.getOrDefault(currencyCode) + } +} diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt index 71b28275..bbe7f246 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt @@ -1,131 +1,429 @@ package com.yapp.ndgl.feature.travelhelper.main +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.yapp.ndgl.core.ui.theme.NDGLTheme import com.yapp.ndgl.feature.travelhelper.R import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.ExchangeRateInfo +import kotlinx.collections.immutable.ImmutableList +import java.time.format.DateTimeFormatter +import kotlinx.collections.immutable.persistentListOf +import com.yapp.ndgl.core.ui.R as CoreR @Composable internal fun CurrencyCalculatorSection( exchangeRateInfo: ExchangeRateInfo, currencyInput: String, convertedAmount: Double?, + availableCurrencies: ImmutableList, onInputChange: (String) -> Unit, + onSwap: () -> Unit, + onCurrencySelect: (String) -> Unit, modifier: Modifier = Modifier, ) { + val convertedFormatted = convertedAmount?.let { "%,.2f".format(it) } ?: "-" + Column( modifier = modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(horizontal = 24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( text = stringResource(R.string.travel_helper_currency_calculator_title), - style = NDGLTheme.typography.subtitleMdSemiBold, - color = NDGLTheme.colors.black900, + style = NDGLTheme.typography.subtitleLgSemiBold, + color = NDGLTheme.colors.black700, ) Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(NDGLTheme.colors.black50) - .padding(16.dp), + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = "${exchangeRateInfo.foreignCurrencyCode} (${exchangeRateInfo.foreignCurrencyName})", - style = NDGLTheme.typography.bodyMdMedium, - color = NDGLTheme.colors.black600, + Column(modifier = Modifier.fillMaxWidth()) { + CurrencyCard( + modifier = Modifier.fillMaxWidth(), + isEditable = true, + flagEmoji = exchangeRateInfo.topCurrency.flagEmoji, + currencyName = exchangeRateInfo.topCurrency.countryName, + currencyCode = exchangeRateInfo.topCurrency.currencyCode, + currencyLabel = exchangeRateInfo.topCurrency.currencyLabel, + currencyInput = currencyInput, + availableCurrencies = availableCurrencies, + selectedCurrencyCode = exchangeRateInfo.topCurrency.currencyCode, + onInputChange = onInputChange, + onCurrencySelect = onCurrencySelect, ) - BasicTextField( - value = currencyInput, - onValueChange = onInputChange, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - textStyle = NDGLTheme.typography.subtitleMdSemiBold.copy( - color = NDGLTheme.colors.black900, - textAlign = TextAlign.End, - ), - cursorBrush = SolidColor(NDGLTheme.colors.green500), - decorationBox = { innerTextField -> - Box(contentAlignment = Alignment.CenterEnd) { - if (currencyInput.isEmpty()) { - Text( - text = "0", - style = NDGLTheme.typography.subtitleMdSemiBold, - color = NDGLTheme.colors.black300, - textAlign = TextAlign.End, - ) - } - innerTextField() - } - }, + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + HorizontalDivider( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 26.dp), + thickness = 1.dp, + color = NDGLTheme.colors.black200, + ) + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(NDGLTheme.colors.black50) + .clickable(onClick = onSwap), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_24_updown), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = NDGLTheme.colors.black900, + ) + } + } + CurrencyCard( + modifier = Modifier.fillMaxWidth(), + isEditable = false, + flagEmoji = exchangeRateInfo.bottomCurrency.flagEmoji, + currencyName = exchangeRateInfo.bottomCurrency.countryName, + currencyCode = exchangeRateInfo.bottomCurrency.currencyCode, + currencyLabel = exchangeRateInfo.bottomCurrency.currencyLabel, + currencyInput = convertedFormatted, + availableCurrencies = persistentListOf(), + selectedCurrencyCode = "", + onInputChange = {}, + onCurrencySelect = {}, ) } - Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(NDGLTheme.colors.black200), + Text( + text = stringResource( + R.string.travel_helper_currency_rate_date, + exchangeRateInfo.rateDate.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")), + ), + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodyMdRegular, ) + } + } +} + +@Composable +private fun CurrencyCard( + modifier: Modifier, + isEditable: Boolean, + flagEmoji: String, + currencyName: String, + currencyCode: String, + currencyLabel: String, + currencyInput: String, + availableCurrencies: ImmutableList, + selectedCurrencyCode: String, + onInputChange: (String) -> Unit, + onCurrencySelect: (String) -> Unit, +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .border( + width = 1.dp, + color = NDGLTheme.colors.black200, + shape = RoundedCornerShape(4.dp) + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ForeignCurrencyLeft( + modifier = Modifier.wrapContentWidth(), + showCurrencySelector = isEditable, + flagEmoji = flagEmoji, + currencyName = currencyName, + currencyCode = currencyCode, + availableCurrencies = availableCurrencies, + selectedCurrencyCode = selectedCurrencyCode, + onCurrencySelect = onCurrencySelect, + ) + ForeignCurrencyRight( + modifier = Modifier.weight(1f), + isEditable = isEditable, + currencyInput = currencyInput, + currencyLabel = currencyLabel, + onInputChange = onInputChange, + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, +@Composable +private fun ForeignCurrencyLeft( + modifier: Modifier, + showCurrencySelector: Boolean, + flagEmoji: String, + currencyName: String, + currencyCode: String, + availableCurrencies: ImmutableList, + selectedCurrencyCode: String, + onCurrencySelect: (String) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Row( + modifier = modifier + .background( + color = NDGLTheme.colors.black50, + shape = RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp), + ) + .then( + if (showCurrencySelector) Modifier.clickable( + interactionSource = null, + indication = ripple(), + onClick = { expanded = true }, + ) else Modifier + ) + .padding(horizontal = 10.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.width(142.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = flagEmoji, + style = NDGLTheme.typography.bodyLgSemiBold, + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( - text = stringResource(R.string.travel_helper_currency_krw), - style = NDGLTheme.typography.bodyMdMedium, - color = NDGLTheme.colors.black600, + text = currencyName, + modifier = Modifier.fillMaxWidth(), + color = NDGLTheme.colors.black800, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = NDGLTheme.typography.bodyLgSemiBold, ) Text( - text = convertedAmount?.let { - String.format("%,.0f", it) - } ?: "-", - style = NDGLTheme.typography.subtitleMdSemiBold, - color = NDGLTheme.colors.black900, + text = currencyCode, + modifier = Modifier.fillMaxWidth(), + color = NDGLTheme.colors.black400, + style = NDGLTheme.typography.bodyMdMedium, + ) + } + } + if (showCurrencySelector) { + Icon( + imageVector = ImageVector.vectorResource(CoreR.drawable.ic_24_chevron_down), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = NDGLTheme.colors.black600, + ) + } else { + Box(modifier = Modifier.size(24.dp)) + } + } + if (showCurrencySelector) { + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + shape = RoundedCornerShape(8.dp), + containerColor = NDGLTheme.colors.white, + shadowElevation = 8.dp, + border = BorderStroke(1.dp, NDGLTheme.colors.black200), + ) { + availableCurrencies.forEach { option -> + CurrencyDropdownItem( + option = option, + isSelected = option.currencyCode == selectedCurrencyCode, + onClick = { + expanded = false + onCurrencySelect(option.currencyCode) + }, ) } + } + } +} - Text( - text = stringResource( - R.string.travel_helper_currency_rate_info, - exchangeRateInfo.foreignCurrencyCode, - String.format("%,.2f", exchangeRateInfo.rateToKrw), - ), - style = NDGLTheme.typography.bodySmRegular, - color = NDGLTheme.colors.black400, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.End, +@Composable +private fun CurrencyDropdownItem( + option: CurrencyOption, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (isSelected) { + NDGLTheme.colors.green100 + } else { + NDGLTheme.colors.white + } ) + .clickable(onClick = onClick) + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isSelected) { + Icon( + imageVector = ImageVector.vectorResource(CoreR.drawable.ic_20_check), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = NDGLTheme.colors.black900, + ) + } else { + Spacer(modifier = Modifier.size(20.dp)) + } + Text( + text = option.countryName, + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black800, + ) + Text( + text = option.currencyCode, + style = NDGLTheme.typography.bodyMdMedium, + color = NDGLTheme.colors.black400, + ) + } +} + +@Composable +private fun ForeignCurrencyRight( + modifier: Modifier, + isEditable: Boolean, + currencyInput: String, + currencyLabel: String, + onInputChange: (String) -> Unit, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + BasicTextField( + value = currencyInput, + onValueChange = onInputChange, + modifier = Modifier.fillMaxWidth(), + readOnly = isEditable.not(), + textStyle = NDGLTheme.typography.bodyLgSemiBold.copy( + color = NDGLTheme.colors.black800, + textAlign = TextAlign.End, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + cursorBrush = SolidColor(NDGLTheme.colors.green500), + visualTransformation = if (isEditable) ThousandSeparatorTransformation() + else VisualTransformation.None, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterEnd) { + if (currencyInput.isEmpty()) { + Text( + text = "0", + style = NDGLTheme.typography.bodyLgSemiBold, + color = NDGLTheme.colors.black300, + textAlign = TextAlign.End, + ) + } + innerTextField() + } + }, + ) + val displayInput = if (isEditable) { + val parts = currencyInput.split(".") + val formattedInt = parts[0].ifEmpty { "0" } + .reversed().chunked(3).joinToString(",").reversed() + if (parts.size > 1) "$formattedInt.${parts[1]}" else formattedInt + } else { + currencyInput.ifEmpty { "0" } + } + Text( + text = "$displayInput $currencyLabel", + modifier = Modifier.fillMaxWidth(), + color = NDGLTheme.colors.black400, + textAlign = TextAlign.End, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = NDGLTheme.typography.bodyMdRegular, + ) + } +} + +private class ThousandSeparatorTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val original = text.text + val dotIndex = original.indexOf('.') + val intPart = if (dotIndex >= 0) original.substring(0, dotIndex) else original + val decimalPart = if (dotIndex >= 0) original.substring(dotIndex) else "" + + val formattedInt = if (intPart.isEmpty()) "" + else intPart.reversed().chunked(3).joinToString(",").reversed() + val formatted = formattedInt + decimalPart + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= intPart.length) { + var origCount = 0 + formattedInt.forEachIndexed { idx, ch -> + if (origCount == offset) return idx + if (ch != ',') origCount++ + } + return formattedInt.length + } + return formattedInt.length + (offset - intPart.length) + } + + override fun transformedToOriginal(offset: Int): Int { + val fmtIntLen = formattedInt.length + return if (offset <= fmtIntLen) { + formattedInt.take(offset).count { it != ',' } + } else { + intPart.length + (offset - fmtIntLen) + } + } } + return TransformedText(AnnotatedString(formatted), offsetMapping) } } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt index eda83677..2fbde6dc 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt @@ -7,13 +7,20 @@ import com.yapp.ndgl.core.base.UiSideEffect import com.yapp.ndgl.core.base.UiState import com.yapp.ndgl.data.travel.model.PlaceCategory import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.time.LocalDate +data class CurrencyOption( + val currencyCode: String, + val countryName: String, +) + @Stable data class TravelHelperState( val travelUiState: TravelUiState = TravelUiState.Loading, val currencyInput: String = "1", val convertedAmount: Double? = null, + val availableCurrencies: ImmutableList = persistentListOf(), ) : UiState { sealed interface TravelUiState { data object Loading : TravelUiState @@ -70,11 +77,20 @@ data class TravelHelperState( val thumbnailUrl: String?, ) + @Immutable + data class CurrencyInfo( + val currencyCode: String, + val currencyLabel: String, + val countryName: String, + val flagEmoji: String, + ) + + @Immutable data class ExchangeRateInfo( - val foreignCurrencyCode: String, - val foreignCurrencyName: String, - val rateToKrw: Double, - val rateDate: String, + val topCurrency: CurrencyInfo, + val bottomCurrency: CurrencyInfo, + val rate: Double, + val rateDate: LocalDate, ) } @@ -82,8 +98,10 @@ sealed interface TravelHelperIntent : UiIntent { data object ClickSearch : TravelHelperIntent data class UpdateCurrencyInput(val input: String) : TravelHelperIntent data object SwapCurrency : TravelHelperIntent + data class SelectCurrency(val currencyCode: String) : TravelHelperIntent } sealed interface TravelHelperSideEffect : UiSideEffect { data object NavigateToSearch : TravelHelperSideEffect + data object ShowExchangeRateError : TravelHelperSideEffect } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt index 9c2e995b..57e4e511 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt @@ -20,13 +20,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -35,9 +40,12 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationIcon +import com.yapp.ndgl.core.ui.designsystem.NDGLSnackbar import com.yapp.ndgl.core.ui.theme.NDGLTheme import com.yapp.ndgl.feature.travelhelper.R import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.launch import com.yapp.ndgl.core.ui.R as CoreR @Composable @@ -45,20 +53,33 @@ internal fun TravelHelperRoute( navigateToSearch: () -> Unit, viewModel: TravelHelperViewModel = hiltViewModel(), ) { + val context = LocalContext.current + val state by viewModel.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + val coroutineScope = rememberCoroutineScope() + val exchangeRateErrorMessage = stringResource(R.string.travel_helper_exchange_rate_error) TravelHelperScreen( state = state, + snackbarHostState = snackbarHostState, onSearchClick = { viewModel.onIntent(TravelHelperIntent.ClickSearch) }, onNewTravelFindClick = {}, onTravelClick = {}, onPlaceClick = {}, onCurrencyInputChange = { viewModel.onIntent(TravelHelperIntent.UpdateCurrencyInput(it)) }, + onSwapCurrency = { viewModel.onIntent(TravelHelperIntent.SwapCurrency) }, + onCurrencySelect = { viewModel.onIntent(TravelHelperIntent.SelectCurrency(it)) }, ) viewModel.collectSideEffect { sideEffect -> when (sideEffect) { TravelHelperSideEffect.NavigateToSearch -> navigateToSearch() + TravelHelperSideEffect.ShowExchangeRateError -> { + coroutineScope.launch { + snackbarHostState.showSnackbar(exchangeRateErrorMessage) + } + } } } } @@ -66,13 +87,24 @@ internal fun TravelHelperRoute( @Composable private fun TravelHelperScreen( state: TravelHelperState, + snackbarHostState: SnackbarHostState, onSearchClick: () -> Unit, onNewTravelFindClick: () -> Unit, onTravelClick: (Long) -> Unit, onPlaceClick: (Long) -> Unit, onCurrencyInputChange: (String) -> Unit, + onSwapCurrency: () -> Unit, + onCurrencySelect: (String) -> Unit, ) { Scaffold( + snackbarHost = { + SnackbarHost(snackbarHostState) { data -> + NDGLSnackbar( + modifier = Modifier.padding(bottom = 100.dp), + snackbarData = data + ) + } + }, topBar = { NDGLNavigationBar( textAlignType = NDGLNavigationBarAttr.TextAlignType.START, @@ -119,17 +151,23 @@ private fun TravelHelperScreen( travel = travelUiState, currencyInput = state.currencyInput, convertedAmount = state.convertedAmount, + availableCurrencies = state.availableCurrencies, onTravelClick = onTravelClick, onCurrencyInputChange = onCurrencyInputChange, + onSwapCurrency = onSwapCurrency, + onCurrencySelect = onCurrencySelect, ) is TravelUiState.OngoingTravel -> inProgressTravelContent( travel = travelUiState, currencyInput = state.currencyInput, convertedAmount = state.convertedAmount, + availableCurrencies = state.availableCurrencies, onTravelClick = onTravelClick, onPlaceClick = onPlaceClick, onCurrencyInputChange = onCurrencyInputChange, + onSwapCurrency = onSwapCurrency, + onCurrencySelect = onCurrencySelect, ) } } @@ -220,8 +258,11 @@ private fun LazyListScope.upcomingTravelContent( travel: TravelUiState.UpcomingTravel, currencyInput: String, convertedAmount: Double?, + availableCurrencies: ImmutableList, onTravelClick: (Long) -> Unit, onCurrencyInputChange: (String) -> Unit, + onSwapCurrency: () -> Unit, + onCurrencySelect: (String) -> Unit, ) { item { UpcomingTravelCard( @@ -238,7 +279,10 @@ private fun LazyListScope.upcomingTravelContent( exchangeRateInfo = travel.exchangeRateInfo, currencyInput = currencyInput, convertedAmount = convertedAmount, + availableCurrencies = availableCurrencies, onInputChange = onCurrencyInputChange, + onSwap = onSwapCurrency, + onCurrencySelect = onCurrencySelect, ) } } @@ -247,9 +291,12 @@ private fun LazyListScope.inProgressTravelContent( travel: TravelUiState.OngoingTravel, currencyInput: String, convertedAmount: Double?, + availableCurrencies: ImmutableList, onTravelClick: (Long) -> Unit, onPlaceClick: (Long) -> Unit, onCurrencyInputChange: (String) -> Unit, + onSwapCurrency: () -> Unit, + onCurrencySelect: (String) -> Unit, ) { item { InProgressTravelCard( @@ -267,7 +314,10 @@ private fun LazyListScope.inProgressTravelContent( exchangeRateInfo = travel.exchangeRateInfo, currencyInput = currencyInput, convertedAmount = convertedAmount, + availableCurrencies = availableCurrencies, onInputChange = onCurrencyInputChange, + onSwap = onSwapCurrency, + onCurrencySelect = onCurrencySelect, ) } } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt index 08739a67..9b3c51c1 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt @@ -2,10 +2,14 @@ package com.yapp.ndgl.feature.travelhelper.main import androidx.lifecycle.viewModelScope import com.yapp.ndgl.core.base.BaseViewModel +import com.yapp.ndgl.core.util.FlagEmojiUtil.toFlagEmoji import com.yapp.ndgl.core.util.suspendRunCatching import com.yapp.ndgl.data.travel.model.WeatherForecastResponse +import com.yapp.ndgl.data.travel.repository.ExchangeRateRepository import com.yapp.ndgl.data.travel.repository.UserTravelRepository import com.yapp.ndgl.data.travel.repository.WeatherRepository +import com.yapp.ndgl.data.travel.util.CurrencyInfoResolver +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.CurrencyInfo import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.ExchangeRateInfo import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelPlace import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelUiState @@ -14,7 +18,6 @@ import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.WeatherUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch -import timber.log.Timber import java.time.LocalDate import java.time.format.TextStyle import java.time.temporal.ChronoUnit @@ -26,10 +29,30 @@ import kotlin.math.roundToInt class TravelHelperViewModel @Inject constructor( private val userTravelRepository: UserTravelRepository, private val weatherRepository: WeatherRepository, + private val exchangeRateRepository: ExchangeRateRepository, ) : BaseViewModel( initialState = TravelHelperState(), ) { init { + viewModelScope.launch { + val supportedCodes = runCatching { + exchangeRateRepository.getSupportedCurrencyCodes() + }.getOrDefault(emptyList()) + + val currencies = supportedCodes + .filter { CurrencyInfoResolver.getCountryCode(it) != null } + .map { code -> + CurrencyOption( + currencyCode = code, + countryName = CurrencyInfoResolver.getCountryName(code), + ) + } + val allCurrencies = + (listOf(CurrencyOption(currencyCode = "KRW", countryName = "대한민국")) + currencies) + .toImmutableList() + + reduce { copy(availableCurrencies = allCurrencies) } + } loadUpcomingTravel() } @@ -43,15 +66,9 @@ class TravelHelperViewModel @Inject constructor( } val today = LocalDate.now() - val (currencyCode, currencyDisplayName) = countryToCurrency[travel.country] - ?: ("USD" to "달러") - val rate = krwRates[currencyCode] ?: 1340.0 - val exchangeRateInfo = ExchangeRateInfo( - foreignCurrencyCode = currencyCode, - foreignCurrencyName = currencyDisplayName, - rateToKrw = rate, - rateDate = "2025-01-01", - ) + val currencyCode = CurrencyInfoResolver.getCurrencyCode(travel.country) + val exchangeRateInfo = + buildExchangeRateInfo(topCode = currencyCode, bottomCode = "KRW") val travelUiState = when { today < travel.startDate -> { @@ -99,19 +116,81 @@ class TravelHelperViewModel @Inject constructor( reduce { copy( travelUiState = travelUiState, - convertedAmount = calculateConvertedAmount(currencyInput, rate), + convertedAmount = calculateConvertedAmount( + currencyInput, + exchangeRateInfo.rate + ), ) } loadWeather(travel.city, travel.country, travel.startDate, travel.endDate) } .onFailure { - Timber.e("Failed to load upcoming travel: $it") reduce { copy(travelUiState = TravelUiState.NoTravel) } } } } + private fun buildCurrencyInfo(code: String): CurrencyInfo { + return if (code == "KRW") { + CurrencyInfo( + currencyCode = "KRW", + currencyLabel = "원", + countryName = "대한민국", + flagEmoji = "KR".toFlagEmoji(), + ) + } else { + CurrencyInfo( + currencyCode = code, + currencyLabel = CurrencyInfoResolver.getKoreanName(code), + countryName = CurrencyInfoResolver.getCountryName(code), + flagEmoji = (CurrencyInfoResolver.getCountryCode(code) ?: "").toFlagEmoji(), + ) + } + } + + private suspend fun buildExchangeRateInfo( + topCode: String, + bottomCode: String + ): ExchangeRateInfo { + val rateResult = runCatching { getCrossRate(topCode, bottomCode) } + rateResult.onFailure { + postSideEffect(TravelHelperSideEffect.ShowExchangeRateError) + } + return ExchangeRateInfo( + topCurrency = buildCurrencyInfo(topCode), + bottomCurrency = buildCurrencyInfo(bottomCode), + rate = rateResult.getOrDefault(1.0), + rateDate = LocalDate.now(), + ) + } + + private suspend fun getCrossRate(from: String, to: String): Double { + if (from == to) return 1.0 + return when { + to == "KRW" -> { + exchangeRateRepository.getKrwRate(from) + ?: throw IllegalStateException("Exchange rate not available for $from") + } + + from == "KRW" -> { + val toKrw = exchangeRateRepository.getKrwRate(to) + ?: throw IllegalStateException("Exchange rate not available for $to") + if (toKrw == 0.0) throw IllegalStateException("KRW rate for $to is zero") + 1.0 / toKrw + } + + else -> { + val fromKrw = exchangeRateRepository.getKrwRate(from) + ?: throw IllegalStateException("Exchange rate not available for $from") + val toKrw = exchangeRateRepository.getKrwRate(to) + ?: throw IllegalStateException("Exchange rate not available for $to") + if (toKrw == 0.0) throw IllegalStateException("KRW rate for $to is zero") + fromKrw / toKrw + } + } + } + private fun loadWeather( city: String, country: String, @@ -126,26 +205,22 @@ class TravelHelperViewModel @Inject constructor( startDate, endDate, ) - } - .onSuccess { response -> - if (response == null) return@onSuccess - val forecasts = response.forecastDays - .map { it.toWeatherForecastUiInfo() } - .toImmutableList() - val weatherState = WeatherUiState.Available(forecasts) - reduce { - copy( - travelUiState = when (val ts = travelUiState) { - is TravelUiState.UpcomingTravel -> ts.copy(weatherState = weatherState) - is TravelUiState.OngoingTravel -> ts.copy(weatherState = weatherState) - else -> ts - }, - ) - } - } - .onFailure { - Timber.e("Failed to load weather: $it") + }.onSuccess { response -> + if (response == null) return@onSuccess + val forecasts = response.forecastDays + .map { it.toWeatherForecastUiInfo() } + .toImmutableList() + val weatherState = WeatherUiState.Available(forecasts) + reduce { + copy( + travelUiState = when (val ts = travelUiState) { + is TravelUiState.UpcomingTravel -> ts.copy(weatherState = weatherState) + is TravelUiState.OngoingTravel -> ts.copy(weatherState = weatherState) + else -> ts + }, + ) } + } } } @@ -153,22 +228,65 @@ class TravelHelperViewModel @Inject constructor( when (intent) { TravelHelperIntent.ClickSearch -> postSideEffect(TravelHelperSideEffect.NavigateToSearch) is TravelHelperIntent.UpdateCurrencyInput -> { - val rate = when (val ts = state.value.travelUiState) { - is TravelUiState.UpcomingTravel -> ts.exchangeRateInfo.rateToKrw - is TravelUiState.OngoingTravel -> ts.exchangeRateInfo.rateToKrw - else -> 1.0 - } + val rate = getExchangeRateInfo()?.rate ?: 1.0 val converted = calculateConvertedAmount(intent.input, rate) reduce { copy(currencyInput = intent.input, convertedAmount = converted) } } - TravelHelperIntent.SwapCurrency -> Unit + TravelHelperIntent.SwapCurrency -> { + val info = getExchangeRateInfo() ?: return + val newInfo = buildExchangeRateInfo( + topCode = info.bottomCurrency.currencyCode, + bottomCode = info.topCurrency.currencyCode, + ) + val newInput = state.value.convertedAmount?.let { "%.2f".format(it) } + ?: state.value.currencyInput + val newConverted = calculateConvertedAmount(newInput, newInfo.rate) + reduce { + copy( + travelUiState = travelUiState.withExchangeRateInfo(newInfo), + currencyInput = newInput, + convertedAmount = newConverted, + ) + } + } + + is TravelHelperIntent.SelectCurrency -> { + val currentInfo = getExchangeRateInfo() ?: return + if (intent.currencyCode == currentInfo.topCurrency.currencyCode) return + val newInfo = buildExchangeRateInfo( + topCode = intent.currencyCode, + bottomCode = currentInfo.bottomCurrency.currencyCode, + ) + val newConverted = calculateConvertedAmount("1", newInfo.rate) + reduce { + copy( + travelUiState = travelUiState.withExchangeRateInfo(newInfo), + currencyInput = "1", + convertedAmount = newConverted, + ) + } + } } } - private fun calculateConvertedAmount(input: String, rateToKrw: Double): Double? { + private fun getExchangeRateInfo(): ExchangeRateInfo? = + when (val ts = state.value.travelUiState) { + is TravelUiState.UpcomingTravel -> ts.exchangeRateInfo + is TravelUiState.OngoingTravel -> ts.exchangeRateInfo + else -> null + } + + private fun TravelUiState.withExchangeRateInfo(info: ExchangeRateInfo): TravelUiState = + when (this) { + is TravelUiState.UpcomingTravel -> copy(exchangeRateInfo = info) + is TravelUiState.OngoingTravel -> copy(exchangeRateInfo = info) + else -> this + } + + private fun calculateConvertedAmount(input: String, rate: Double): Double? { val amount = input.toDoubleOrNull() ?: return null - return amount * rateToKrw + return amount * rate } private fun WeatherForecastResponse.ForecastDay.toWeatherForecastUiInfo(): WeatherForecastUiInfo { @@ -182,31 +300,4 @@ class TravelHelperViewModel @Inject constructor( ) } - companion object { - private val countryToCurrency = mapOf( - "IT" to ("EUR" to "유로"), - "FR" to ("EUR" to "유로"), - "DE" to ("EUR" to "유로"), - "ES" to ("EUR" to "유로"), - "JP" to ("JPY" to "엔"), - "US" to ("USD" to "달러"), - "GB" to ("GBP" to "파운드"), - "TH" to ("THB" to "밧"), - "VN" to ("VND" to "동"), - "SG" to ("SGD" to "달러"), - "AU" to ("AUD" to "달러"), - "CN" to ("CNY" to "위안"), - ) - private val krwRates = mapOf( - "EUR" to 1460.0, - "JPY" to 9.5, - "USD" to 1340.0, - "GBP" to 1700.0, - "THB" to 38.0, - "VND" to 0.054, - "SGD" to 1000.0, - "AUD" to 870.0, - "CNY" to 185.0, - ) - } } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt index cd50b638..2bb8fb39 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt @@ -199,7 +199,10 @@ internal fun InProgressTravelCard( overflow = TextOverflow.Ellipsis, ) Text( - text = stringResource(R.string.travel_helper_card_in_progress_day_count, travel.dayCount), + text = stringResource( + R.string.travel_helper_card_in_progress_day_count, + travel.dayCount + ), style = NDGLTheme.typography.subtitleMdSemiBold, color = NDGLTheme.colors.black700, maxLines = 1, @@ -339,7 +342,22 @@ private fun UpcomingTravelCardPreview() { thumbnail = null, dDay = 7L, weatherState = TravelHelperState.WeatherUiState.NotAvailable, - exchangeRateInfo = TravelHelperState.ExchangeRateInfo("JPY", "엔", 9.5, "2025-01-01"), + exchangeRateInfo = TravelHelperState.ExchangeRateInfo( + topCurrency = TravelHelperState.CurrencyInfo( + currencyCode = "JPY", + currencyLabel = "엔", + countryName = "일본", + flagEmoji = "🇯🇵", + ), + bottomCurrency = TravelHelperState.CurrencyInfo( + currencyCode = "KRW", + currencyLabel = "원", + countryName = "대한민국", + flagEmoji = "🇰🇷", + ), + rate = 9.5, + rateDate = LocalDate.of(2025, 1, 1), + ), ), onCardClick = {}, ) @@ -366,7 +384,22 @@ private fun InProgressTravelCardPreview() { estimatedDuration = 60, thumbnailUrl = null, ), - exchangeRateInfo = TravelHelperState.ExchangeRateInfo("USD", "달러", 1340.0, "2025-01-01"), + exchangeRateInfo = TravelHelperState.ExchangeRateInfo( + topCurrency = TravelHelperState.CurrencyInfo( + currencyCode = "USD", + currencyLabel = "달러", + countryName = "미국", + flagEmoji = "🇺🇸", + ), + bottomCurrency = TravelHelperState.CurrencyInfo( + currencyCode = "KRW", + currencyLabel = "원", + countryName = "대한민국", + flagEmoji = "🇰🇷", + ), + rate = 1340.0, + rateDate = LocalDate.of(2025, 1, 1), + ), ), onCardClick = {}, onPlaceClick = {}, diff --git a/feature/travel-helper/src/main/res/drawable/ic_24_updown.xml b/feature/travel-helper/src/main/res/drawable/ic_24_updown.xml new file mode 100644 index 00000000..7fc8aabf --- /dev/null +++ b/feature/travel-helper/src/main/res/drawable/ic_24_updown.xml @@ -0,0 +1,12 @@ + + + + diff --git a/feature/travel-helper/src/main/res/values/strings.xml b/feature/travel-helper/src/main/res/values/strings.xml index 343b1a04..870f29f4 100644 --- a/feature/travel-helper/src/main/res/values/strings.xml +++ b/feature/travel-helper/src/main/res/values/strings.xml @@ -22,4 +22,6 @@ 환율 계산기 KRW (원) 1 %1$s = %2$s KRW + 환율기준 %s + 환율 정보를 불러오지 못했습니다. From 971136d12f0f047801a307782d66d0c739c767b8 Mon Sep 17 00:00:00 2001 From: "Jihee.Han" Date: Thu, 26 Feb 2026 04:07:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[NDGL-104]=20feat:=20=EB=82=B4=20=EC=97=AC?= =?UTF-8?q?=ED=96=89=20=EB=B0=8F=20=ED=8A=B8=EB=9E=98=EB=B8=94=20=ED=97=AC?= =?UTF-8?q?=ED=8D=BC=20=EB=82=B4=20=EC=9E=A5=EC=86=8C=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=ED=85=8D=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=83=9D=EB=9E=B5=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ndgl/feature/home/main/HomeContract.kt | 3 + .../yapp/ndgl/feature/home/main/HomeScreen.kt | 36 +++++++--- .../ndgl/feature/home/main/HomeViewModel.kt | 9 ++- .../home/main/UpcomingTravelCardSection.kt | 68 +++++++++++++------ .../ndgl/feature/home/navigation/HomeEntry.kt | 3 + feature/home/src/main/res/values/strings.xml | 2 +- .../main/CurrencyCalculatorSection.kt | 40 +++++++---- .../travelhelper/main/TravelHelperContract.kt | 8 ++- .../travelhelper/main/TravelHelperScreen.kt | 37 ++++++---- .../main/TravelHelperViewModel.kt | 25 +++++-- .../main/UpcomingTravelCardSection.kt | 20 +++--- .../navigation/TravelHelperEntry.kt | 5 ++ .../mytravel/UpcomingTravelCardSection.kt | 39 ++++++++--- .../mytravel/UpcomingTravelListSection.kt | 3 + .../travel/src/main/res/values/strings.xml | 2 +- 15 files changed, 215 insertions(+), 85 deletions(-) diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeContract.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeContract.kt index d3065bb7..9de90b73 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeContract.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeContract.kt @@ -49,6 +49,7 @@ data class HomeState( } data class TravelPlace( + val googlePlaceId: String, val category: PlaceCategory, val estimatedDuration: Int, val name: String, @@ -73,6 +74,7 @@ sealed interface HomeIntent : UiIntent { data class ClickTravel(val travelId: Long, val days: Int) : HomeIntent data object ClickTravelMore : HomeIntent data class ClickMyTravel(val travelId: Long, val days: Int) : HomeIntent + data class ClickMyTravelPlace(val placeId: String) : HomeIntent } sealed interface HomeSideEffect : UiSideEffect { @@ -81,4 +83,5 @@ sealed interface HomeSideEffect : UiSideEffect { data class NavigateToFollowTravel(val travelId: Long, val days: Int) : HomeSideEffect data object NavigateToTravelMore : HomeSideEffect data class NavigateToTravelDetail(val travelId: Long, val days: Int) : HomeSideEffect + data class NavigateToPlaceDetail(val placeId: String) : HomeSideEffect } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeScreen.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeScreen.kt index c2e6b56a..4b485c0b 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeScreen.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeScreen.kt @@ -33,6 +33,7 @@ internal fun HomeRoute( navigateToFollowTravel: (Long, Int) -> Unit, navigateToPopularTravelList: () -> Unit, navigateToTravelDetail: (Long, Int) -> Unit, + navigateToPlaceDetail: (String) -> Unit, ) { val state by viewModel.collectAsState() @@ -53,12 +54,11 @@ internal fun HomeRoute( onTravelMoreClick = { viewModel.onIntent(HomeIntent.ClickTravelMore) }, - onMyTravelCardClick = { - when (val myTravel = state.myTravel) { - is HomeState.MyTravel.Upcoming -> viewModel.onIntent(HomeIntent.ClickMyTravel(myTravel.travelId, myTravel.days)) - is HomeState.MyTravel.InProgress -> viewModel.onIntent(HomeIntent.ClickMyTravel(myTravel.travelId, myTravel.days)) - HomeState.MyTravel.None -> {} // Do nothing - } + onMyTravelClick = { travelId, days -> + viewModel.onIntent(HomeIntent.ClickMyTravel(travelId, days)) + }, + onPlaceClick = { + viewModel.onIntent(HomeIntent.ClickMyTravelPlace(it)) }, ) @@ -66,9 +66,18 @@ internal fun HomeRoute( when (sideEffect) { HomeSideEffect.NavigateToSearchTravelTemplate -> navigateToTemplateSearch() HomeSideEffect.NavigateToSettings -> navigateToSettings() - is HomeSideEffect.NavigateToFollowTravel -> navigateToFollowTravel(sideEffect.travelId, sideEffect.days) + is HomeSideEffect.NavigateToFollowTravel -> navigateToFollowTravel( + sideEffect.travelId, + sideEffect.days, + ) + HomeSideEffect.NavigateToTravelMore -> navigateToPopularTravelList() - is HomeSideEffect.NavigateToTravelDetail -> navigateToTravelDetail(sideEffect.travelId, sideEffect.days) + is HomeSideEffect.NavigateToTravelDetail -> navigateToTravelDetail( + sideEffect.travelId, + sideEffect.days, + ) + + is HomeSideEffect.NavigateToPlaceDetail -> navigateToPlaceDetail(sideEffect.placeId) } } } @@ -81,7 +90,8 @@ private fun HomeScreen( onTabSelected: (Int) -> Unit, onTravelClick: (Long, Int) -> Unit, onTravelMoreClick: () -> Unit, - onMyTravelCardClick: () -> Unit, + onMyTravelClick: (Long, Int) -> Unit, + onPlaceClick: (String) -> Unit, ) { Scaffold( topBar = { @@ -116,7 +126,9 @@ private fun HomeScreen( UpcomingTravelCardSection( modifier = Modifier.fillMaxWidth(), myTravel = state.myTravel, - onCardClick = onMyTravelCardClick, + onMyTravelClick = onMyTravelClick, + onPlaceClick = onPlaceClick, + onEmptyTravelClick = onTravelMoreClick, ) } @@ -197,6 +209,7 @@ private fun HomeScreenPreview() { startDate = LocalDate.of(2024, 12, 23), endDate = LocalDate.of(2024, 12, 26), currentPlace = HomeState.TravelPlace( + googlePlaceId = "", category = PlaceCategory.TRANSPORT, estimatedDuration = 60, name = "인도 국제 공항", @@ -228,7 +241,8 @@ private fun HomeScreenPreview() { onTabSelected = {}, onTravelClick = { _, _ -> }, onTravelMoreClick = {}, - onMyTravelCardClick = {}, + onMyTravelClick = { _, _ -> }, + onPlaceClick = {}, ) } } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt index 72dd154a..0a9ca209 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt @@ -39,8 +39,7 @@ class HomeViewModel @Inject constructor( } private fun subscribeToTravelCreatedEvent() = viewModelScope.launch { - userTravelRepository.travelCreatedEvent.collect { event -> - // 새 여행 생성 시 내 여행 섹션만 새로고침 + userTravelRepository.travelCreatedEvent.collect { _ -> loadMyTravel() } } @@ -82,6 +81,7 @@ class HomeViewModel @Inject constructor( endDate = travel.endDate, currentPlace = upcomingPlace?.place?.let { place -> HomeState.TravelPlace( + googlePlaceId = place.googlePlaceId, category = place.category, estimatedDuration = upcomingPlace.estimatedDuration, name = place.name, @@ -193,6 +193,7 @@ class HomeViewModel @Inject constructor( is HomeIntent.ClickTravel -> postNavigateToTravelTemplate(travelId = intent.travelId, days = intent.days) HomeIntent.ClickTravelMore -> postNavigateToTravelMore() is HomeIntent.ClickMyTravel -> postNavigateToTravelDetail(travelId = intent.travelId, days = intent.days) + is HomeIntent.ClickMyTravelPlace -> postNavigateToPlaceDetail(placeId = intent.placeId) } } @@ -216,6 +217,10 @@ class HomeViewModel @Inject constructor( postSideEffect(HomeSideEffect.NavigateToTravelDetail(travelId = travelId, days = days)) } + private fun postNavigateToPlaceDetail(placeId: String) { + postSideEffect(HomeSideEffect.NavigateToPlaceDetail(placeId)) + } + companion object { private const val MAX_POPULAR_TRAVEL_COUNT = 9 } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/UpcomingTravelCardSection.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/UpcomingTravelCardSection.kt index 05420fa4..84b7798f 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/UpcomingTravelCardSection.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/UpcomingTravelCardSection.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -43,25 +44,27 @@ import com.yapp.ndgl.core.ui.R as CoreR @Composable internal fun UpcomingTravelCardSection( myTravel: MyTravel, + onMyTravelClick: (Long, Int) -> Unit, + onPlaceClick: (String) -> Unit, + onEmptyTravelClick: () -> Unit, modifier: Modifier = Modifier, - onCardClick: () -> Unit = {}, ) { when (myTravel) { MyTravel.None -> EmptyTravelCard( modifier = modifier, - onCardClick = { /* FIXME: 인기 여행 컨텐츠 전체보기 페이지 이동 */ }, + onCardClick = onEmptyTravelClick, ) is MyTravel.Upcoming -> UpcomingTravelCard( modifier = modifier, travel = myTravel, - onCardClick = onCardClick, + onCardClick = { onMyTravelClick(myTravel.travelId, myTravel.days) }, ) is MyTravel.InProgress -> InProgressTravelCard( travel = myTravel, - onCardClick = onCardClick, - onPlaceClick = { /* FIXME: 장소 상세 보기 페이지 이동 */ }, + onCardClick = { onMyTravelClick(myTravel.travelId, myTravel.days) }, + onPlaceClick = onPlaceClick, ) } } @@ -143,8 +146,10 @@ private fun UpcomingTravelCard( DayTag(dDay = travel.dDay) Text( text = travel.title, - style = NDGLTheme.typography.subtitleMdSemiBold, color = NDGLTheme.colors.black700, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = NDGLTheme.typography.subtitleMdSemiBold, ) } val dateFormatter = DateTimeFormatter.ofPattern( @@ -193,13 +198,13 @@ private fun DayTag( @Composable private fun InProgressTravelCard( travel: MyTravel.InProgress, - onCardClick: () -> Unit, - onPlaceClick: () -> Unit, + onCardClick: (Long) -> Unit, + onPlaceClick: (String) -> Unit, modifier: Modifier = Modifier, ) { CardContainer( modifier = modifier, - onCardClick = onCardClick, + onCardClick = { onCardClick(travel.travelId) }, ) { Column( modifier = Modifier @@ -208,15 +213,28 @@ private fun InProgressTravelCard( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource( - R.string.home_my_travel_card_in_progress_title, - travel.title, - travel.dayCount, - ), - style = NDGLTheme.typography.subtitleMdSemiBold, - color = NDGLTheme.colors.black700, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = travel.title + " ", + modifier = Modifier.weight(1f, fill = false), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource( + R.string.home_my_travel_card_in_progress_day_count, + travel.dayCount, + ), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black700, + maxLines = 1, + ) + } val dateFormatter = DateTimeFormatter.ofPattern( stringResource(R.string.home_my_travel_card_date_format), ) @@ -234,7 +252,7 @@ private fun InProgressTravelCard( if (travel.currentPlace != null) { PlaceInfoCard( place = travel.currentPlace, - onPlaceClick = onPlaceClick, + onPlaceClick = { onPlaceClick(travel.currentPlace.googlePlaceId) }, ) } } @@ -293,6 +311,8 @@ private fun PlaceInfoCard( Text( text = place.name, color = NDGLTheme.colors.black900, + overflow = TextOverflow.Ellipsis, + maxLines = 1, style = NDGLTheme.typography.bodyLgSemiBold, ) } @@ -332,6 +352,9 @@ private fun EmptyTravelCardPreview() { UpcomingTravelCardSection( modifier = Modifier, myTravel = MyTravel.None, + onMyTravelClick = { _, _ -> }, + onPlaceClick = {}, + onEmptyTravelClick = {}, ) } } @@ -351,6 +374,9 @@ private fun UpcomingTravelCardPreview() { endDate = LocalDate.of(2025, 2, 20), imageUrl = "", ), + onMyTravelClick = { _, _ -> }, + onPlaceClick = {}, + onEmptyTravelClick = {}, ) } } @@ -369,12 +395,16 @@ private fun InProgressTravelCardPreview() { startDate = LocalDate.of(2025, 2, 1), endDate = LocalDate.of(2025, 2, 10), currentPlace = TravelPlace( + googlePlaceId = "", category = PlaceCategory.TRANSPORT, estimatedDuration = 60, name = "인도 국제 공항", thumbnailUrl = "", ), ), + onMyTravelClick = { _, _ -> }, + onPlaceClick = {}, + onEmptyTravelClick = {}, ) } } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt index 14ba3270..fc2b8270 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/navigation/HomeEntry.kt @@ -29,6 +29,9 @@ fun EntryProviderScope.homeEntry( navigateToTravelDetail = { travelId, days -> navigator.navigate(Route.TravelDetail(travelId = travelId, days = days)) }, + navigateToPlaceDetail = { placeId -> + navigator.navigate(Route.PlaceDetail(googlePlaceId = placeId)) + }, ) } entry { diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index a5fe0415..c4cdd8d3 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -8,7 +8,7 @@ 새 여행 일정을 만들어 보세요! %s~%s M월 d일 - %1$s %2$d일차 입니다! + %d일차 입니다! D-%d D+%d diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt index bbe7f246..faaa6a3e 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/CurrencyCalculatorSection.kt @@ -46,8 +46,8 @@ import com.yapp.ndgl.core.ui.theme.NDGLTheme import com.yapp.ndgl.feature.travelhelper.R import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.ExchangeRateInfo import kotlinx.collections.immutable.ImmutableList -import java.time.format.DateTimeFormatter import kotlinx.collections.immutable.persistentListOf +import java.time.format.DateTimeFormatter import com.yapp.ndgl.core.ui.R as CoreR @Composable @@ -95,7 +95,7 @@ internal fun CurrencyCalculatorSection( ) Box( modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, ) { HorizontalDivider( modifier = Modifier @@ -167,7 +167,7 @@ private fun CurrencyCard( .border( width = 1.dp, color = NDGLTheme.colors.black200, - shape = RoundedCornerShape(4.dp) + shape = RoundedCornerShape(4.dp), ), verticalAlignment = Alignment.CenterVertically, ) { @@ -203,7 +203,7 @@ private fun ForeignCurrencyLeft( onCurrencySelect: (String) -> Unit, ) { var expanded by remember { mutableStateOf(false) } - + Row( modifier = modifier .background( @@ -211,11 +211,15 @@ private fun ForeignCurrencyLeft( shape = RoundedCornerShape(topStart = 4.dp, bottomStart = 4.dp), ) .then( - if (showCurrencySelector) Modifier.clickable( - interactionSource = null, - indication = ripple(), - onClick = { expanded = true }, - ) else Modifier + if (showCurrencySelector) { + Modifier.clickable( + interactionSource = null, + indication = ripple(), + onClick = { expanded = true }, + ) + } else { + Modifier + }, ) .padding(horizontal = 10.dp, vertical = 12.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), @@ -232,7 +236,7 @@ private fun ForeignCurrencyLeft( ) Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp) + verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( text = currencyName, @@ -298,7 +302,7 @@ private fun CurrencyDropdownItem( NDGLTheme.colors.green100 } else { NDGLTheme.colors.white - } + }, ) .clickable(onClick = onClick) .padding(horizontal = 4.dp, vertical = 8.dp), @@ -355,8 +359,11 @@ private fun ForeignCurrencyRight( keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), singleLine = true, cursorBrush = SolidColor(NDGLTheme.colors.green500), - visualTransformation = if (isEditable) ThousandSeparatorTransformation() - else VisualTransformation.None, + visualTransformation = if (isEditable) { + ThousandSeparatorTransformation() + } else { + VisualTransformation.None + }, decorationBox = { innerTextField -> Box(contentAlignment = Alignment.CenterEnd) { if (currencyInput.isEmpty()) { @@ -398,8 +405,11 @@ private class ThousandSeparatorTransformation : VisualTransformation { val intPart = if (dotIndex >= 0) original.substring(0, dotIndex) else original val decimalPart = if (dotIndex >= 0) original.substring(dotIndex) else "" - val formattedInt = if (intPart.isEmpty()) "" - else intPart.reversed().chunked(3).joinToString(",").reversed() + val formattedInt = if (intPart.isEmpty()) { + "" + } else { + intPart.reversed().chunked(3).joinToString(",").reversed() + } val formatted = formattedInt + decimalPart val offsetMapping = object : OffsetMapping { diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt index 2fbde6dc..71bf4ac5 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperContract.kt @@ -35,6 +35,7 @@ data class TravelHelperState( val endDate: LocalDate, val thumbnail: String?, val dDay: Long, + val days: Int, val weatherState: WeatherUiState, val exchangeRateInfo: ExchangeRateInfo, ) : TravelUiState @@ -46,7 +47,7 @@ data class TravelHelperState( val startDate: LocalDate, val endDate: LocalDate, val thumbnail: String?, - val dayCount: Long, + val dayCount: Int, val weatherState: WeatherUiState, val currentPlace: TravelPlace?, val exchangeRateInfo: ExchangeRateInfo, @@ -71,6 +72,7 @@ data class TravelHelperState( ) data class TravelPlace( + val googlePlaceId: String, val name: String, val category: PlaceCategory, val estimatedDuration: Int, @@ -99,9 +101,13 @@ sealed interface TravelHelperIntent : UiIntent { data class UpdateCurrencyInput(val input: String) : TravelHelperIntent data object SwapCurrency : TravelHelperIntent data class SelectCurrency(val currencyCode: String) : TravelHelperIntent + data class ClickTravelCard(val travelId: Long, val days: Int) : TravelHelperIntent + data class ClickPlace(val placeId: String) : TravelHelperIntent } sealed interface TravelHelperSideEffect : UiSideEffect { data object NavigateToSearch : TravelHelperSideEffect data object ShowExchangeRateError : TravelHelperSideEffect + data class NavigateToTravelDetail(val travelId: Long, val days: Int) : TravelHelperSideEffect + data class NavigateToPlaceDetail(val placeId: String) : TravelHelperSideEffect } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt index 57e4e511..5925710a 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt @@ -51,6 +51,9 @@ import com.yapp.ndgl.core.ui.R as CoreR @Composable internal fun TravelHelperRoute( navigateToSearch: () -> Unit, + navigateToTravelDetail: (Long, Int) -> Unit, + navigateToPopularTravelList: () -> Unit, + navigateToPlaceDetail: (String) -> Unit, viewModel: TravelHelperViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -64,9 +67,13 @@ internal fun TravelHelperRoute( state = state, snackbarHostState = snackbarHostState, onSearchClick = { viewModel.onIntent(TravelHelperIntent.ClickSearch) }, - onNewTravelFindClick = {}, - onTravelClick = {}, - onPlaceClick = {}, + onNewTravelFindClick = navigateToPopularTravelList, + onTravelClick = { travelId, days -> + viewModel.onIntent(TravelHelperIntent.ClickTravelCard(travelId, days)) + }, + onPlaceClick = { + viewModel.onIntent(TravelHelperIntent.ClickPlace(it)) + }, onCurrencyInputChange = { viewModel.onIntent(TravelHelperIntent.UpdateCurrencyInput(it)) }, onSwapCurrency = { viewModel.onIntent(TravelHelperIntent.SwapCurrency) }, onCurrencySelect = { viewModel.onIntent(TravelHelperIntent.SelectCurrency(it)) }, @@ -75,11 +82,17 @@ internal fun TravelHelperRoute( viewModel.collectSideEffect { sideEffect -> when (sideEffect) { TravelHelperSideEffect.NavigateToSearch -> navigateToSearch() + is TravelHelperSideEffect.NavigateToTravelDetail -> { + navigateToTravelDetail(sideEffect.travelId, sideEffect.days) + } + TravelHelperSideEffect.ShowExchangeRateError -> { coroutineScope.launch { snackbarHostState.showSnackbar(exchangeRateErrorMessage) } } + + is TravelHelperSideEffect.NavigateToPlaceDetail -> navigateToPlaceDetail(sideEffect.placeId) } } } @@ -90,8 +103,8 @@ private fun TravelHelperScreen( snackbarHostState: SnackbarHostState, onSearchClick: () -> Unit, onNewTravelFindClick: () -> Unit, - onTravelClick: (Long) -> Unit, - onPlaceClick: (Long) -> Unit, + onTravelClick: (Long, Int) -> Unit, + onPlaceClick: (String) -> Unit, onCurrencyInputChange: (String) -> Unit, onSwapCurrency: () -> Unit, onCurrencySelect: (String) -> Unit, @@ -101,7 +114,7 @@ private fun TravelHelperScreen( SnackbarHost(snackbarHostState) { data -> NDGLSnackbar( modifier = Modifier.padding(bottom = 100.dp), - snackbarData = data + snackbarData = data, ) } }, @@ -259,7 +272,7 @@ private fun LazyListScope.upcomingTravelContent( currencyInput: String, convertedAmount: Double?, availableCurrencies: ImmutableList, - onTravelClick: (Long) -> Unit, + onTravelClick: (Long, Int) -> Unit, onCurrencyInputChange: (String) -> Unit, onSwapCurrency: () -> Unit, onCurrencySelect: (String) -> Unit, @@ -268,7 +281,7 @@ private fun LazyListScope.upcomingTravelContent( UpcomingTravelCard( modifier = Modifier, travel = travel, - onCardClick = { onTravelClick(travel.id) }, + onCardClick = { onTravelClick(travel.id, travel.days) }, ) } item { @@ -292,8 +305,8 @@ private fun LazyListScope.inProgressTravelContent( currencyInput: String, convertedAmount: Double?, availableCurrencies: ImmutableList, - onTravelClick: (Long) -> Unit, - onPlaceClick: (Long) -> Unit, + onTravelClick: (Long, Int) -> Unit, + onPlaceClick: (String) -> Unit, onCurrencyInputChange: (String) -> Unit, onSwapCurrency: () -> Unit, onCurrencySelect: (String) -> Unit, @@ -301,8 +314,8 @@ private fun LazyListScope.inProgressTravelContent( item { InProgressTravelCard( travel = travel, - onCardClick = { onTravelClick(travel.id) }, - onPlaceClick = { onPlaceClick(travel.id) }, + onTravelClick = onTravelClick, + onPlaceClick = onPlaceClick, modifier = Modifier, ) } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt index 9b3c51c1..39aa2618 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt @@ -9,6 +9,7 @@ import com.yapp.ndgl.data.travel.repository.ExchangeRateRepository import com.yapp.ndgl.data.travel.repository.UserTravelRepository import com.yapp.ndgl.data.travel.repository.WeatherRepository import com.yapp.ndgl.data.travel.util.CurrencyInfoResolver +import com.yapp.ndgl.feature.travelhelper.main.TravelHelperSideEffect.NavigateToTravelDetail import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.CurrencyInfo import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.ExchangeRateInfo import com.yapp.ndgl.feature.travelhelper.main.TravelHelperState.TravelPlace @@ -54,6 +55,13 @@ class TravelHelperViewModel @Inject constructor( reduce { copy(availableCurrencies = allCurrencies) } } loadUpcomingTravel() + subscribeToTravelCreatedEvent() + } + + private fun subscribeToTravelCreatedEvent() = viewModelScope.launch { + userTravelRepository.travelCreatedEvent.collect { _ -> + loadUpcomingTravel() + } } private fun loadUpcomingTravel() { @@ -80,6 +88,7 @@ class TravelHelperViewModel @Inject constructor( startDate = travel.startDate, endDate = travel.endDate, thumbnail = travel.thumbnail, + days = travel.days, dDay = dDay, weatherState = WeatherUiState.NotAvailable, exchangeRateInfo = exchangeRateInfo, @@ -96,10 +105,11 @@ class TravelHelperViewModel @Inject constructor( startDate = travel.startDate, endDate = travel.endDate, thumbnail = travel.thumbnail, - dayCount = dayNumber, + dayCount = dayNumber.toInt(), weatherState = WeatherUiState.NotAvailable, currentPlace = place?.place?.let { TravelPlace( + googlePlaceId = it.googlePlaceId, name = it.name, category = it.category, estimatedDuration = place.estimatedDuration, @@ -118,7 +128,7 @@ class TravelHelperViewModel @Inject constructor( travelUiState = travelUiState, convertedAmount = calculateConvertedAmount( currencyInput, - exchangeRateInfo.rate + exchangeRateInfo.rate, ), ) } @@ -151,7 +161,7 @@ class TravelHelperViewModel @Inject constructor( private suspend fun buildExchangeRateInfo( topCode: String, - bottomCode: String + bottomCode: String, ): ExchangeRateInfo { val rateResult = runCatching { getCrossRate(topCode, bottomCode) } rateResult.onFailure { @@ -251,6 +261,10 @@ class TravelHelperViewModel @Inject constructor( } } + is TravelHelperIntent.ClickTravelCard -> { + postSideEffect(NavigateToTravelDetail(intent.travelId, intent.days)) + } + is TravelHelperIntent.SelectCurrency -> { val currentInfo = getExchangeRateInfo() ?: return if (intent.currencyCode == currentInfo.topCurrency.currencyCode) return @@ -267,6 +281,10 @@ class TravelHelperViewModel @Inject constructor( ) } } + + is TravelHelperIntent.ClickPlace -> { + postSideEffect(TravelHelperSideEffect.NavigateToPlaceDetail(intent.placeId)) + } } } @@ -299,5 +317,4 @@ class TravelHelperViewModel @Inject constructor( lowTempCelsius = minTemperature?.degrees?.roundToInt() ?: 0, ) } - } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt index 2bb8fb39..6c760b37 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/UpcomingTravelCardSection.kt @@ -171,13 +171,13 @@ private fun DayTag( @Composable internal fun InProgressTravelCard( travel: TravelUiState.OngoingTravel, - onCardClick: () -> Unit, - onPlaceClick: () -> Unit, + onTravelClick: (Long, Int) -> Unit, + onPlaceClick: (String) -> Unit, modifier: Modifier = Modifier, ) { CardContainer( modifier = modifier, - onCardClick = onCardClick, + onCardClick = { onTravelClick(travel.id, travel.dayCount) }, ) { Column( modifier = Modifier @@ -201,7 +201,7 @@ internal fun InProgressTravelCard( Text( text = stringResource( R.string.travel_helper_card_in_progress_day_count, - travel.dayCount + travel.dayCount, ), style = NDGLTheme.typography.subtitleMdSemiBold, color = NDGLTheme.colors.black700, @@ -235,7 +235,7 @@ internal fun InProgressTravelCard( @Composable private fun PlaceInfoCard( place: TravelPlace, - onPlaceClick: () -> Unit, + onPlaceClick: (String) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -243,7 +243,7 @@ private fun PlaceInfoCard( .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) .background(NDGLTheme.colors.white) - .clickable(onClick = onPlaceClick) + .clickable(onClick = { onPlaceClick(place.googlePlaceId) }) .padding(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, @@ -284,6 +284,8 @@ private fun PlaceInfoCard( Text( text = place.name, color = NDGLTheme.colors.black900, + maxLines = 1, + overflow = TextOverflow.Ellipsis, style = NDGLTheme.typography.bodyLgSemiBold, ) } @@ -341,6 +343,7 @@ private fun UpcomingTravelCardPreview() { endDate = LocalDate.of(2025, 2, 20), thumbnail = null, dDay = 7L, + days = 6, weatherState = TravelHelperState.WeatherUiState.NotAvailable, exchangeRateInfo = TravelHelperState.ExchangeRateInfo( topCurrency = TravelHelperState.CurrencyInfo( @@ -376,9 +379,10 @@ private fun InProgressTravelCardPreview() { startDate = LocalDate.of(2025, 2, 1), endDate = LocalDate.of(2025, 2, 10), thumbnail = null, - dayCount = 3L, + dayCount = 3, weatherState = TravelHelperState.WeatherUiState.NotAvailable, currentPlace = TravelPlace( + googlePlaceId = "", name = "인도 국제 공항", category = PlaceCategory.TRANSPORT, estimatedDuration = 60, @@ -401,7 +405,7 @@ private fun InProgressTravelCardPreview() { rateDate = LocalDate.of(2025, 1, 1), ), ), - onCardClick = {}, + onTravelClick = { _, _ -> }, onPlaceClick = {}, ) } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt index db19d459..07338c2e 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/navigation/TravelHelperEntry.kt @@ -10,6 +10,11 @@ fun EntryProviderScope.travelHelperEntry(navigator: Navigator) { entry { TravelHelperRoute( navigateToSearch = { navigator.navigate(Route.TemplateSearch) }, + navigateToTravelDetail = { travelId, days -> + navigator.navigate(Route.TravelDetail(travelId, days)) + }, + navigateToPopularTravelList = { navigator.navigate(Route.PopularTravelList) }, + navigateToPlaceDetail = { placeId -> navigator.navigate(Route.PlaceDetail(placeId)) }, ) } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt index aeff50fb..70924da7 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DatePickerDefaults.dateFormatter import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -24,6 +23,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -115,8 +115,10 @@ private fun UpcomingTravelCard( DayTag(dDay = travel.dDay) Text( text = travel.title, - style = NDGLTheme.typography.subtitleMdSemiBold, color = NDGLTheme.colors.black700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = NDGLTheme.typography.subtitleMdSemiBold, ) } Text( @@ -182,15 +184,28 @@ private fun InProgressTravelCard( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = stringResource( - R.string.my_travel_upcoming_travel_in_progress_title, - travel.title, - travel.dayCount, - ), - style = NDGLTheme.typography.subtitleMdSemiBold, - color = NDGLTheme.colors.black700, - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = travel.title, + modifier = Modifier.weight(1f, fill = false), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource( + R.string.my_travel_upcoming_travel_in_progress_day_count, + travel.dayCount, + ), + style = NDGLTheme.typography.subtitleMdSemiBold, + color = NDGLTheme.colors.black700, + maxLines = 1, + ) + } Text( text = stringResource( R.string.my_travel_upcoming_travel_travel_duration, @@ -264,6 +279,8 @@ private fun PlaceInfoCard( Text( text = place.name, color = NDGLTheme.colors.black900, + maxLines = 1, + overflow = TextOverflow.Ellipsis, style = NDGLTheme.typography.bodyLgSemiBold, ) } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt index 36f09492..def72e91 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage @@ -115,6 +116,8 @@ private fun UpcomingTravel( Text( text = travel.title, color = NDGLTheme.colors.black700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, style = NDGLTheme.typography.subtitleMdSemiBold, ) } diff --git a/feature/travel/src/main/res/values/strings.xml b/feature/travel/src/main/res/values/strings.xml index f91bd37e..375928e3 100644 --- a/feature/travel/src/main/res/values/strings.xml +++ b/feature/travel/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ 새 여행 일정을 만들어 보세요! %s~%s M월 d일 - %1$s %2$d일차 입니다! + %d일차 입니다! D-%d D+%d From 502c2abaf12f06a107c825ceabd19a3654031d1f Mon Sep 17 00:00:00 2001 From: "Jihee.Han" Date: Thu, 26 Feb 2026 04:20:03 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[NDGL-104]=20style:=20lint=20=EB=B0=8F=20de?= =?UTF-8?q?tekt=20=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/interceptor/AndroidCredentialInterceptor.kt | 4 +++- .../feature/travelhelper/main/TravelHelperScreen.kt | 3 --- .../travelhelper/main/TravelHelperViewModel.kt | 12 ++++++------ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt index 706ff560..2cdbc708 100644 --- a/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt +++ b/data/core/src/main/java/com/yapp/ndgl/data/core/interceptor/AndroidCredentialInterceptor.kt @@ -31,6 +31,8 @@ class AndroidCredentialInterceptor @Inject constructor( MessageDigest.getInstance("SHA-1") .digest(signatures?.getOrNull(0)?.toByteArray()) .joinToString("") { "%02x".format(it) } - } catch (e: Exception) { "" } + } catch (_: Exception) { + "" + } } } diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt index 5925710a..c723eca4 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperScreen.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -56,8 +55,6 @@ internal fun TravelHelperRoute( navigateToPlaceDetail: (String) -> Unit, viewModel: TravelHelperViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val state by viewModel.collectAsState() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() diff --git a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt index 39aa2618..d7bfa220 100644 --- a/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt +++ b/feature/travel-helper/src/main/java/com/yapp/ndgl/feature/travelhelper/main/TravelHelperViewModel.kt @@ -180,22 +180,22 @@ class TravelHelperViewModel @Inject constructor( return when { to == "KRW" -> { exchangeRateRepository.getKrwRate(from) - ?: throw IllegalStateException("Exchange rate not available for $from") + ?: error("Exchange rate not available for $from") } from == "KRW" -> { val toKrw = exchangeRateRepository.getKrwRate(to) - ?: throw IllegalStateException("Exchange rate not available for $to") - if (toKrw == 0.0) throw IllegalStateException("KRW rate for $to is zero") + ?: error("Exchange rate not available for $to") + check(toKrw != 0.0) { "KRW rate for $to is zero" } 1.0 / toKrw } else -> { val fromKrw = exchangeRateRepository.getKrwRate(from) - ?: throw IllegalStateException("Exchange rate not available for $from") + ?: error("Exchange rate not available for $from") val toKrw = exchangeRateRepository.getKrwRate(to) - ?: throw IllegalStateException("Exchange rate not available for $to") - if (toKrw == 0.0) throw IllegalStateException("KRW rate for $to is zero") + ?: error("Exchange rate not available for $to") + check(toKrw != 0.0) { "KRW rate for $to is zero" } fromKrw / toKrw } }