diff --git a/README.md b/README.md new file mode 100644 index 00000000..034df677 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# 핫한 공연 예매의 시작 불티 + +- 플레이 스토어 : https://play.google.com/store/apps/details?id=com.nexters.boolti +- 앱 스토어 : https://apps.apple.com/kr/app/%EB%B6%88%ED%8B%B0/id6476589322 +- 주최자용 웹 : https://boolti.in + +
+ +## Screenshots + + + + + +

+ +## Android developers + +|Android|Android| +|:---:|:---:| +|[박명범](https://github.com/mangbaam)|[송준영](https://github.com/HamBP)| +||| + +
+ +## Other repositories +- iOS Repository : https://github.com/Nexters/Boolti-iOS +- FE Repository : https://github.com/Nexters/boolti-web +- BE Repository : private diff --git a/app/.gitignore b/app/.gitignore index 42afabfd..46a46b2f 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,3 @@ -/build \ No newline at end of file +/build +*.jks +*keystore diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3547adb..15837bd0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,7 +26,20 @@ + android:theme="@style/Theme.Boolti" > + + + + + + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 25ec2843..38bbf1dd 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -64,6 +64,8 @@ dependencies { implementation(libs.bundles.network) implementation(libs.firebase.config.ktx) + implementation(libs.bundles.firebase) + implementation(platform(libs.firebase.bom)) testImplementation(libs.junit) testImplementation(libs.bundles.kotest) diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/AuthDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/AuthDataSource.kt index fe65f6eb..aaa135a3 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/AuthDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/AuthDataSource.kt @@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject -class AuthDataSource @Inject constructor( +internal class AuthDataSource @Inject constructor( private val context: Context, private val loginService: LoginService, ) { @@ -48,7 +48,11 @@ class AuthDataSource @Inject constructor( } suspend fun logout(): Result = runCatching { + localLogout() loginService.logout() + } + + suspend fun localLogout() { dataStore.updateData { it.copy( userId = null, @@ -58,7 +62,7 @@ class AuthDataSource @Inject constructor( phoneNumber = null, photo = null, accessToken = "", - refreshToken = "" + refreshToken = "", ) } } diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/DeviceTokenDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/DeviceTokenDataSource.kt new file mode 100644 index 00000000..954c96be --- /dev/null +++ b/data/src/main/java/com/nexters/boolti/data/datasource/DeviceTokenDataSource.kt @@ -0,0 +1,37 @@ +package com.nexters.boolti.data.datasource + +import com.google.android.gms.tasks.OnCompleteListener +import com.google.firebase.messaging.FirebaseMessaging +import com.nexters.boolti.data.network.api.DeviceTokenService +import com.nexters.boolti.data.network.request.DeviceTokenRequest +import java.io.IOException +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +internal class DeviceTokenDataSource @Inject constructor( + private val deviceTokenService: DeviceTokenService, +) { + suspend fun sendFcmToken(): Result = runCatching { + val response = deviceTokenService.postFcmToken( + DeviceTokenRequest(deviceToken = getFcmToken(), deviceType = "ANDROID") + ) + + if (!response.isSuccessful) throw IOException("fcm 토큰을 서버에 전송하는 데 실패했어요.") + } + + private suspend fun getFcmToken(): String = suspendCoroutine { continuation -> + val firebaseMessaging = FirebaseMessaging.getInstance() + firebaseMessaging.token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + continuation.resumeWithException(IllegalStateException("fcm 토큰을 가져오는 데 실패했어요.")) + return@OnCompleteListener + } + + val token = task.result + + continuation.resume(token) + }) + } +} diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/HostDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/HostDataSource.kt index 2bf05064..f297ac81 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/HostDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/HostDataSource.kt @@ -7,7 +7,7 @@ import com.nexters.boolti.domain.request.QrScanRequest import retrofit2.Response import javax.inject.Inject -class HostDataSource @Inject constructor( +internal class HostDataSource @Inject constructor( private val apiService: HostService, ) { suspend fun requestEntrance(request: QrScanRequest): Response = apiService.requestEntrance(request) diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/PolicyDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/PolicyDataSource.kt index e56a8a18..7311b486 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/PolicyDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/PolicyDataSource.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject -class PolicyDataSource @Inject constructor( +internal class PolicyDataSource @Inject constructor( private val context: Context, ) { private val dataStore: DataStore diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/RemoteConfigDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/RemoteConfigDataSource.kt index 6dcfa1f0..b848cebc 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/RemoteConfigDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/RemoteConfigDataSource.kt @@ -7,7 +7,7 @@ import timber.log.Timber import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine -class RemoteConfigDataSource( +internal class RemoteConfigDataSource( private val remoteConfig: FirebaseRemoteConfig, ) { diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/ReservationDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/ReservationDataSource.kt index 54d9d5bd..3d586b24 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/ReservationDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/ReservationDataSource.kt @@ -6,7 +6,7 @@ import com.nexters.boolti.data.network.response.ReservationResponse import com.nexters.boolti.domain.request.RefundRequest import javax.inject.Inject -class ReservationDataSource @Inject constructor( +internal class ReservationDataSource @Inject constructor( private val reservationService: ReservationService, ) { suspend fun getReservations(): List = reservationService.getReservations() @@ -15,4 +15,4 @@ class ReservationDataSource @Inject constructor( reservationService.findReservationById(id) suspend fun refund(request: RefundRequest) = reservationService.refund(request) -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/ShowDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/ShowDataSource.kt index 569d8c44..4ba15b99 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/ShowDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/ShowDataSource.kt @@ -5,7 +5,7 @@ import com.nexters.boolti.data.network.response.ShowDetailResponse import com.nexters.boolti.data.network.response.ShowResponse import javax.inject.Inject -class ShowDataSource @Inject constructor( +internal class ShowDataSource @Inject constructor( private val showService: ShowService, ) { suspend fun search(keyword: String): Result> = runCatching { @@ -15,4 +15,4 @@ class ShowDataSource @Inject constructor( suspend fun findShowById(id: String): Result = runCatching { showService.findShowById(id) } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/SignUpDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/SignUpDataSource.kt index 2a115338..116dc041 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/SignUpDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/SignUpDataSource.kt @@ -5,10 +5,10 @@ import com.nexters.boolti.data.network.response.SignUpResponse import com.nexters.boolti.domain.request.SignUpRequest import javax.inject.Inject -class SignUpDataSource @Inject constructor( +internal class SignUpDataSource @Inject constructor( private val signUpService: SignUpService, ) { suspend fun signUp(signUpRequest: SignUpRequest): Result = runCatching { signUpService.signup(signUpRequest) } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/TicketDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/TicketDataSource.kt index d78aaa9c..82380852 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/TicketDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/TicketDataSource.kt @@ -4,7 +4,7 @@ import com.nexters.boolti.data.network.api.TicketService import com.nexters.boolti.domain.model.Ticket import javax.inject.Inject -class TicketDataSource @Inject constructor( +internal class TicketDataSource @Inject constructor( private val apiService: TicketService, ) { suspend fun getTickets(): List = apiService.getTickets().map { it.toDomain() } diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/TicketingDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/TicketingDataSource.kt index 4b7b79dd..efffac41 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/TicketingDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/TicketingDataSource.kt @@ -12,7 +12,7 @@ import com.nexters.boolti.domain.request.TicketingInfoRequest import retrofit2.Response import javax.inject.Inject -class TicketingDataSource @Inject constructor( +internal class TicketingDataSource @Inject constructor( private val ticketingService: TicketingService, ) { suspend fun getSalesTickets(request: SalesTicketRequest): List { diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/TokenDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/TokenDataSource.kt index d5a1ec88..996dfe9e 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/TokenDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/TokenDataSource.kt @@ -6,11 +6,9 @@ import com.nexters.boolti.data.db.AppSettings import com.nexters.boolti.data.db.dataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.single -import kotlinx.coroutines.runBlocking import javax.inject.Inject -class TokenDataSource @Inject constructor( +internal class TokenDataSource @Inject constructor( private val context: Context, ) { private val dataStore: DataStore diff --git a/data/src/main/java/com/nexters/boolti/data/datasource/UserDataSource.kt b/data/src/main/java/com/nexters/boolti/data/datasource/UserDataSource.kt index 112db81a..4073dcd9 100644 --- a/data/src/main/java/com/nexters/boolti/data/datasource/UserDataSource.kt +++ b/data/src/main/java/com/nexters/boolti/data/datasource/UserDataSource.kt @@ -2,10 +2,12 @@ package com.nexters.boolti.data.datasource import com.nexters.boolti.data.network.api.UserService import com.nexters.boolti.data.network.response.UserResponse +import com.nexters.boolti.domain.request.SignoutRequest import javax.inject.Inject -class UserDataSource @Inject constructor( +internal class UserDataSource @Inject constructor( private val userService: UserService, ) { suspend fun getUser(): UserResponse = userService.getUser() -} \ No newline at end of file + suspend fun signout(request: SignoutRequest) = userService.signout(request) +} diff --git a/data/src/main/java/com/nexters/boolti/data/db/AppSettings.kt b/data/src/main/java/com/nexters/boolti/data/db/AppSettings.kt index 490f782f..149ff4fd 100644 --- a/data/src/main/java/com/nexters/boolti/data/db/AppSettings.kt +++ b/data/src/main/java/com/nexters/boolti/data/db/AppSettings.kt @@ -8,7 +8,7 @@ import java.io.InputStream import java.io.OutputStream @Serializable -data class AppSettings( +internal data class AppSettings( val userId: String? = null, val loginType: String? = null, val nickname: String? = null, @@ -20,7 +20,7 @@ data class AppSettings( val refundPolicy: List = emptyList(), ) -object AppSettingsSerializer : Serializer { +internal object AppSettingsSerializer : Serializer { override val defaultValue: AppSettings = AppSettings() diff --git a/data/src/main/java/com/nexters/boolti/data/db/DataStore.kt b/data/src/main/java/com/nexters/boolti/data/db/DataStore.kt index 5d905780..b21a8d2a 100644 --- a/data/src/main/java/com/nexters/boolti/data/db/DataStore.kt +++ b/data/src/main/java/com/nexters/boolti/data/db/DataStore.kt @@ -4,7 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.dataStore -val Context.dataStore: DataStore by dataStore( +internal val Context.dataStore: DataStore by dataStore( "app-settings.json", AppSettingsSerializer, ) diff --git a/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt b/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt index 235e56f2..fe59d845 100644 --- a/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt +++ b/data/src/main/java/com/nexters/boolti/data/di/DataSourceModule.kt @@ -16,7 +16,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -object DataSourceModule { +internal object DataSourceModule { @Singleton @Provides fun provideRemoteConfigDataSource(remoteConfig: FirebaseRemoteConfig): RemoteConfigDataSource = diff --git a/data/src/main/java/com/nexters/boolti/data/di/FirebaseModule.kt b/data/src/main/java/com/nexters/boolti/data/di/FirebaseModule.kt index a9e2a65d..27463b05 100644 --- a/data/src/main/java/com/nexters/boolti/data/di/FirebaseModule.kt +++ b/data/src/main/java/com/nexters/boolti/data/di/FirebaseModule.kt @@ -11,7 +11,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -object FirebaseModule { +internal object FirebaseModule { @Singleton @Provides fun provideRemoteConfig(): FirebaseRemoteConfig { diff --git a/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt b/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt index 8b3d4b39..595d7f12 100644 --- a/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt +++ b/data/src/main/java/com/nexters/boolti/data/di/NetworkModule.kt @@ -7,6 +7,7 @@ import com.nexters.boolti.data.network.api.LoginService import com.nexters.boolti.data.network.AuthAuthenticator import com.nexters.boolti.data.datasource.AuthDataSource import com.nexters.boolti.data.network.AuthInterceptor +import com.nexters.boolti.data.network.api.DeviceTokenService import com.nexters.boolti.data.network.api.HostService import com.nexters.boolti.data.network.api.ReservationService import com.nexters.boolti.data.network.api.ShowService @@ -30,7 +31,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) @Module -object NetworkModule { +internal object NetworkModule { @Singleton @Provides @Named("auth") @@ -77,6 +78,11 @@ object NetworkModule { @Provides fun provideUserService(@Named("auth") retrofit: Retrofit): UserService = retrofit.create() + @Singleton + @Provides + fun provideDeviceTokenService(@Named("auth") retrofit: Retrofit): DeviceTokenService = + retrofit.create() + @Singleton @Provides fun provideSignUpService(retrofit: Retrofit): SignUpService = retrofit.create() diff --git a/data/src/main/java/com/nexters/boolti/data/di/RepositoryModule.kt b/data/src/main/java/com/nexters/boolti/data/di/RepositoryModule.kt index 33646dc5..dba19763 100644 --- a/data/src/main/java/com/nexters/boolti/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/nexters/boolti/data/di/RepositoryModule.kt @@ -21,7 +21,7 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) @Module -abstract class RepositoryModule { +internal abstract class RepositoryModule { @Binds abstract fun bindConfigRepository(repository: ConfigRepositoryImpl): ConfigRepository diff --git a/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt b/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt index d1e736e0..8ae8e232 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/AuthAuthenticator.kt @@ -9,7 +9,7 @@ import okhttp3.Response import okhttp3.Route import javax.inject.Inject -class AuthAuthenticator @Inject constructor( +internal class AuthAuthenticator @Inject constructor( private val tokenDataSource: TokenDataSource, private val authDataSource: AuthDataSource, ) : Authenticator { @@ -28,6 +28,9 @@ class AuthAuthenticator @Inject constructor( refreshToken = it.refreshToken, ) it.accessToken + } ?: run { + authDataSource.logout() + null } } } diff --git a/data/src/main/java/com/nexters/boolti/data/network/AuthInterceptor.kt b/data/src/main/java/com/nexters/boolti/data/network/AuthInterceptor.kt index 22e27439..4275709e 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/AuthInterceptor.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/AuthInterceptor.kt @@ -6,7 +6,7 @@ import okhttp3.Interceptor import okhttp3.Response import javax.inject.Inject -class AuthInterceptor @Inject constructor( +internal class AuthInterceptor @Inject constructor( private val tokenDataSource: TokenDataSource, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/DeviceTokenService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/DeviceTokenService.kt new file mode 100644 index 00000000..78e4c6f3 --- /dev/null +++ b/data/src/main/java/com/nexters/boolti/data/network/api/DeviceTokenService.kt @@ -0,0 +1,13 @@ +package com.nexters.boolti.data.network.api + + +import com.nexters.boolti.data.network.request.DeviceTokenRequest +import com.nexters.boolti.data.network.response.DeviceTokenResponse +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +internal interface DeviceTokenService { + @POST("/app/papi/v1/device-token") + suspend fun postFcmToken(@Body request: DeviceTokenRequest): Response +} diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/HostService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/HostService.kt index a3990c39..cb2b6fa7 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/HostService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/HostService.kt @@ -10,7 +10,7 @@ import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path -interface HostService { +internal interface HostService { @GET("/app/api/v1/host/shows") suspend fun getHostedShows(): List diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/LoginService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/LoginService.kt index 039a7778..5a85eb7d 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/LoginService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/LoginService.kt @@ -8,7 +8,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST -interface LoginService { +internal interface LoginService { @POST("/app/papi/v1/login/kakao") suspend fun kakaoLogin(@Body request: LoginRequest): LoginResponse diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/ReservationService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/ReservationService.kt index 8ba95c3b..7e594e98 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/ReservationService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/ReservationService.kt @@ -8,7 +8,7 @@ import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.Path -interface ReservationService { +internal interface ReservationService { @GET("/app/api/v1/reservations") suspend fun getReservations(): List @@ -17,4 +17,4 @@ interface ReservationService { @PATCH("/app/api/v1/reservation/refund") suspend fun refund(@Body request: RefundRequest): Boolean -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/ShowService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/ShowService.kt index 81c5e54e..81025cf3 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/ShowService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/ShowService.kt @@ -6,7 +6,7 @@ import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query -interface ShowService { +internal interface ShowService { @GET("/app/papi/v1/shows/search") suspend fun search(@Query("nameLike") keyword: String): List diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/SignUpService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/SignUpService.kt index 15977cb0..08989e8f 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/SignUpService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/SignUpService.kt @@ -5,7 +5,7 @@ import com.nexters.boolti.domain.request.SignUpRequest import retrofit2.http.Body import retrofit2.http.POST -interface SignUpService { +internal interface SignUpService { @POST("/app/papi/v1/signup/sns") suspend fun signup(@Body request: SignUpRequest): SignUpResponse -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/TicketService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/TicketService.kt index 901b0e70..a2ee4827 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/TicketService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/TicketService.kt @@ -5,7 +5,7 @@ import com.nexters.boolti.data.network.response.TicketDto import retrofit2.http.GET import retrofit2.http.Path -interface TicketService { +internal interface TicketService { @GET("/app/api/v1/tickets") suspend fun getTickets(): List diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/TicketingService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/TicketingService.kt index b9466904..081d3eab 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/TicketingService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/TicketingService.kt @@ -13,7 +13,7 @@ import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query -interface TicketingService { +internal interface TicketingService { @GET("app/api/v1/sales-ticket-type/{showId}") suspend fun getSalesTickets( @Path("showId") showId: String, diff --git a/data/src/main/java/com/nexters/boolti/data/network/api/UserService.kt b/data/src/main/java/com/nexters/boolti/data/network/api/UserService.kt index 98e734f1..8cfc7032 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/api/UserService.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/api/UserService.kt @@ -1,9 +1,17 @@ package com.nexters.boolti.data.network.api import com.nexters.boolti.data.network.response.UserResponse +import com.nexters.boolti.domain.request.SignoutRequest +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.HTTP -interface UserService { +internal interface UserService { @GET("/app/api/v1/user") suspend fun getUser(): UserResponse -} \ No newline at end of file + + @HTTP(method = "DELETE", path = "/app/api/v1/user", hasBody = true) + suspend fun signout( + @Body request: SignoutRequest, + ) +} diff --git a/data/src/main/java/com/nexters/boolti/data/network/request/DeviceTokenRequest.kt b/data/src/main/java/com/nexters/boolti/data/network/request/DeviceTokenRequest.kt new file mode 100644 index 00000000..172135f5 --- /dev/null +++ b/data/src/main/java/com/nexters/boolti/data/network/request/DeviceTokenRequest.kt @@ -0,0 +1,9 @@ +package com.nexters.boolti.data.network.request + +import kotlinx.serialization.Serializable + +@Serializable +internal data class DeviceTokenRequest( + val deviceToken: String, + val deviceType: String, +) diff --git a/data/src/main/java/com/nexters/boolti/data/network/request/RefreshRequest.kt b/data/src/main/java/com/nexters/boolti/data/network/request/RefreshRequest.kt index c88bc120..f23d2bef 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/request/RefreshRequest.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/request/RefreshRequest.kt @@ -3,6 +3,6 @@ package com.nexters.boolti.data.network.request import kotlinx.serialization.Serializable @Serializable -data class RefreshRequest( +internal data class RefreshRequest( val refreshToken: String, ) diff --git a/data/src/main/java/com/nexters/boolti/data/network/request/ReservationInviteTicketRequest.kt b/data/src/main/java/com/nexters/boolti/data/network/request/ReservationInviteTicketRequest.kt index 36353fd0..ae9f1495 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/request/ReservationInviteTicketRequest.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/request/ReservationInviteTicketRequest.kt @@ -4,7 +4,7 @@ import com.nexters.boolti.domain.request.TicketingRequest import kotlinx.serialization.Serializable @Serializable -data class ReservationInviteTicketRequest( +internal data class ReservationInviteTicketRequest( val userId: String, val showId: String, val salesTicketTypeId: String, @@ -13,7 +13,7 @@ data class ReservationInviteTicketRequest( val inviteCode: String, ) -fun TicketingRequest.Invite.toData(): ReservationInviteTicketRequest { +internal fun TicketingRequest.Invite.toData(): ReservationInviteTicketRequest { return ReservationInviteTicketRequest( userId = userId, showId = showId, diff --git a/data/src/main/java/com/nexters/boolti/data/network/request/ReservationSalesTicketRequest.kt b/data/src/main/java/com/nexters/boolti/data/network/request/ReservationSalesTicketRequest.kt index fad0b578..6a9b57d1 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/request/ReservationSalesTicketRequest.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/request/ReservationSalesTicketRequest.kt @@ -5,7 +5,7 @@ import com.nexters.boolti.domain.request.TicketingRequest import kotlinx.serialization.Serializable @Serializable -data class ReservationSalesTicketRequest( +internal data class ReservationSalesTicketRequest( val userId: String, val showId: String, val salesTicketTypeId: String, @@ -18,7 +18,7 @@ data class ReservationSalesTicketRequest( val means: String, ) -fun TicketingRequest.Normal.toData(): ReservationSalesTicketRequest = ReservationSalesTicketRequest( +internal fun TicketingRequest.Normal.toData(): ReservationSalesTicketRequest = ReservationSalesTicketRequest( userId = userId, showId = showId, salesTicketTypeId = salesTicketTypeId, diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/CheckInviteCodeResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/CheckInviteCodeResponse.kt index 68a1db8c..8cc8f994 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/CheckInviteCodeResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/CheckInviteCodeResponse.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class CheckInviteCodeResponse( +internal data class CheckInviteCodeResponse( @SerialName("id") val id: String, @SerialName("code") val inviteCode: String, @SerialName("isUsed") val isUsed: Boolean, diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/DeviceTokenResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/DeviceTokenResponse.kt new file mode 100644 index 00000000..ac450aed --- /dev/null +++ b/data/src/main/java/com/nexters/boolti/data/network/response/DeviceTokenResponse.kt @@ -0,0 +1,8 @@ +package com.nexters.boolti.data.network.response + +import kotlinx.serialization.Serializable + +@Serializable +internal data class DeviceTokenResponse( + val tokenId: String +) diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/HostedShowDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/HostedShowDto.kt index f7251d8b..29708fe8 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/HostedShowDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/HostedShowDto.kt @@ -7,7 +7,7 @@ import java.time.LocalDate import java.time.LocalDateTime @Serializable -data class HostedShowDto( +internal data class HostedShowDto( @SerialName("showId") val showId: String, @SerialName("showName") val showName: String, ) { diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ImageResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ImageResponse.kt index 73486a74..3d4b2b51 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ImageResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ImageResponse.kt @@ -4,7 +4,7 @@ import com.nexters.boolti.domain.model.ImagePair import kotlinx.serialization.Serializable @Serializable -data class ImageResponse( +internal data class ImageResponse( val id: String, val path: String, val thumbnailPath: String, @@ -19,7 +19,7 @@ data class ImageResponse( } } -fun List.toDomains(): List { +internal fun List.toDomains(): List { return this.asSequence() .sortedBy { it.sequence } .map { it.toDomain() } diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/LoginResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/LoginResponse.kt index 73c7eab5..87ea0edb 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/LoginResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/LoginResponse.kt @@ -1,11 +1,18 @@ package com.nexters.boolti.data.network.response +import com.nexters.boolti.domain.model.LoginUserState import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class LoginResponse( - @SerialName("signUpRequired") val signUpRequired: Boolean, +internal data class LoginResponse( + @SerialName("signUpRequired") val signUpRequired: Boolean = false, + @SerialName("removeCancelled") val signOutCancelled: Boolean = false, @SerialName("accessToken") val accessToken: String?, @SerialName("refreshToken") val refreshToken: String?, -) +) { + fun toDomain(): LoginUserState = LoginUserState( + signUpRequired = signUpRequired, + signOutCancelled = signOutCancelled, + ) +} diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ManagerCodeDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ManagerCodeDto.kt index c23a3703..0a41d27d 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ManagerCodeDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ManagerCodeDto.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class ManagerCodeDto( +internal data class ManagerCodeDto( @SerialName("managerCode") val code: String, ) { fun toDomain(): String = code diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDetailResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDetailResponse.kt index e9d98d63..8ae71aba 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDetailResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDetailResponse.kt @@ -7,10 +7,11 @@ import com.nexters.boolti.domain.model.ReservationDetail import kotlinx.serialization.Serializable @Serializable -data class ReservationDetailResponse( +internal data class ReservationDetailResponse( val reservationId: String, val showImg: String, val showName: String, + val showDate: String, val salesTicketName: String, val salesTicketType: String, val ticketCount: Int, @@ -26,12 +27,14 @@ data class ReservationDetailResponse( val reservationPhoneNumber: String, val depositorName: String = "", val depositorPhoneNumber: String = "", + val csReservationId: String, ) { fun toDomain(): ReservationDetail { return ReservationDetail( id = reservationId, showImage = showImg, showName = showName, + showDate = showDate.toLocalDateTime(), ticketName = salesTicketName, isInviteTicket = salesTicketType == "INVITE", ticketCount = ticketCount, @@ -47,6 +50,7 @@ data class ReservationDetailResponse( ticketHolderPhoneNumber = reservationPhoneNumber, depositorName = depositorName, depositorPhoneNumber = depositorPhoneNumber, + csReservationId = csReservationId, ) } } diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDto.kt index 3cfbf9c0..c236e5d2 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ReservationDto.kt @@ -3,6 +3,6 @@ package com.nexters.boolti.data.network.response import kotlinx.serialization.Serializable @Serializable -data class ReservationDto( +internal data class ReservationDto( val reservationId: String, ) diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ReservationResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ReservationResponse.kt index 35dfd656..8cb48216 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ReservationResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ReservationResponse.kt @@ -6,7 +6,7 @@ import com.nexters.boolti.domain.model.Reservation import kotlinx.serialization.Serializable @Serializable -data class ReservationResponse( +internal data class ReservationResponse( val reservationId: String, val reservationStatus: String, val reservationDate: String, @@ -30,4 +30,4 @@ data class ReservationResponse( } } -fun List.toDomains(): List = this.map { it.toDomain() } \ No newline at end of file +internal fun List.toDomains(): List = this.map { it.toDomain() } diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/SalesTicketTypesDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/SalesTicketTypesDto.kt index ba018287..94aa8fde 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/SalesTicketTypesDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/SalesTicketTypesDto.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SalesTicketTypeDto( +internal data class SalesTicketTypeDto( @SerialName("id") val id: String, @SerialName("showId") val showId: String, @SerialName("ticketType") val ticketType: String, diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ShowDetailResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ShowDetailResponse.kt index a530aef0..3f4e6de3 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ShowDetailResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ShowDetailResponse.kt @@ -6,7 +6,7 @@ import com.nexters.boolti.domain.model.ShowDetail import kotlinx.serialization.Serializable @Serializable -data class ShowDetailResponse( +internal data class ShowDetailResponse( val id: String, val name: String, val placeName: String, @@ -40,4 +40,4 @@ data class ShowDetailResponse( isReserved = reservationStatus, ) } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/ShowResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/ShowResponse.kt index 20946e9b..f832d905 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/ShowResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/ShowResponse.kt @@ -6,7 +6,7 @@ import com.nexters.boolti.domain.model.Show import kotlinx.serialization.Serializable @Serializable -data class ShowResponse( +internal data class ShowResponse( val id: String, val name: String, val date: String, @@ -26,4 +26,4 @@ data class ShowResponse( } } -fun List.toDomains(): List = this.map { it.toDomain() } +internal fun List.toDomains(): List = this.map { it.toDomain() } diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/SignUpResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/SignUpResponse.kt index a451e04a..b597ab04 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/SignUpResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/SignUpResponse.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class SignUpResponse( +internal data class SignUpResponse( @SerialName("accessToken") val accessToken: String, @SerialName("refreshToken") val refreshToken: String, ) diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/TicketDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/TicketDto.kt index 52353f60..05529d50 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/TicketDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/TicketDto.kt @@ -7,7 +7,7 @@ import java.time.LocalDateTime import java.time.format.DateTimeFormatter @Serializable -data class TicketDto( +internal data class TicketDto( val userId: String, val ticketId: String, val showName: String, @@ -18,6 +18,8 @@ data class TicketDto( val ticketName: String, val entryCode: String, val usedAt: String? = null, // 사용되지 않았을 때 null + val ticketCreatedAt: String, + val csTicketId: String, ) { fun toDomain(): Ticket = Ticket( userId = userId, @@ -30,6 +32,8 @@ data class TicketDto( placeName = placeName, entryCode = entryCode, usedAt = usedAt?.toLocalDateTime(), + csReservationId = "", // TODO 이거 빈 값으로 둬도 되는지 확인 필요 + csTicketId = csTicketId, ) } @@ -54,6 +58,8 @@ data class TicketDetailDto( val usedAt: String? = null, val hostName: String = "", val hostPhoneNumber: String = "", + val csReservationId: String = "", + val csTicketId: String = "", ) { fun toDomain(): Ticket = Ticket( userId = userId, @@ -75,5 +81,7 @@ data class TicketDetailDto( usedAt = usedAt?.toLocalDateTime(), hostName = hostName, hostPhoneNumber = hostPhoneNumber, + csReservationId = csReservationId, + csTicketId = csTicketId, ) } diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/TicketingInfoDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/TicketingInfoDto.kt index 391c3a9c..6e2f8464 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/TicketingInfoDto.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/TicketingInfoDto.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class TicketingInfoDto( +internal data class TicketingInfoDto( @SerialName("meansType") val meansType: String, @SerialName("salesTicketType") diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/UserResponse.kt b/data/src/main/java/com/nexters/boolti/data/network/response/UserResponse.kt index acb8968c..d5182e35 100644 --- a/data/src/main/java/com/nexters/boolti/data/network/response/UserResponse.kt +++ b/data/src/main/java/com/nexters/boolti/data/network/response/UserResponse.kt @@ -4,7 +4,7 @@ import com.nexters.boolti.domain.model.User import kotlinx.serialization.Serializable @Serializable -data class UserResponse( +internal data class UserResponse( val id: String, val nickname: String? = null, val email: String? = null, diff --git a/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt index 1d37e778..08dd4869 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/AuthRepositoryImpl.kt @@ -1,23 +1,28 @@ package com.nexters.boolti.data.repository import com.nexters.boolti.data.datasource.AuthDataSource +import com.nexters.boolti.data.datasource.DeviceTokenDataSource import com.nexters.boolti.data.datasource.SignUpDataSource import com.nexters.boolti.data.datasource.TokenDataSource import com.nexters.boolti.data.datasource.UserDataSource +import com.nexters.boolti.data.network.response.LoginResponse +import com.nexters.boolti.domain.model.LoginUserState import com.nexters.boolti.domain.model.User import com.nexters.boolti.domain.repository.AuthRepository import com.nexters.boolti.domain.request.LoginRequest import com.nexters.boolti.domain.request.SignUpRequest +import com.nexters.boolti.domain.request.SignoutRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import javax.inject.Inject -class AuthRepositoryImpl @Inject constructor( +internal class AuthRepositoryImpl @Inject constructor( private val authDataSource: AuthDataSource, private val tokenDataSource: TokenDataSource, private val signUpDataSource: SignUpDataSource, private val userDateSource: UserDataSource, + private val deviceTokenDataSource: DeviceTokenDataSource, ) : AuthRepository { override val loggedIn: Flow get() = authDataSource.loggedIn @@ -25,24 +30,26 @@ class AuthRepositoryImpl @Inject constructor( override val cachedUser: Flow get() = authDataSource.user.map { it?.toDomain() } - override suspend fun kakaoLogin(request: LoginRequest): Result { - return authDataSource.login(request) - .onSuccess { response -> - tokenDataSource.saveTokens(response.accessToken ?: "", response.refreshToken ?: "") - } - .mapCatching { - !it.signUpRequired - } + override suspend fun kakaoLogin(request: LoginRequest): Result { + return authDataSource.login(request).onSuccess { response -> + tokenDataSource.saveTokens(response.accessToken ?: "", response.refreshToken ?: "") + deviceTokenDataSource.sendFcmToken() + }.mapCatching(LoginResponse::toDomain) } override suspend fun logout(): Result = authDataSource.logout() override suspend fun signUp(signUpRequest: SignUpRequest): Result { - return signUpDataSource.signUp(signUpRequest) - .onSuccess { response -> - tokenDataSource.saveTokens(response.accessToken, response.refreshToken) - } - .mapCatching { } + return signUpDataSource.signUp(signUpRequest).onSuccess { response -> + tokenDataSource.saveTokens(response.accessToken, response.refreshToken) + deviceTokenDataSource.sendFcmToken() + }.mapCatching { } + } + + override suspend fun signout(request: SignoutRequest): Result = runCatching { + userDateSource.signout(request) + }.onSuccess { + authDataSource.localLogout() } override fun getUserAndCache(): Flow = flow { @@ -50,4 +57,6 @@ class AuthRepositoryImpl @Inject constructor( authDataSource.updateUser(response) emit(response.toDomain()) } + + override suspend fun sendFcmToken(): Result = deviceTokenDataSource.sendFcmToken() } diff --git a/data/src/main/java/com/nexters/boolti/data/repository/ConfigRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/ConfigRepositoryImpl.kt index f32312f8..50c0b20d 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/ConfigRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/ConfigRepositoryImpl.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.flow import timber.log.Timber import javax.inject.Inject -class ConfigRepositoryImpl @Inject constructor( +internal class ConfigRepositoryImpl @Inject constructor( private val remoteConfigDataSource: RemoteConfigDataSource, private val policyDataSource: PolicyDataSource, ) : ConfigRepository { diff --git a/data/src/main/java/com/nexters/boolti/data/repository/HostRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/HostRepositoryImpl.kt index 59a696d3..e39f6a4c 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/HostRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/HostRepositoryImpl.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject -class HostRepositoryImpl @Inject constructor( +internal class HostRepositoryImpl @Inject constructor( private val dataSource: HostDataSource, ) : HostRepository { override fun requestEntrance(request: QrScanRequest): Flow = flow { diff --git a/data/src/main/java/com/nexters/boolti/data/repository/ReservationRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/ReservationRepositoryImpl.kt index 6709f9a1..19673df2 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/ReservationRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/ReservationRepositoryImpl.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject -class ReservationRepositoryImpl @Inject constructor( +internal class ReservationRepositoryImpl @Inject constructor( private val reservationDataSource: ReservationDataSource, ) : ReservationRepository { override fun getReservations(): Flow> = flow { @@ -26,7 +26,7 @@ class ReservationRepositoryImpl @Inject constructor( if (isSuccessful) { emit(Unit) } else { - throw RuntimeException("환불 실패") + throw RuntimeException("취소 실패") } } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/repository/ShowRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/ShowRepositoryImpl.kt index d1042752..dd276245 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/ShowRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/ShowRepositoryImpl.kt @@ -7,7 +7,7 @@ import com.nexters.boolti.domain.model.ShowDetail import com.nexters.boolti.domain.repository.ShowRepository import javax.inject.Inject -class ShowRepositoryImpl @Inject constructor( +internal class ShowRepositoryImpl @Inject constructor( private val showDateSource: ShowDataSource, ) : ShowRepository { override suspend fun search(keyword: String): Result> { @@ -21,4 +21,4 @@ class ShowRepositoryImpl @Inject constructor( it.toDomain() } } -} \ No newline at end of file +} diff --git a/data/src/main/java/com/nexters/boolti/data/repository/TicketRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/TicketRepositoryImpl.kt index da69674b..f5828026 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/TicketRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/TicketRepositoryImpl.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject -class TicketRepositoryImpl @Inject constructor( +internal class TicketRepositoryImpl @Inject constructor( private val dataSource: TicketDataSource, private val hostDataSource: HostDataSource, ) : TicketRepository { diff --git a/data/src/main/java/com/nexters/boolti/data/repository/TicketingRepositoryImpl.kt b/data/src/main/java/com/nexters/boolti/data/repository/TicketingRepositoryImpl.kt index 75c3c66f..eeb65fa1 100644 --- a/data/src/main/java/com/nexters/boolti/data/repository/TicketingRepositoryImpl.kt +++ b/data/src/main/java/com/nexters/boolti/data/repository/TicketingRepositoryImpl.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject -class TicketingRepositoryImpl @Inject constructor( +internal class TicketingRepositoryImpl @Inject constructor( private val dataSource: TicketingDataSource, private val reservationDataSource: ReservationDataSource, ) : TicketingRepository { @@ -50,7 +50,6 @@ class TicketingRepositoryImpl @Inject constructor( val errMsg = response.errorBody()?.string() val status = InviteCodeStatus.fromString(errMsg?.errorType) emit(status) - throw InviteCodeException(status) } } diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/LoginUserState.kt b/domain/src/main/java/com/nexters/boolti/domain/model/LoginUserState.kt new file mode 100644 index 00000000..5d00c6a2 --- /dev/null +++ b/domain/src/main/java/com/nexters/boolti/domain/model/LoginUserState.kt @@ -0,0 +1,6 @@ +package com.nexters.boolti.domain.model + +data class LoginUserState( + val signUpRequired: Boolean = false, + val signOutCancelled: Boolean = false, +) diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/ReservationDetail.kt b/domain/src/main/java/com/nexters/boolti/domain/model/ReservationDetail.kt index 2504da9a..1325e773 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/model/ReservationDetail.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/model/ReservationDetail.kt @@ -6,6 +6,7 @@ data class ReservationDetail( val id: String, val showImage: String, val showName: String, + val showDate: LocalDateTime, val ticketName: String, val isInviteTicket: Boolean, val ticketCount: Int, @@ -21,4 +22,5 @@ data class ReservationDetail( val ticketHolderPhoneNumber: String, val depositorName: String, val depositorPhoneNumber: String, + val csReservationId: String, ) diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt b/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt index 4d33287a..ed87dcd1 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/model/Ticket.kt @@ -43,14 +43,15 @@ data class Ticket( val usedAt: LocalDateTime? = null, val hostName: String = "", val hostPhoneNumber: String = "", + val csReservationId: String = "", + val csTicketId: String = "", ) { val ticketState: TicketState get() = run { val now = LocalDateTime.now() when { + usedAt != null && now > usedAt -> TicketState.Used now.toLocalDate() > showDate.toLocalDate() -> TicketState.Finished - usedAt == null -> TicketState.Ready - now > usedAt -> TicketState.Used else -> TicketState.Ready } } diff --git a/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt b/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt index 2f6f1e9c..cd827031 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/repository/AuthRepository.kt @@ -1,8 +1,10 @@ package com.nexters.boolti.domain.repository +import com.nexters.boolti.domain.model.LoginUserState import com.nexters.boolti.domain.model.User import com.nexters.boolti.domain.request.LoginRequest import com.nexters.boolti.domain.request.SignUpRequest +import com.nexters.boolti.domain.request.SignoutRequest import kotlinx.coroutines.flow.Flow interface AuthRepository { @@ -12,12 +14,14 @@ interface AuthRepository { * 잘못된 idToken 등의 사유로 로그인에 실패한 경우 400 에러 발생 * * @param request idToken 은 Kakao 로그인 성공 시 내려오는 token - * @return true 면 로그인 가능, false 면 회원가입 필요 + * @return [LoginUserState] 회원가입 여부, 탈퇴 후 재로그인 여부 */ - suspend fun kakaoLogin(request: LoginRequest): Result + suspend fun kakaoLogin(request: LoginRequest): Result suspend fun logout(): Result suspend fun signUp(signUpRequest: SignUpRequest): Result + suspend fun signout(request: SignoutRequest): Result fun getUserAndCache(): Flow + suspend fun sendFcmToken(): Result val loggedIn: Flow val cachedUser: Flow diff --git a/domain/src/main/java/com/nexters/boolti/domain/request/SignoutRequest.kt b/domain/src/main/java/com/nexters/boolti/domain/request/SignoutRequest.kt new file mode 100644 index 00000000..9027447a --- /dev/null +++ b/domain/src/main/java/com/nexters/boolti/domain/request/SignoutRequest.kt @@ -0,0 +1,8 @@ +package com.nexters.boolti.domain.request + +import kotlinx.serialization.Serializable + +@Serializable +data class SignoutRequest( + val reason: String, +) diff --git a/domain/src/main/java/com/nexters/boolti/domain/usecase/IsLoggedInUseCase.kt b/domain/src/main/java/com/nexters/boolti/domain/usecase/IsLoggedInUseCase.kt deleted file mode 100644 index 86ad9ab6..00000000 --- a/domain/src/main/java/com/nexters/boolti/domain/usecase/IsLoggedInUseCase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.nexters.boolti.domain.usecase - -import com.nexters.boolti.domain.repository.AuthRepository -import kotlinx.coroutines.flow.Flow -import javax.inject.Inject - -class IsLoggedInUseCase @Inject constructor( - private val authRepository: AuthRepository, -) { - operator fun invoke(): Flow = authRepository.loggedIn -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5636d4f..ef73023a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] minSdk = "26" targetSdk = "34" -versionCode = "5" -versionName = "1.1.1" +versionCode = "8" +versionName = "1.4.0" packageName = "com.nexters.boolti" compileSdk = "34" targetJvm = "17" @@ -41,6 +41,7 @@ google-services = "4.4.0" firebase-crashlytics = "2.9.9" firebase-config = "21.6.0" kotest = "5.8.0" +zoomable = "1.6.0" zxing = "4.3.0" kakao = "2.19.0" timber = "5.0.1" @@ -100,12 +101,15 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics-ktx", version.require = "false" } firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics-ktx", version.require = "false" } +firebase-messaging-ktx = { module = "com.google.firebase:firebase-messaging-ktx", version.require = "false" } firebase-config-ktx = { module = "com.google.firebase:firebase-config-ktx", version.ref = "firebase-config" } +firebase-dynamic-links = { module = "com.google.firebase:firebase-dynamic-links-ktx", version.require = "false" } +zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" } zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing" } kakao-login = { group = "com.kakao.sdk", name = "v2-user", version.ref = "kakao" } retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "serializationConverter" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } -mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk"} +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } [plugins] android-application = { id = "com.android.application", version.ref = "android" } @@ -124,7 +128,7 @@ lifecycle = ["androidx-lifecycle-runtime-ktx", "androidx-lifecycle-viewmodel-ktx android-test = ["junit", "androidx-junit", "androidx-espresso-core", "kotlinx-coroutines-test"] network = ["retrofit", "logging-interceptor"] db = ["androidx-datastore", "androidx-datastore-preferences-core", "androidx-room-runtime", "androidx-room-ktx"] -firebase = ["firebase-analytics-ktx", "firebase-crashlytics-ktx"] +firebase = ["firebase-analytics-ktx", "firebase-crashlytics-ktx", "firebase-messaging-ktx", "firebase-dynamic-links"] kotest = ["kotest-assertions-core", "kotest-property", "kotest-runner-junit5"] coil = ["coil", "coil-compose"] -compose = ["androidx-activity-compose", "androidx-navigation-compose", "androidx-material3-android", "androidx-compose-ui-ui", "androidx-compose-ui-ui-graphics", "androidx-compose-ui-tooling-preview", "androidx-compose-ui-ui-util", "androidx-lifecycle-runtime-compose"] \ No newline at end of file +compose = ["androidx-activity-compose", "androidx-navigation-compose", "androidx-material3-android", "androidx-compose-ui-ui", "androidx-compose-ui-ui-graphics", "androidx-compose-ui-tooling-preview", "androidx-compose-ui-ui-util", "androidx-lifecycle-runtime-compose"] diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index ff190d69..bdbccbce 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -59,10 +59,13 @@ dependencies { implementation(libs.bundles.compose) implementation(platform(libs.andoridx.compose.compose.bom)) implementation(libs.bundles.coroutines) + implementation(libs.bundles.firebase) + implementation(platform(libs.firebase.bom)) implementation(libs.hilt.android) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.material3.android) + implementation(libs.zoomable) kapt(libs.hilt.compiler) implementation(libs.lottie) diff --git a/presentation/src/main/AndroidManifest.xml b/presentation/src/main/AndroidManifest.xml index a5918e68..1818d8cd 100644 --- a/presentation/src/main/AndroidManifest.xml +++ b/presentation/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/QrScanActivity.kt b/presentation/src/main/java/com/nexters/boolti/presentation/QrScanActivity.kt index 2462e936..d892d373 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/QrScanActivity.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/QrScanActivity.kt @@ -3,45 +3,13 @@ package com.nexters.boolti.presentation import android.Manifest import android.os.Bundle import android.view.KeyEvent -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels -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.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.zxing.BarcodeFormat import com.journeyapps.barcodescanner.BarcodeCallback import com.journeyapps.barcodescanner.BarcodeResult @@ -51,6 +19,8 @@ import com.nexters.boolti.presentation.extension.requestPermission import com.nexters.boolti.presentation.screen.qr.QrScanScreen import com.nexters.boolti.presentation.theme.BooltiTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @AndroidEntryPoint class QrScanActivity : ComponentActivity() { @@ -70,6 +40,13 @@ class QrScanActivity : ComponentActivity() { private val callback = BarcodeCallback { result: BarcodeResult -> result.text ?: return@BarcodeCallback viewModel.scan(result.text) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + barcodeView.pause() + delay(1000) + barcodeView.resume() + } + } } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/QrScanViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/QrScanViewModel.kt index ff127553..0b100dc7 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/QrScanViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/QrScanViewModel.kt @@ -7,6 +7,7 @@ import com.nexters.boolti.domain.exception.QrErrorType import com.nexters.boolti.domain.exception.QrScanException import com.nexters.boolti.domain.repository.HostRepository import com.nexters.boolti.domain.request.QrScanRequest +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -23,7 +24,7 @@ import javax.inject.Inject class QrScanViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val hostRepository: HostRepository, -) : ViewModel() { +) : BaseViewModel() { private var lastCode: String? = null // 테스트 코드: wkjai-qoxzaz private val showId: String = requireNotNull(savedStateHandle["showId"]) @@ -48,18 +49,15 @@ class QrScanViewModel @Inject constructor( * @param entryCode 스캔한 QR 의 데이터 */ fun scan(entryCode: String) { - if (entryCode != lastCode) { - lastCode = entryCode - Timber.tag("mangbaam_QrScanActivity").d("스캔 결과: $entryCode") - requestEntrance(entryCode) - } + Timber.tag("mangbaam_QrScanActivity").d("스캔 결과: $entryCode") + requestEntrance(entryCode) } /** * 입장 확인 */ private fun requestEntrance(entryCode: String) { - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { hostRepository.requestEntrance( QrScanRequest(showId = showId, entryCode = entryCode) ).catch { e -> @@ -69,6 +67,7 @@ class QrScanViewModel @Inject constructor( event(QrScanEvent.ScanError(type)) } } + else -> throw e } }.singleOrNull()?.let { event(QrScanEvent.ScanSuccess) @@ -77,10 +76,8 @@ class QrScanViewModel @Inject constructor( } private fun getManagerCode() { - viewModelScope.launch { - hostRepository.getManagerCode(showId).catch { e -> - e.printStackTrace() - }.singleOrNull()?.let { code -> + viewModelScope.launch(recordExceptionHandler) { + hostRepository.getManagerCode(showId).singleOrNull()?.let { code -> _uiState.update { it.copy(managerCode = code) } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/base/BaseViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/base/BaseViewModel.kt new file mode 100644 index 00000000..8a5bd4de --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/base/BaseViewModel.kt @@ -0,0 +1,18 @@ +package com.nexters.boolti.presentation.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.crashlytics.FirebaseCrashlytics +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber + +open class BaseViewModel : ViewModel() { + protected val recordExceptionHandler = CoroutineExceptionHandler { _, throwable -> + viewModelScope.launch(Dispatchers.IO) { + FirebaseCrashlytics.getInstance().recordException(throwable) + Timber.e(throwable) + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/BtAppBar.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/BtAppBar.kt index a6241e08..88a115cc 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/component/BtAppBar.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/BtAppBar.kt @@ -3,52 +3,211 @@ package com.nexters.boolti.presentation.component import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope 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.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Grey10 -import com.nexters.boolti.presentation.theme.marginHorizontal @Composable fun BtAppBar( - title: String, - onBackPressed: () -> Unit, modifier: Modifier = Modifier, - @DrawableRes navIconRes: Int = R.drawable.ic_arrow_back, + title: String = "", + colors: BtAppBarColors = BtAppBarDefaults.appBarColors(), + navigateButtons: @Composable (RowScope.() -> Unit)? = null, + actionButtons: @Composable (RowScope.() -> Unit)? = null, ) { Row( modifier = modifier + .background(color = colors.containerColor) .fillMaxWidth() .height(44.dp) - .background(color = MaterialTheme.colorScheme.background), + .padding( + start = if (navigateButtons != null) 0.dp else 4.dp, + end = if (actionButtons != null) 0.dp else 4.dp, + ), verticalAlignment = Alignment.CenterVertically, ) { - IconButton( - modifier = Modifier.size(width = 48.dp, height = 44.dp), onClick = onBackPressed - ) { - Icon( - painter = painterResource(navIconRes), - contentDescription = stringResource(id = R.string.description_navigate_back), - modifier - .padding(start = marginHorizontal) - .size(width = 24.dp, height = 24.dp) + navigateButtons?.let { + CompositionLocalProvider( + LocalContentColor provides colors.navigationIconColor, + content = { it() }, ) } Text( + modifier = Modifier + .weight(1f) + .padding( + start = if (navigateButtons != null) 0.dp else 16.dp, + end = if (actionButtons != null) 0.dp else 16.dp, + ), text = title, - style = MaterialTheme.typography.titleMedium.copy(color = Grey10), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + color = colors.titleColor, ) + actionButtons?.let { + CompositionLocalProvider( + LocalContentColor provides colors.actionIconColor, + content = { it() }, + ) + } + } +} + +@Composable +fun BtBackAppBar( + modifier: Modifier = Modifier, + title: String = "", + colors: BtAppBarColors = BtAppBarDefaults.appBarColors(), + onClickBack: () -> Unit, +) { + BtAppBar( + modifier = modifier, + title = title, + navigateButtons = { + BtAppBarDefaults.AppBarIconButton( + onClick = onClickBack, + iconRes = R.drawable.ic_arrow_back, + ) + }, + colors = colors, + ) +} + +@Composable +fun BtCloseableAppBar( + modifier: Modifier = Modifier, + title: String = "", + colors: BtAppBarColors = BtAppBarDefaults.appBarColors(), + onClickClose: () -> Unit, +) { + BtAppBar( + modifier = modifier, + title = title, + actionButtons = { + BtAppBarDefaults.AppBarIconButton( + onClick = onClickClose, + iconRes = R.drawable.ic_close, + description = stringResource(R.string.description_close_button), + ) + }, + colors = colors, + ) +} + +object BtAppBarDefaults { + @Composable + fun AppBarIconButton( + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + description: String? = null, + onClick: () -> Unit, + ) { + IconButton( + modifier = modifier, + onClick = onClick, + ) { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(iconRes), + contentDescription = description, + ) + } + } + + @Composable + fun appBarColors( + containerColor: Color = MaterialTheme.colorScheme.background, + navigationIconColor: Color = Grey10, + titleColor: Color = Grey10, + actionIconColor: Color = Grey10, + ): BtAppBarColors = BtAppBarColors( + containerColor = containerColor, + navigationIconColor = navigationIconColor, + titleColor = titleColor, + actionIconColor = actionIconColor, + ) +} + +data class BtAppBarColors( + val containerColor: Color, + val navigationIconColor: Color, + val titleColor: Color, + val actionIconColor: Color, +) + +@Preview +@Composable +private fun BackBtAppBarPreview() { + BooltiTheme { + Surface { + BtBackAppBar(title = "title", onClickBack = {}) + } + } +} + +@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF) +@Composable +private fun CloseableBtAppBarPreview() { + BooltiTheme { + Surface { + BtCloseableAppBar(title = "", onClickClose = {}) + } + } +} + +@Preview +@Composable +private fun ShowDetailAppBarPreview() { + BooltiTheme { + Surface { + BtAppBar( + navigateButtons = { + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_arrow_back, + description = stringResource(id = R.string.description_navigate_back), + onClick = {}, + ) + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_home, + description = stringResource(id = R.string.description_toolbar_home), + onClick = {}, + ) + }, + actionButtons = { + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_share, + description = stringResource(id = R.string.ticketing_share), + onClick = {}, + ) + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_verticle_more, + description = stringResource(id = R.string.description_more_menu), + onClick = {}, + ) + } + ) + } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/BtCheckBox.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/BtCheckBox.kt new file mode 100644 index 00000000..f090db77 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/BtCheckBox.kt @@ -0,0 +1,39 @@ +package com.nexters.boolti.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.Grey05 +import com.nexters.boolti.presentation.theme.Grey50 + +@Composable +fun BtCheckBox( + isSelected: Boolean, + modifier: Modifier = Modifier, +) { + if (isSelected) { + Icon( + modifier = modifier + .padding(3.dp) + .background(MaterialTheme.colorScheme.primary, shape = CircleShape), + painter = painterResource(R.drawable.ic_checkbox_selected), + tint = Grey05, + contentDescription = null, + ) + } else { + Icon( + modifier = modifier, + painter = painterResource(R.drawable.ic_checkbox_18), + tint = Grey50, + contentDescription = null, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/BusinessInformation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/BusinessInformation.kt new file mode 100644 index 00000000..88dd4169 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/BusinessInformation.kt @@ -0,0 +1,58 @@ +package com.nexters.boolti.presentation.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.Grey70 + +@Composable +fun BusinessInformation( + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier + .padding(bottom = 2.dp) + .clickable(onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(id = R.string.business_information), + color = Grey70, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + Icon( + modifier = Modifier.size(18.dp), + painter = painterResource(id = R.drawable.ic_arrow_right), + tint = Grey70, + contentDescription = null, + ) + } + Text( + text = stringResource(id = R.string.business_copyright), + color = Grey70, + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + ) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/CircleBgIcon.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/CircleBgIcon.kt new file mode 100644 index 00000000..3c68a133 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/CircleBgIcon.kt @@ -0,0 +1,26 @@ +package com.nexters.boolti.presentation.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter + +@Composable +fun CircleBgIcon( + modifier: Modifier = Modifier, + painter: Painter, + bgColor: Color, +) { + Box( + modifier = modifier + .clip(CircleShape) + .background(bgColor) + ) { + Icon(painter = painter, contentDescription = null) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/HorizontalStepper.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/HorizontalStepper.kt new file mode 100644 index 00000000..03af893d --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/HorizontalStepper.kt @@ -0,0 +1,125 @@ +package com.nexters.boolti.presentation.component + +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.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.BooltiTheme +import com.nexters.boolti.presentation.theme.Grey15 +import com.nexters.boolti.presentation.theme.Grey70 + +@Composable +fun HorizontalStepper( + currentItem: T, + modifier: Modifier = Modifier, + minusEnabled: Boolean = true, + plusEnabled: Boolean = true, + onClickMinus: (current: T) -> Unit, + onClickPlus: (current: T) -> Unit, +) { + Card( + shape = RoundedCornerShape(4.dp), + ) { + Row( + modifier = modifier + .defaultMinSize(minWidth = 100.dp, minHeight = 32.dp) + .background(color = MaterialTheme.colorScheme.secondaryContainer), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Image( + modifier = Modifier.clickable( + enabled = minusEnabled, + role = Role.Button, + onClickLabel = stringResource(R.string.stepper_minus_description), + onClick = { onClickMinus(currentItem) }, + ), + painter = painterResource(id = R.drawable.ic_stepper_minus), + colorFilter = ColorFilter.tint(if (minusEnabled) Grey15 else Grey70), + contentDescription = stringResource(R.string.stepper_minus_description), + ) + Box( + modifier = Modifier + .defaultMinSize(minWidth = 32.dp, minHeight = 32.dp) + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = currentItem.toString(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + Image( + modifier = Modifier.clickable( + enabled = plusEnabled, + role = Role.Button, + onClickLabel = stringResource(R.string.stepper_plus_description), + onClick = { onClickPlus(currentItem) }, + ), + painter = painterResource(id = R.drawable.ic_stepper_plus), + colorFilter = ColorFilter.tint(if (plusEnabled) Grey15 else Grey70), + contentDescription = stringResource(R.string.stepper_plus_description), + ) + } + } +} + +@Composable +fun HorizontalCountStepper( + modifier: Modifier = Modifier, + minCount: Int = 1, + maxCount: Int = Int.MAX_VALUE, + currentCount: Int = 1, + onClickMinus: (current: Int) -> Unit = {}, + onClickPlus: (current: Int) -> Unit = {}, +) { + HorizontalStepper( + modifier = modifier, + currentItem = currentCount, + minusEnabled = currentCount != minCount, + plusEnabled = currentCount != maxCount, + onClickMinus = onClickMinus, + onClickPlus = onClickPlus, + ) +} + +@Preview +@Composable +private fun HorizontalCountStepperPreview() { + var current by remember { mutableIntStateOf(1) } + BooltiTheme { + HorizontalCountStepper( + modifier = Modifier.width(100.dp), + currentCount = current, + maxCount = 10, + onClickPlus = { current++ }, + onClickMinus = { current-- } + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/MainButton.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/MainButton.kt index 5829757d..8cecad52 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/component/MainButton.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/MainButton.kt @@ -13,7 +13,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Color.Companion.White import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,6 +48,32 @@ fun MainButton( } } +@Composable +fun SecondaryButton( + modifier: Modifier = Modifier, + label: String = stringResource(id = R.string.btn_ok), + enabled: Boolean = true, + disabledContentColor: Color = Grey50, + onClick: () -> Unit, +) { + Button( + modifier = modifier.height(48.dp), + onClick = onClick, + enabled = enabled, + colors = ButtonDefaults.buttonColors( + containerColor = Grey80, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = Grey80, + disabledContentColor = disabledContentColor, + ), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(horizontal = marginHorizontal), + interactionSource = remember { MutableInteractionSource() }, + ) { + Text(text = label, style = MaterialTheme.typography.titleMedium) + } +} + @Preview @Composable fun MainButtonPreview() { diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/ShowFeed.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/ShowFeed.kt index b1d0da01..6195fb56 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/component/ShowFeed.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/ShowFeed.kt @@ -14,24 +14,27 @@ 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.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.nexters.boolti.domain.model.Show import com.nexters.boolti.domain.model.ShowState import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.constants.posterRatio +import com.nexters.boolti.presentation.extension.showDateTimeString +import com.nexters.boolti.presentation.extension.toPx import com.nexters.boolti.presentation.theme.Grey05 +import com.nexters.boolti.presentation.theme.Grey20 import com.nexters.boolti.presentation.theme.Grey30 +import com.nexters.boolti.presentation.theme.Grey40 import com.nexters.boolti.presentation.theme.Grey80 -import com.nexters.boolti.presentation.theme.aggroFamily -import java.time.format.DateTimeFormatter +import com.nexters.boolti.presentation.theme.Grey95 +import com.nexters.boolti.presentation.theme.point1 @Composable fun ShowFeed( @@ -52,7 +55,7 @@ fun ShowFeed( contentDescription = stringResource(id = R.string.description_poster), modifier = Modifier .fillMaxWidth() - .aspectRatio(210f / 297f) + .aspectRatio(posterRatio) .clip(RoundedCornerShape(borderRadius)) .border( width = 1.dp, @@ -62,55 +65,39 @@ fun ShowFeed( contentScale = ContentScale.Crop, ) - if (showState !is ShowState.TicketingInProgress) { + if (showState is ShowState.WaitingTicketing || showState is ShowState.FinishedShow) { Box( modifier = Modifier .fillMaxWidth() - .aspectRatio(210f / 297f) + .aspectRatio(posterRatio) .background( brush = SolidColor(Color.Black), alpha = 0.5f, ) ) + } else { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(posterRatio) + .background( + brush = Brush.verticalGradient( + listOf(Color.Transparent, Grey95), + startY = 48.dp.toPx(), + ), + alpha = 0.5f, + ) + ) } - when (showState) { - is ShowState.WaitingTicketing -> { - Badge( - label = stringResource( - id = R.string.ticketing_button_upcoming_ticket, - showState.dDay - ), - modifier = Modifier.padding(all = 10.dp), - color = Grey05, - containerColor = MaterialTheme.colorScheme.primary, - ) - } - - is ShowState.FinishedShow -> { - Badge( - label = stringResource(id = R.string.finished_show), - modifier = Modifier.padding(all = 10.dp) - ) - } - - is ShowState.ClosedTicketing -> { - Badge( - label = stringResource(id = R.string.ticketing_button_closed_ticket), - modifier = Modifier.padding(all = 10.dp) - ) - } - - else -> {} - } + ShowBadge( + modifier = Modifier.padding(all = 10.dp), + showState = showState + ) } - val daysOfWeek = stringArrayResource(id = R.array.days_of_week) - val indexOfDay = show.date.dayOfWeek.value - 1 - val formatter = - DateTimeFormatter.ofPattern("yyyy.MM.dd (${daysOfWeek[indexOfDay]}) HH:mm") Text( - text = show.date.format(formatter), + text = show.date.showDateTimeString, modifier = Modifier.padding(top = 12.dp), style = MaterialTheme.typography.bodySmall.copy(color = Grey30) ) @@ -119,11 +106,46 @@ fun ShowFeed( modifier = Modifier.padding(top = 2.dp), maxLines = 2, overflow = TextOverflow.Ellipsis, - fontSize = 16.sp, - lineHeight = 26.sp, - fontFamily = aggroFamily, - fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.onBackground, + style = point1, ) } } + +@Composable +private fun ShowBadge( + showState: ShowState, + modifier: Modifier = Modifier, +) { + var dDay: Int? = null + val (color, containerColor, labelId) = when (showState) { + is ShowState.WaitingTicketing -> { + dDay = showState.dDay + Triple( + MaterialTheme.colorScheme.primary, + Grey80, + R.string.ticketing_button_upcoming_ticket, + ) + } + + ShowState.TicketingInProgress -> Triple( + Grey05, + MaterialTheme.colorScheme.primary, + R.string.ticketing_in_progress, + ) + + ShowState.ClosedTicketing -> Triple(Grey80, Grey20, R.string.ticketing_button_closed_ticket) + ShowState.FinishedShow -> Triple(Grey40, Grey80, R.string.finished_show) + } + val label = if (dDay == null) stringResource(labelId) else stringResource(labelId, dDay) + + + Text( + text = label, + modifier = modifier + .clip(RoundedCornerShape(100.dp)) + .background(containerColor.copy(0.9f)) + .padding(horizontal = 12.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelMedium.copy(color = color), + ) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/ToastSnackbarHost.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/ToastSnackbarHost.kt index 5203d552..f5b0e013 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/component/ToastSnackbarHost.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/ToastSnackbarHost.kt @@ -1,5 +1,7 @@ package com.nexters.boolti.presentation.component +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card @@ -9,6 +11,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -16,6 +19,7 @@ import androidx.compose.ui.unit.dp fun ToastSnackbarHost( hostState: SnackbarHostState, modifier: Modifier = Modifier, + leadingIcon: (@Composable () -> Unit)? = null, ) { SnackbarHost( hostState = hostState, @@ -28,11 +32,20 @@ fun ToastSnackbarHost( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Text( - modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), - text = data.visuals.message, - style = MaterialTheme.typography.bodySmall, - ) + Row( + modifier = Modifier.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingIcon != null) { + leadingIcon() + Spacer(modifier = Modifier.padding(end = 12.dp)) + } + Text( + modifier = Modifier.padding(vertical = 12.dp), + text = data.visuals.message, + style = MaterialTheme.typography.bodySmall, + ) + } } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/constants/ShowConstants.kt b/presentation/src/main/java/com/nexters/boolti/presentation/constants/ShowConstants.kt new file mode 100644 index 00000000..79611f35 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/constants/ShowConstants.kt @@ -0,0 +1,3 @@ +package com.nexters.boolti.presentation.constants + +const val posterRatio = 210f / 297f \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/extension/Activity.kt b/presentation/src/main/java/com/nexters/boolti/presentation/extension/Activity.kt index 3688ca64..3ea6b825 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/extension/Activity.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/extension/Activity.kt @@ -5,7 +5,7 @@ import android.content.Intent import androidx.core.app.ActivityCompat fun Activity.requestPermission(permission: String, requestCode: Int) { - if (!checkGranted(permission)) { + if (!checkGrantedPermission(permission)) { ActivityCompat.requestPermissions( this, arrayOf(permission), requestCode, diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/extension/Context.kt b/presentation/src/main/java/com/nexters/boolti/presentation/extension/Context.kt index 407a4667..6a71a2ea 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/extension/Context.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/extension/Context.kt @@ -21,6 +21,6 @@ fun Context.requireActivity(): Activity { ) } -fun Context.checkGranted(permission: String): Boolean { +fun Context.checkGrantedPermission(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/extension/LocalDateTime.kt b/presentation/src/main/java/com/nexters/boolti/presentation/extension/LocalDateTime.kt index 077073e7..e4b5afd1 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/extension/LocalDateTime.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/extension/LocalDateTime.kt @@ -16,4 +16,16 @@ val LocalDateTime.dayOfWeekString: String dayOfWeekArr[dayOfWeek.value - 1] } +/** + * ## yyyy.MM.dd (A) HH:mm + * + * ex) 2024.01.20 (토) 18:00 + */ +val LocalDateTime.showDateTimeString: String + @Composable + get() = run { + val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd ($dayOfWeekString) HH:mm") + format(formatter) + } + fun LocalDateTime.format(pattern: String): String = format(DateTimeFormatter.ofPattern(pattern)) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/extension/NavController.kt b/presentation/src/main/java/com/nexters/boolti/presentation/extension/NavController.kt index faf1f69b..efb98184 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/extension/NavController.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/extension/NavController.kt @@ -1,8 +1,13 @@ package com.nexters.boolti.presentation.extension import androidx.navigation.NavController +import com.nexters.boolti.presentation.screen.MainDestination fun NavController.navigateToHome() { popBackStack(graph.startDestinationId, true) - navigate(graph.startDestinationId) + try { + navigate(MainDestination.Home.route) + } catch (e: IllegalArgumentException) { + navigate(graph.startDestinationId) + } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/extension/ReservationState.kt b/presentation/src/main/java/com/nexters/boolti/presentation/extension/ReservationState.kt index c6d08693..1390ab7b 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/extension/ReservationState.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/extension/ReservationState.kt @@ -10,10 +10,10 @@ import com.nexters.boolti.presentation.theme.Success fun ReservationState.toDescriptionAndColorPair(): Pair { return when (this) { ReservationState.DEPOSITING -> Pair(R.string.reservations_depositing, Grey30) - ReservationState.REFUNDING -> Pair(R.string.reservations_refunding, Success) + ReservationState.REFUNDING -> Pair(R.string.reservations_refunding, Grey30) ReservationState.CANCELED -> Pair(R.string.reservations_canceled, Error) - ReservationState.RESERVED -> Pair(R.string.reservations_reserved, Grey30) + ReservationState.RESERVED -> Pair(R.string.reservations_reserved, Success) ReservationState.REFUNDED -> Pair(R.string.reservations_refunded, Error) ReservationState.UNDEFINED -> Pair(R.string.reservations_unknown, Error) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/extension/String.kt b/presentation/src/main/java/com/nexters/boolti/presentation/extension/String.kt index 16407af5..378bb6d1 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/extension/String.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/extension/String.kt @@ -1,7 +1,16 @@ package com.nexters.boolti.presentation.extension +import java.lang.StringBuilder + fun String.filterToPhoneNumber(): String = filter { it.isDigit() }.run { substring(0..minOf(10, lastIndex)) } fun String.sliceAtMost(maxLength: Int): String = slice(0 until minOf(maxLength, length)) + +fun String.toContactFormat(sep: Char = '-'): String = StringBuilder().apply { + filterToPhoneNumber().forEachIndexed { i, n -> + if (i in listOf(3, 7)) append(sep) + append(n) + } +}.toString() diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/DeepLinkEvent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/DeepLinkEvent.kt new file mode 100644 index 00000000..98ea4ca1 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/DeepLinkEvent.kt @@ -0,0 +1,20 @@ +package com.nexters.boolti.presentation.screen + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DeepLinkEvent @Inject constructor() { + private val _events = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val events = _events.asSharedFlow() + + suspend fun sendEvent(deepLink: String) { + _events.emit(deepLink) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/HomeViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/HomeViewModel.kt index e3cabf53..18db2497 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/HomeViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/HomeViewModel.kt @@ -1,32 +1,53 @@ package com.nexters.boolti.presentation.screen -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.AuthRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val authRepository: AuthRepository, -) : ViewModel() { + deepLinkEvent: DeepLinkEvent, +) : BaseViewModel() { val loggedIn = authRepository.loggedIn.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), null, ) + val event: SharedFlow = + deepLinkEvent.events.filter { it.startsWith("https://app.boolti.in/home") } + .shareIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + ) + init { initUserInfo() + sendFcmToken() } private fun initUserInfo() { authRepository.getUserAndCache() - .catch { } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) + } + + private fun sendFcmToken() { + viewModelScope.launch { + loggedIn.collectLatest { + if (it == true) authRepository.sendFcmToken() + } + } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt index 91fb77cd..3b3cf1eb 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/Main.kt @@ -1,21 +1,31 @@ package com.nexters.boolti.presentation.screen +import android.content.Intent import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController -import androidx.navigation.NavType import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.nexters.boolti.presentation.component.ToastSnackbarHost import com.nexters.boolti.presentation.extension.navigateToHome +import com.nexters.boolti.presentation.screen.MainDestination.Home +import com.nexters.boolti.presentation.screen.MainDestination.ShowDetail +import com.nexters.boolti.presentation.screen.business.BusinessScreen import com.nexters.boolti.presentation.screen.home.HomeScreen import com.nexters.boolti.presentation.screen.login.LoginScreen import com.nexters.boolti.presentation.screen.payment.PaymentScreen @@ -27,18 +37,45 @@ import com.nexters.boolti.presentation.screen.reservations.ReservationDetailScre import com.nexters.boolti.presentation.screen.reservations.ReservationsScreen import com.nexters.boolti.presentation.screen.show.ShowDetailContentScreen import com.nexters.boolti.presentation.screen.show.ShowDetailScreen -import com.nexters.boolti.presentation.screen.show.ShowDetailViewModel import com.nexters.boolti.presentation.screen.show.ShowImagesScreen +import com.nexters.boolti.presentation.screen.signout.SignoutScreen import com.nexters.boolti.presentation.screen.ticket.detail.TicketDetailScreen import com.nexters.boolti.presentation.screen.ticketing.TicketingScreen import com.nexters.boolti.presentation.theme.BooltiTheme +import com.nexters.boolti.presentation.util.SnackbarController + +val LocalSnackbarController = staticCompositionLocalOf { + SnackbarController(SnackbarHostState()) +} @Composable fun Main(onClickQrScan: (showId: String, showName: String) -> Unit) { val modifier = Modifier.fillMaxSize() + val scope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } + BooltiTheme { Surface(modifier) { - MainNavigation(modifier, onClickQrScan) + Scaffold( + snackbarHost = { + ToastSnackbarHost( + modifier = Modifier.padding(bottom = 80.dp), + hostState = snackbarHostState, + ) + }, + ) { innerPadding -> + CompositionLocalProvider( + LocalSnackbarController provides SnackbarController( + snackbarHostState, + scope + ) + ) { + MainNavigation( + modifier = modifier.padding(innerPadding), + onClickQrScan = onClickQrScan, + ) + } + } } } } @@ -47,207 +84,93 @@ fun Main(onClickQrScan: (showId: String, showName: String) -> Unit) { fun MainNavigation(modifier: Modifier, onClickQrScan: (showId: String, showName: String) -> Unit) { val navController = rememberNavController() - // TODO: 하드코딩 된 route 를 각 화면에 정의 NavHost( navController = navController, - startDestination = "home", + startDestination = Home.route, ) { - composable( - route = "home", - ) { - HomeScreen( - modifier = modifier, - onClickShowItem = { - navController.navigate("show/$it") - }, - onClickTicket = { - navController.navigate("tickets/$it") - }, - onClickQr = { - navController.navigate("qr/${it.filter { c -> c.isLetterOrDigit() }}") - }, - onClickQrScan = { - navController.navigate("hostedShows") - }, - navigateToReservations = { - navController.navigate("reservations") - } - ) { - navController.navigate("login") - } - } - - composable( - route = "login", - ) { - LoginScreen( - modifier = modifier, - ) { - navController.popBackStack() - } - } - - composable( - route = "reservations", - ) { - ReservationsScreen(onBackPressed = { - navController.popBackStack() - }, navigateToDetail = { reservationId -> - navController.navigate("reservations/$reservationId") - }) - } - - composable( - route = "reservations/{reservationId}", - arguments = listOf(navArgument("reservationId") { type = NavType.StringType }), - ) { - ReservationDetailScreen( - onBackPressed = { navController.popBackStack() }, - navigateToRefund = { id -> navController.navigate("refund/$id") }, - ) - } - - composable( - route = "refund/{reservationId}", - arguments = listOf(navArgument("reservationId") { type = NavType.StringType }), - ) { - RefundScreen( - onBackPressed = { navController.popBackStack() }, - ) - } + HomeScreen(modifier = modifier, navigateTo = navController::navigateTo) + LoginScreen(modifier = modifier, popBackStack = navController::popBackStack) + SignoutScreen( + navigateToHome = navController::navigateToHome, + popBackStack = navController::popBackStack + ) + ReservationsScreen( + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack + ) + ReservationDetailScreen( + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack + ) + RefundScreen(popBackStack = navController::popBackStack) navigation( - route = "show/{showId}", + route = "${ShowDetail.route}/{$showId}", startDestination = "detail", - arguments = listOf(navArgument("showId") { type = NavType.StringType }), - ) { - composable( - route = "detail", - ) { entry -> - val showViewModel: ShowDetailViewModel = - entry.sharedViewModel(navController = navController) - - ShowDetailScreen( - onBack = { navController.popBackStack() }, - onClickHome = { navController.navigateToHome() }, - onClickContent = { - navController.navigate("content") - }, - modifier = modifier, - onTicketSelected = { showId, ticketId, ticketCount, isInviteTicket -> - navController.navigate("ticketing/$showId?salesTicketId=$ticketId&ticketCount=$ticketCount&inviteTicket=$isInviteTicket") - }, - viewModel = showViewModel, - navigateToLogin = { navController.navigate("login") }, - navigateToImages = { index -> navController.navigate("images/$index") }, - navigateToReport = { - val showId = entry.arguments?.getString("showId") - navController.navigate("report/$showId") - } - ) - } - composable( - route = "images/{index}", - arguments = listOf(navArgument("index") { type = NavType.IntType }), - ) { entry -> - val showViewModel: ShowDetailViewModel = - entry.sharedViewModel(navController = navController) - val index = entry.arguments!!.getInt("index") - - ShowImagesScreen( - index = index, - viewModel = showViewModel, - onBackPressed = { navController.popBackStack() }, - ) - } - composable( - route = "content", - ) { entry -> - val showViewModel: ShowDetailViewModel = - entry.sharedViewModel(navController = navController) - - ShowDetailContentScreen( - modifier = modifier, - viewModel = showViewModel, - onBackPressed = { navController.popBackStack() } - ) - } - composable( - route = "report/{showId}", - ) { - ReportScreen( - onBackPressed = { navController.popBackStack() }, - popupToHome = { navController.navigateToHome() }, - modifier = modifier, - ) - } - } - - composable( - route = "tickets/{ticketId}", - arguments = listOf(navArgument("ticketId") { type = NavType.StringType }), - ) { - TicketDetailScreen(modifier = modifier, - onBackClicked = { navController.popBackStack() }, - onClickQr = { navController.navigate("qr/${it.filter { c -> c.isLetterOrDigit() }}") }, - navigateToShowDetail = { navController.navigate("show/$it") } - ) - } - composable( - route = "ticketing/{showId}?salesTicketId={salesTicketId}&ticketCount={ticketCount}&inviteTicket={isInviteTicket}", - arguments = listOf( - navArgument("showId") { type = NavType.StringType }, - navArgument("salesTicketId") { type = NavType.StringType }, - navArgument("ticketCount") { type = NavType.IntType }, - navArgument("isInviteTicket") { type = NavType.BoolType }, + arguments = ShowDetail.arguments, + deepLinks = listOf( +// navDeepLink { +// uriPattern = "https://app.boolti.in/show?showId={$showId}" +// action = Intent.ACTION_VIEW +// }, + navDeepLink { + uriPattern = "https://preview.boolti.in/show/{$showId}" + action = Intent.ACTION_VIEW + }, ), ) { - TicketingScreen( + ShowDetailScreen( modifier = modifier, - onBackClicked = { navController.popBackStack() }, - onReserved = { reservationId, showId -> - navController.navigate("payment/$reservationId?showId=$showId") - } + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack, + navigateToHome = navController::navigateToHome, + getSharedViewModel = { entry -> entry.sharedViewModel(navController) } ) - } - - composable( - route = "qr/{data}", - arguments = listOf(navArgument("data") { type = NavType.StringType }), - ) { - QrFullScreen(modifier = modifier) { - navController.popBackStack() - } - } - composable( - route = "hostedShows" - ) { - HostedShowScreen( + ShowImagesScreen( + popBackStack = navController::popBackStack, + getSharedViewModel = { entry -> entry.sharedViewModel(navController) } + ) + ShowDetailContentScreen( modifier = modifier, - onClickShow = onClickQrScan, - onClickBack = { - navController.popBackStack() - } + popBackStack = navController::popBackStack, + getSharedViewModel = { entry -> entry.sharedViewModel(navController) } ) - } - - composable( - route = "payment/{reservationId}?showId={showId}", - arguments = listOf( - navArgument("reservationId") { type = NavType.StringType }, - navArgument("showId") { type = NavType.StringType }), - ) { - val showId = it.arguments?.getString("showId") - PaymentScreen( - onClickHome = { navController.navigateToHome() }, - onClickClose = { - showId?.let { showId -> - navController.popBackStack("show/$showId", inclusive = true) - navController.navigate("show/$showId") - } ?: navController.popBackStack() - }, + ReportScreen( + modifier = modifier, + navigateToHome = navController::navigateToHome, + popBackStack = navController::popBackStack, ) } + + TicketDetailScreen( + modifier = modifier, + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack + ) + TicketingScreen( + modifier = modifier, + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack + ) + QrFullScreen(modifier = modifier, popBackStack = navController::popBackStack) + HostedShowScreen( + modifier = modifier, + onClickShow = onClickQrScan, + popBackStack = navController::popBackStack, + ) + + PaymentScreen( + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack, + popInclusiveBackStack = { route -> + navController.popBackStack( + route = route, + inclusive = true, + ) + }, + navigateToHome = navController::navigateToHome, + ) + BusinessScreen(popBackStack = navController::popBackStack) } } @@ -261,3 +184,5 @@ inline fun NavBackStackEntry.sharedViewModel( } return hiltViewModel(parentEntry) } + +private fun NavController.navigateTo(route: String) = navigate(route) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainActivity.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainActivity.kt index 8569df68..d1ee937d 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainActivity.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainActivity.kt @@ -1,5 +1,9 @@ package com.nexters.boolti.presentation.screen +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -7,10 +11,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier +import com.google.firebase.Firebase +import com.google.firebase.messaging.messaging +import com.nexters.boolti.presentation.BuildConfig import com.nexters.boolti.presentation.QrScanActivity +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.extension.requestPermission import com.nexters.boolti.presentation.extension.startActivity import com.nexters.boolti.presentation.theme.BooltiTheme import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -18,7 +28,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { BooltiTheme { - Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { Main( onClickQrScan = { showId, showName -> startActivity { @@ -30,5 +43,34 @@ class MainActivity : ComponentActivity() { } } } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermission(Manifest.permission.POST_NOTIFICATIONS, 101) + } + + createDefaultFcmChannel() + subscribeDefaultTopic() + } + + private fun createDefaultFcmChannel() { + val channelId = getString(R.string.default_notification_channel_id) + val name = getString(R.string.fcm_default_channel_name) + val description = getString(R.string.fcm_default_channel_description) + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel(channelId, name, importance) + channel.description = description + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private fun subscribeDefaultTopic() { + val defaultTopic = if (BuildConfig.DEBUG) "dev" else "prod" + + Firebase.messaging.subscribeToTopic(defaultTopic) + .addOnCompleteListener { task -> + if (!task.isSuccessful) { + Timber.d("구독 실패") + } + } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainDestination.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainDestination.kt new file mode 100644 index 00000000..26a8ae24 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainDestination.kt @@ -0,0 +1,65 @@ +package com.nexters.boolti.presentation.screen + +import androidx.navigation.NavType +import androidx.navigation.navArgument + +sealed class MainDestination(val route: String) { + data object Home : MainDestination(route = "home") + data object ShowDetail : MainDestination(route = "show") { + val arguments = listOf(navArgument(showId) { type = NavType.StringType }) + } + + data object Ticketing : MainDestination(route = "ticketing") { + val arguments = listOf( + navArgument(showId) { type = NavType.StringType }, + navArgument(salesTicketId) { type = NavType.StringType }, + navArgument(ticketCount) { type = NavType.IntType }, + navArgument(isInviteTicket) { type = NavType.BoolType }, + ) + } + + data object Payment : MainDestination(route = "payment") { + val arguments = listOf( + navArgument(reservationId) { type = NavType.StringType }, + navArgument(showId) { type = NavType.StringType } + ) + } + + data object TicketDetail : MainDestination(route = "tickets") { + val arguments = listOf(navArgument(ticketId) { type = NavType.StringType }) + } + + data object Qr : MainDestination(route = "qr") { + val arguments = listOf( + navArgument(data) { type = NavType.StringType }, + navArgument(ticketName) { type = NavType.StringType }, + ) + } + + data object Reservations : MainDestination(route = "reservations") + data object ReservationDetail : MainDestination(route = "reservations") { + val arguments = listOf(navArgument(reservationId) { type = NavType.StringType }) + } + + data object Refund : MainDestination(route = "refund") { + val arguments = listOf(navArgument(reservationId) { type = NavType.StringType }) + } + + data object HostedShows : MainDestination(route = "hostedShows") + + data object SignOut : MainDestination(route = "signout") + data object Login : MainDestination(route = "login") + data object Business : MainDestination(route = "business") +} + +/** + * arguments + */ +const val showId = "showId" +const val ticketId = "ticketId" +const val ticketName = "ticketName" +const val data = "data" +const val reservationId = "reservationId" +const val salesTicketId = "salesTicketId" +const val ticketCount = "ticketCount" +const val isInviteTicket = "isInviteTicket" diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/business/BusinessNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/business/BusinessNavigation.kt new file mode 100644 index 00000000..8d8d6e9e --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/business/BusinessNavigation.kt @@ -0,0 +1,17 @@ +package com.nexters.boolti.presentation.screen.business + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination + +fun NavGraphBuilder.BusinessScreen( + popBackStack: () -> Unit, +) { + composable( + route = MainDestination.Business.route, + ) { + BusinessScreen( + onBackPressed = popBackStack + ) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/business/BusinessScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/business/BusinessScreen.kt new file mode 100644 index 00000000..cf1a7aca --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/business/BusinessScreen.kt @@ -0,0 +1,129 @@ +package com.nexters.boolti.presentation.screen.business + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BtBackAppBar +import com.nexters.boolti.presentation.theme.Grey30 +import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.Grey85 +import com.nexters.boolti.presentation.theme.marginHorizontal + +@Composable +fun BusinessScreen( + onBackPressed: () -> Unit, +) { + Scaffold( + contentColor = MaterialTheme.colorScheme.background, + topBar = { + BtBackAppBar( + title = stringResource(id = R.string.business_title), + onClickBack = onBackPressed, + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = marginHorizontal) + .verticalScroll(rememberScrollState()) + ) { + Text( + modifier = Modifier.padding(top = 20.dp, bottom = 16.dp), + text = stringResource(id = R.string.business_name), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + val information = stringArrayResource(id = R.array.business_information) + information.forEachIndexed { index, text -> + val modifier = if (index == 0) Modifier else Modifier.padding(top = 4.dp) + + Text( + modifier = modifier, + text = text, + style = MaterialTheme.typography.bodyLarge, + color = Grey30, + ) + } + + BusinessMenu( + modifier = Modifier.padding(top = 48.dp, bottom = 12.dp), + title = stringResource(id = R.string.business_service_terms), + url = "https://www.notion.so/boolti/b4c5beac61c2480886da75a1f3afb982" + ) + BusinessMenu( + modifier = Modifier.padding(bottom = 12.dp), + title = stringResource(id = R.string.business_privacy_policy), + url = "https://www.notion.so/boolti/5f73661efdcd4507a1e5b6827aa0da70" + ) + BusinessMenu( + title = stringResource(id = R.string.business_refund_policy), + url = "https://www.notion.so/boolti/d2a89e2c19824c60bb1e928370d16989" + ) + } + } +} + +@Composable +private fun BusinessMenu( + title: String, + url: String, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + + Row( + modifier = modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Grey85) + .clickable { uriHandler.openUri(url) } + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = Grey30, + ) + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.ic_arrow_right), + tint = Grey50, + contentDescription = null, + ) + } +} + +@Preview(backgroundColor = 0xFF090A0B) +@Composable +private fun BusinessScreenPreview() { + BusinessScreen(onBackPressed = {}) +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeNavigation.kt new file mode 100644 index 00000000..186dfce7 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeNavigation.kt @@ -0,0 +1,31 @@ +package com.nexters.boolti.presentation.screen.home + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination + +fun NavGraphBuilder.HomeScreen( + navigateTo: (String) -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = MainDestination.Home.route, + ) { + HomeScreen( + modifier = modifier, + onClickShowItem = { navigateTo("${MainDestination.ShowDetail.route}/$it") }, + onClickTicket = { navigateTo("${MainDestination.TicketDetail.route}/$it") }, + onClickQr = { code, ticketName -> + navigateTo( + "${MainDestination.Qr.route}/${code.filter { c -> c.isLetterOrDigit() }}?ticketName=$ticketName" + ) + }, + onClickQrScan = { navigateTo(MainDestination.HostedShows.route) }, + onClickSignout = { navigateTo(MainDestination.SignOut.route) }, + navigateToReservations = { navigateTo(MainDestination.Reservations.route) }, + navigateToBusiness = { navigateTo(MainDestination.Business.route) }, + requireLogin = { navigateTo(MainDestination.Login.route) } + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeScreen.kt index cc41e6c9..9c20a35c 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/home/HomeScreen.kt @@ -1,5 +1,7 @@ package com.nexters.boolti.presentation.screen.home +import android.content.Intent +import android.net.Uri import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column @@ -14,6 +16,7 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -27,6 +30,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navDeepLink import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.screen.HomeViewModel import com.nexters.boolti.presentation.screen.my.MyScreen @@ -36,17 +40,20 @@ import com.nexters.boolti.presentation.screen.ticket.TicketScreen import com.nexters.boolti.presentation.theme.Grey10 import com.nexters.boolti.presentation.theme.Grey50 import com.nexters.boolti.presentation.theme.Grey85 +import kotlinx.coroutines.channels.consumeEach @Composable fun HomeScreen( - modifier: Modifier, - viewModel: HomeViewModel = hiltViewModel(), onClickShowItem: (showId: String) -> Unit, onClickTicket: (ticketId: String) -> Unit, - onClickQr: (data: String) -> Unit, + onClickQr: (data: String, ticketName: String) -> Unit, onClickQrScan: () -> Unit, + onClickSignout: () -> Unit, navigateToReservations: () -> Unit, + navigateToBusiness: () -> Unit, requireLogin: () -> Unit, + modifier: Modifier, + viewModel: HomeViewModel = hiltViewModel(), ) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -54,6 +61,12 @@ fun HomeScreen( val loggedIn by viewModel.loggedIn.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { + viewModel.event.collect { deepLink -> + navController.navigate(Uri.parse(deepLink)) + } + } + Scaffold( bottomBar = { HomeNavigationBar( @@ -80,10 +93,17 @@ fun HomeScreen( modifier = modifier.padding(innerPadding), onClickShowItem = onClickShowItem, navigateToReservations = navigateToReservations, + navigateToBusiness = navigateToBusiness, ) } composable( route = Destination.Ticket.route, + deepLinks = listOf( + navDeepLink { + uriPattern = "https://app.boolti.in/home/tickets" + action = Intent.ACTION_VIEW + } + ) ) { when (loggedIn) { true -> TicketScreen( @@ -92,7 +112,11 @@ fun HomeScreen( modifier = modifier.padding(innerPadding), ) - false -> TicketLoginScreen(modifier.padding(innerPadding), onLoginClick = requireLogin) + false -> TicketLoginScreen( + modifier.padding(innerPadding), + onLoginClick = requireLogin + ) + else -> Unit // 로그인 여부를 불러오는 중 } } @@ -104,6 +128,7 @@ fun HomeScreen( requireLogin = requireLogin, navigateToReservations = navigateToReservations, onClickQrScan = onClickQrScan, + onClickSignout = onClickSignout, ) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginEvent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginEvent.kt index 57ec7849..32786b1e 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginEvent.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginEvent.kt @@ -3,5 +3,6 @@ package com.nexters.boolti.presentation.screen.login sealed interface LoginEvent { data object Success : LoginEvent data object RequireSignUp : LoginEvent + data object SignOutCancelled : LoginEvent data object Invalid : LoginEvent } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginNavigation.kt new file mode 100644 index 00000000..42d4675a --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginNavigation.kt @@ -0,0 +1,21 @@ +package com.nexters.boolti.presentation.screen.login + +import androidx.compose.ui.Modifier +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination + +fun NavGraphBuilder.LoginScreen( + popBackStack: () -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = MainDestination.Login.route, + ) { + LoginScreen( + modifier = modifier, + onBackPressed = popBackStack + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginScreen.kt index 0fae236d..6beb0455 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginScreen.kt @@ -2,19 +2,15 @@ package com.nexters.boolti.presentation.screen.login import android.widget.Toast import androidx.activity.compose.BackHandler -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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold @@ -24,20 +20,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BTDialog +import com.nexters.boolti.presentation.component.BtCloseableAppBar import com.nexters.boolti.presentation.component.KakaoLoginButton import com.nexters.boolti.presentation.component.MainButton import com.nexters.boolti.presentation.theme.Grey30 @@ -47,25 +46,21 @@ import com.nexters.boolti.presentation.theme.subTextPadding @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen( + onBackPressed: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), - onBackPressed: () -> Unit, ) { val context = LocalContext.current val sheetState = rememberModalBottomSheetState() + var showSignOutCancelledDialog by remember { mutableStateOf(false) } var isSheetOpen by rememberSaveable { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.event.collect { when (it) { - LoginEvent.Success -> { - onBackPressed() - } - - LoginEvent.RequireSignUp -> { - isSheetOpen = true - } - + LoginEvent.Success -> onBackPressed() + LoginEvent.RequireSignUp -> isSheetOpen = true + LoginEvent.SignOutCancelled -> showSignOutCancelledDialog = true LoginEvent.Invalid -> Toast.makeText(context, "로그인 실패", Toast.LENGTH_SHORT).show() } } @@ -88,8 +83,15 @@ fun LoginScreen( } } + if (showSignOutCancelledDialog) { + SignOutCancelledDialog { + showSignOutCancelledDialog = false + onBackPressed() + } + } + Scaffold( - topBar = { LoginAppBar(onBackPressed = onBackPressed) }, + topBar = { BtCloseableAppBar(onClickClose = onBackPressed) }, containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> Box( @@ -122,31 +124,6 @@ fun LoginScreen( } } -@Composable -private fun LoginAppBar( - modifier: Modifier = Modifier, - onBackPressed: () -> Unit, -) { - Box( - modifier = modifier - .fillMaxWidth() - .height(44.dp) - .background(color = MaterialTheme.colorScheme.background), - ) { - IconButton( - modifier = Modifier.size(width = 48.dp, height = 44.dp), onClick = onBackPressed - ) { - Icon( - painter = painterResource(R.drawable.ic_close), - contentDescription = stringResource(id = R.string.description_navigate_back), - modifier - .padding(start = marginHorizontal) - .size(width = 24.dp, height = 24.dp) - ) - } - } -} - @Composable private fun SignUpBottomSheet( signUp: () -> Unit, @@ -162,7 +139,9 @@ private fun SignUpBottomSheet( modifier = modifier.padding(horizontal = 24.dp), ) { Text( - modifier = Modifier.padding(top = 24.dp, bottom = 12.dp).height(32.dp), + modifier = Modifier + .padding(top = 24.dp, bottom = 12.dp) + .height(32.dp), text = stringResource(id = R.string.signup_greeting), style = MaterialTheme.typography.headlineSmall ) @@ -195,4 +174,19 @@ private fun SignUpBottomSheet( onClick = signUp, ) } -} \ No newline at end of file +} + +@Composable +fun SignOutCancelledDialog(onDismiss: () -> Unit) { + BTDialog( + onDismiss = onDismiss, + onClickPositiveButton = onDismiss, + ) { + Text( + text = stringResource(R.string.signout_cancelled_message), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginViewModel.kt index bc13b333..da0ea68c 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/login/LoginViewModel.kt @@ -33,7 +33,11 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { authRepository.kakaoLogin(LoginRequest(accessToken)).onSuccess { - if (it) event(LoginEvent.Success) else event(LoginEvent.RequireSignUp) + when { + it.signUpRequired -> event(LoginEvent.RequireSignUp) + it.signOutCancelled -> event(LoginEvent.SignOutCancelled) + else -> event(LoginEvent.Success) + } }.onFailure { Timber.d("login failed: $it") event(LoginEvent.Invalid) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt index 814567a8..9b31bcfa 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyScreen.kt @@ -1,6 +1,5 @@ package com.nexters.boolti.presentation.screen.my -import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -8,7 +7,6 @@ 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.rememberScrollState @@ -26,7 +24,7 @@ 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.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -48,13 +46,13 @@ fun MyScreen( requireLogin: () -> Unit, navigateToReservations: () -> Unit, onClickQrScan: () -> Unit, + onClickSignout: () -> Unit, modifier: Modifier = Modifier, viewModel: MyViewModel = hiltViewModel(), ) { val user by viewModel.user.collectAsStateWithLifecycle() - var showSignoutDialog by remember { mutableStateOf(false) } - val context = LocalContext.current var openLogoutDialog by remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { viewModel.fetchMyInfo() @@ -70,34 +68,39 @@ fun MyScreen( text = stringResource(id = R.string.my_ticketing_history), onClick = if (user == null) requireLogin else navigateToReservations, ) - Spacer(modifier = Modifier.height(12.dp)) MyButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + text = stringResource(R.string.my_register_show), + onClick = { + if (user != null) { + uriHandler.openUri("https://boolti.in/home") // 웹에서 로그인되지 않은 상태라면 login 페이지로 리다이렉션 시킴 + } else { + uriHandler.openUri("https://boolti.in/login") + } + } + ) + MyButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), text = stringResource(id = R.string.my_scan_qr), onClick = onClickQrScan, ) if (user != null) { - Spacer(modifier = Modifier.height(12.dp)) MyButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), text = stringResource(id = R.string.my_logout), onClick = { openLogoutDialog = true }, ) } Spacer(modifier = Modifier.weight(1.0f)) - if (user != null) SignoutButton(onClick = { showSignoutDialog = true }) - } - - if (showSignoutDialog) { - SignoutDialog( - onDismiss = { showSignoutDialog = false }, - onClickButton = { - Toast.makeText(context, "탈퇴 요청이 접수되었습니다.", Toast.LENGTH_LONG).show() - viewModel.logout() - }, - ) + if (user != null) SignoutButton(onClick = onClickSignout) } if (openLogoutDialog) { @@ -196,39 +199,8 @@ fun SignoutButton( modifier = modifier .padding(bottom = 40.dp) .clickable(onClick = onClick), - text = stringResource(id = R.string.signout_button), + text = stringResource(id = R.string.signout), style = MaterialTheme.typography.bodySmall.copy(color = Grey50), textDecoration = TextDecoration.Underline, ) } - -@Composable -private fun SignoutDialog( - onDismiss: () -> Unit, - onClickButton: () -> Unit, -) { - BTDialog( - positiveButtonLabel = stringResource(R.string.signout), - onClickPositiveButton = { - onClickButton() - onDismiss() - }, - onDismiss = onDismiss, - ) { - Text( - text = stringResource(R.string.signout_dialog_title), - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - modifier = Modifier - .padding(top = 4.dp) - .align(Alignment.CenterHorizontally), - text = stringResource(R.string.signout_dialog_message), - style = MaterialTheme.typography.bodySmall, - textAlign = TextAlign.Center, - color = Grey50, - ) - } -} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyViewModel.kt index f33082ca..83a22b8a 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/my/MyViewModel.kt @@ -1,26 +1,20 @@ package com.nexters.boolti.presentation.screen.my -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.AuthRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel class MyViewModel @Inject constructor( private val authRepository: AuthRepository, -) : ViewModel() { +) : BaseViewModel() { val user = authRepository.cachedUser.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), @@ -35,9 +29,6 @@ class MyViewModel @Inject constructor( fun fetchMyInfo() { authRepository.getUserAndCache() - .catch { - // TODO 예외처리 - } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/AccountTransferContent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/AccountTransferContent.kt index 76953c6e..4f2ae2a6 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/AccountTransferContent.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/AccountTransferContent.kt @@ -10,7 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,6 +21,7 @@ import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.nexters.boolti.domain.model.PaymentType import com.nexters.boolti.domain.model.ReservationDetail import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.extension.format @@ -34,6 +35,7 @@ import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point4 import com.nexters.boolti.presentation.theme.subTextPadding import java.time.LocalDateTime +import com.nexters.boolti.presentation.screen.payment.LegacyTicketSummarySection as LegacyTicketSummarySection1 @Composable fun AccountTransferContent( @@ -45,18 +47,20 @@ fun AccountTransferContent( modifier = modifier.padding(horizontal = marginHorizontal), ) { HeaderSection(reservation.totalAmountPrice, reservation.salesEndDateTime) - Divider( + + HorizontalDivider( modifier = Modifier.padding(top = 20.dp), thickness = 1.dp, - color = Grey85, + color = Grey85 ) - TicketSummarySection( - Modifier.padding(top = 24.dp), + + LegacyTicketSummarySection1( + modifier = Modifier.padding(top = 24.dp), poster = reservation.showImage, showName = reservation.showName, ticketName = reservation.ticketName, ticketCount = reservation.ticketCount, - price = reservation.totalAmountPrice, + totalPrice = reservation.totalAmountPrice, ) Box( diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentCompleteScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentCompleteScreen.kt index c737b9a3..264e6280 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentCompleteScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentCompleteScreen.kt @@ -1,44 +1,126 @@ package com.nexters.boolti.presentation.screen.payment +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +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.material3.Divider +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.nexters.boolti.domain.model.PaymentType import com.nexters.boolti.domain.model.ReservationDetail +import com.nexters.boolti.domain.model.ReservationState import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.BooltiTheme +import com.nexters.boolti.presentation.theme.Grey15 +import com.nexters.boolti.presentation.theme.Grey30 import com.nexters.boolti.presentation.theme.Grey85 import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point4 +import java.time.LocalDateTime @Composable fun PaymentCompleteScreen( modifier: Modifier = Modifier, reservation: ReservationDetail, + navigateToReservation: (reservation: ReservationDetail) -> Unit = {}, + navigateToTicketDetail: (reservation: ReservationDetail) -> Unit = {}, ) { - Column( - modifier = modifier.padding(horizontal = marginHorizontal) + val scrollState = rememberScrollState() + Box( + modifier = modifier.fillMaxSize(), ) { - HeaderSection() - Divider( - modifier = Modifier.padding(top = 20.dp), - thickness = 1.dp, - color = Grey85, - ) - TicketSummarySection( - Modifier.padding(top = 24.dp), - poster = reservation.showImage, - showName = reservation.showName, - ticketName = reservation.ticketName, - ticketCount = reservation.ticketCount, - price = reservation.totalAmountPrice, - ) + Column( + modifier = Modifier + .padding(horizontal = marginHorizontal) + .verticalScroll(scrollState), + ) { + HeaderSection() + SectionDivider(modifier = Modifier.padding(top = 20.dp)) + + InfoRow( + modifier = Modifier.padding(top = 24.dp), + label = stringResource(R.string.reservation_number), value = reservation.csReservationId + ) + InfoRow( + modifier = Modifier.padding(top = 16.dp), + label = stringResource(R.string.ticketing_ticket_holder_label), + value = slashFormat(reservation.ticketHolderName, reservation.ticketHolderPhoneNumber), + ) + if (!reservation.isInviteTicket && reservation.totalAmountPrice > 0) { + InfoRow( + modifier = Modifier.padding(top = 16.dp), + label = stringResource(R.string.depositor_info_label), + value = slashFormat(reservation.depositorName, reservation.depositorPhoneNumber), + ) + } + SectionDivider(modifier = Modifier.padding(top = 24.dp)) + + InfoRow( + modifier = Modifier.padding(top = 24.dp), + label = stringResource(R.string.payment_amount_label), + value = stringResource( + R.string.unit_won, + reservation.totalAmountPrice + ), // TODO (카카오뱅크카드 / 일시불) 형태의 정보 추가 + ) + InfoRow( + modifier = Modifier.padding(top = 16.dp), + label = stringResource(R.string.reservation_ticket_type), + value = slashFormat( + reservation.ticketName, + stringResource(R.string.ticket_count, reservation.ticketCount) + ), + ) + + TicketSummarySection( + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + poster = reservation.showImage, + showName = reservation.showName, + showDate = reservation.showDate, + ) + } + + // TODO 백엔드에 TicketId 요청 필요 <-- 1.5.0 에 주석 제거 + /*Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(horizontal = marginHorizontal) + .padding(bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SecondaryButton( + modifier = Modifier.weight(1f), + label = stringResource(R.string.show_reservation), + ) { + navigateToReservation(reservation) + } + MainButton( + modifier = Modifier.weight(1f), + label = stringResource(R.string.show_ticket), + ) { + navigateToTicketDetail(reservation) + } + }*/ } } +private fun slashFormat(s1: String, s2: String): String = String.format("%s / %s", s1, s2) + @Composable private fun HeaderSection() { Text( @@ -47,3 +129,70 @@ private fun HeaderSection() { style = point4, ) } + +@Composable +private fun InfoRow( + modifier: Modifier = Modifier, + label: String, + value: String, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.width(100.dp), + text = label, + style = MaterialTheme.typography.bodyLarge, + color = Grey30, + ) + Text( + modifier = Modifier.padding(horizontal = 12.dp), + text = value, + style = MaterialTheme.typography.bodyLarge, + color = Grey15, + ) + } +} + +@Composable +private fun SectionDivider( + modifier: Modifier = Modifier, +) = HorizontalDivider( + modifier = modifier, + thickness = 1.dp, + color = Grey85, +) + +@Preview +@Composable +private fun PaymentCompleteScreenPreview() { + BooltiTheme { + PaymentCompleteScreen( + reservation = ReservationDetail( + id = "eius", + showImage = "noster", + showName = "Mara King", + showDate = LocalDateTime.now(), + ticketName = "Juliet Greer", + isInviteTicket = false, + ticketCount = 6931, + bankName = "Corinne Leon", + accountNumber = "graece", + accountHolder = "reprimique", + salesEndDateTime = LocalDateTime.now(), + paymentType = PaymentType.UNDEFINED, + totalAmountPrice = 3473, + reservationState = ReservationState.REFUNDING, + completedDateTime = null, + ticketHolderName = "Cedric Butler", + ticketHolderPhoneNumber = "(453) 355-6682", + depositorName = "Dick Haley", + depositorPhoneNumber = "(869) 823-0418", + csReservationId = "mutat" + ), + navigateToReservation = {}, + navigateToTicketDetail = {}, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentNavigation.kt new file mode 100644 index 00000000..30b635f2 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentNavigation.kt @@ -0,0 +1,32 @@ +package com.nexters.boolti.presentation.screen.payment + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.reservationId +import com.nexters.boolti.presentation.screen.showId + +fun NavGraphBuilder.PaymentScreen( + navigateTo: (String) -> Unit, + popBackStack: () -> Unit, + popInclusiveBackStack: (String) -> Unit, + navigateToHome: () -> Unit, +) { + composable( + route = "${MainDestination.Payment.route}/{$reservationId}?showId={$showId}", + arguments = MainDestination.Payment.arguments, + ) { + val showId = it.arguments?.getString(showId) + PaymentScreen( + onClickHome = navigateToHome, + onClickClose = { + showId?.let { showId -> + popInclusiveBackStack("${MainDestination.ShowDetail.route}/$showId") + navigateTo("${MainDestination.ShowDetail.route}/$showId") + } ?: popBackStack() + }, + navigateToReservation = { reservation -> navigateTo("${MainDestination.ReservationDetail.route}/${reservation.id}") }, + navigateToTicketDetail = { reservation -> navigateTo("${MainDestination.TicketDetail.route}/${reservation.id}") }, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentScreen.kt index ffde4b4c..e4464556 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentScreen.kt @@ -5,6 +5,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -12,7 +13,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -20,13 +23,18 @@ import com.nexters.boolti.domain.model.PaymentType import com.nexters.boolti.domain.model.ReservationDetail import com.nexters.boolti.domain.model.ReservationState import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BtAppBar +import com.nexters.boolti.presentation.component.BtAppBarDefaults import com.nexters.boolti.presentation.component.ToastSnackbarHost +import com.nexters.boolti.presentation.theme.BooltiTheme import kotlinx.coroutines.launch @Composable fun PaymentScreen( onClickHome: () -> Unit, onClickClose: () -> Unit, + navigateToReservation: (reservation: ReservationDetail) -> Unit, + navigateToTicketDetail: (reservation: ReservationDetail) -> Unit, viewModel: PaymentViewModel = hiltViewModel(), ) { val snackbarHostState = remember { SnackbarHostState() } @@ -41,7 +49,10 @@ fun PaymentScreen( Scaffold( topBar = { PaymentToolbar(onClickHome = onClickHome, onClickClose = onClickClose) }, snackbarHost = { - ToastSnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(bottom = 40.dp)) + ToastSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = 40.dp) + ) }, ) { innerPadding -> when (uiState) { @@ -49,10 +60,14 @@ fun PaymentScreen( is PaymentState.Success -> { val reservation = (uiState as PaymentState.Success).reservationDetail when { - reservation.reservationState == ReservationState.RESERVED || reservation.isInviteTicket -> + reservation.totalAmountPrice == 0 || + reservation.reservationState == ReservationState.RESERVED || + reservation.isInviteTicket -> PaymentCompleteScreen( modifier = Modifier.padding(innerPadding), - reservation = reservation + reservation = reservation, + navigateToReservation = navigateToReservation, + navigateToTicketDetail = navigateToTicketDetail, ) else -> ProgressPayment( @@ -92,3 +107,36 @@ private fun ProgressPayment( PaymentType.UNDEFINED -> Unit } } + +@Composable +private fun PaymentToolbar( + onClickHome: () -> Unit, + onClickClose: () -> Unit, +) { + BtAppBar( + navigateButtons = { + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_home, + description = stringResource(R.string.description_toolbar_home), + onClick = onClickHome, + ) + }, + actionButtons = { + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_close, + description = stringResource(R.string.description_close_button), + onClick = onClickClose, + ) + } + ) +} + +@Preview +@Composable +private fun PaymentToolBarPreview() { + BooltiTheme { + Surface { + PaymentToolbar({}, {}) + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentToolbar.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentToolbar.kt deleted file mode 100644 index 7f7a90bb..00000000 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentToolbar.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.nexters.boolti.presentation.screen.payment - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import com.nexters.boolti.presentation.R -import com.nexters.boolti.presentation.theme.BooltiTheme - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PaymentToolbar( - onClickHome: () -> Unit, - onClickClose: () -> Unit, -) { - TopAppBar( - title = {}, - navigationIcon = { - IconButton(onClick = onClickHome) { - Icon( - painter = painterResource(R.drawable.ic_home), - contentDescription = stringResource(R.string.description_toolbar_home), - ) - } - }, - actions = { - IconButton(onClick = onClickClose) { - Icon( - painter = painterResource(R.drawable.ic_close), - contentDescription = stringResource(R.string.description_close_button), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - ), - ) -} - -@Preview -@Composable -fun PaymentToolbarPreview() { - BooltiTheme { - Surface { - PaymentToolbar({}, {}) - } - } -} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentViewModel.kt index cef6549e..474cfce7 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/PaymentViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.TicketingRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,7 +17,7 @@ import javax.inject.Inject class PaymentViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: TicketingRepository, -) : ViewModel() { +) : BaseViewModel() { private val reservationId: String = requireNotNull(savedStateHandle["reservationId"]) { "TicketingCompleteViewModel 에 reservationId 가 전달되지 않았습니다." } @@ -29,11 +30,8 @@ class PaymentViewModel @Inject constructor( } private fun load() { - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { repository.getPaymentInfo(reservationId) - .catch { e -> - e.printStackTrace() - } .singleOrNull()?.let { _uiState.value = PaymentState.Success(reservationDetail = it) } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/TicketSummarySection.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/TicketSummarySection.kt index 10686416..ae288c7a 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/TicketSummarySection.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/payment/TicketSummarySection.kt @@ -6,7 +6,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -14,21 +17,75 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.nexters.boolti.domain.model.PaymentType import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.extension.showDateTimeString +import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Grey05 import com.nexters.boolti.presentation.theme.Grey30 +import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point1 +import java.time.LocalDateTime @Composable fun TicketSummarySection( + modifier: Modifier = Modifier, + poster: String, + showName: String, + showDate: LocalDateTime, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(4.dp), + ) { + Row( + modifier = Modifier.padding(vertical = 16.dp, horizontal = marginHorizontal), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = poster, + contentDescription = stringResource(R.string.description_poster), + modifier = Modifier + .size(width = 70.dp, height = 98.dp) + .clip(RoundedCornerShape(4.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(4.dp), + ), + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier.padding(start = 16.dp), + ) { + Text( + text = showName, + style = point1, + color = Grey05, + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = showDate.showDateTimeString, + color = Grey30, + style = MaterialTheme.typography.bodySmall, + ) + } + } + } +} + +@Composable +fun LegacyTicketSummarySection( modifier: Modifier = Modifier, poster: String, showName: String, ticketName: String, ticketCount: Int, - price: Int, + totalPrice: Int, ) { Row( modifier = modifier, @@ -48,24 +105,62 @@ fun TicketSummarySection( contentScale = ContentScale.Crop, ) Column( - modifier = Modifier.padding(start = 16.dp), + modifier = Modifier.padding(start = 16.dp) ) { Text( text = showName, style = point1, color = Grey05, ) + Text( modifier = Modifier.padding(top = 4.dp), - text = "$ticketName / ${stringResource(R.string.ticket_count, ticketCount)}", - style = MaterialTheme.typography.labelMedium, + text = stringResource( + id = R.string.reservation_ticket_info_format, + ticketName, + ticketCount + ), color = Grey30, + style = MaterialTheme.typography.bodySmall, ) + Text( modifier = Modifier.padding(top = 4.dp), - text = stringResource(R.string.unit_won, price), - style = MaterialTheme.typography.labelMedium, + text = stringResource(id = R.string.unit_won, totalPrice), color = Grey30, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Preview +@Composable +private fun TicketSummaryPreview() { + BooltiTheme { + Surface { + TicketSummarySection( + modifier = Modifier.padding(16.dp), + poster = "", + showName = "2024 TOGETHER LUCKY CLUB", + showDate = LocalDateTime.now(), + ) + } + } +} + +@Preview +@Composable +private fun LegacyTicketSummaryPreview() { + BooltiTheme { + Surface { + LegacyTicketSummarySection( + modifier = Modifier.padding(24.dp), + poster = "", + showName = "2024 TOGETHER LUCKY CLUB", + ticketName = "일반 티켓 B", + ticketCount = 10, + totalPrice = 30000, ) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowNavigation.kt new file mode 100644 index 00000000..4c998285 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowNavigation.kt @@ -0,0 +1,22 @@ +package com.nexters.boolti.presentation.screen.qr + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination + +fun NavGraphBuilder.HostedShowScreen( + popBackStack: () -> Unit, + onClickShow: (showId: String, showName: String) -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = MainDestination.HostedShows.route + ) { + HostedShowScreen( + modifier = modifier, + onClickShow = onClickShow, + onClickBack = popBackStack + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowScreen.kt index 96e2a37d..003e23b4 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowScreen.kt @@ -12,15 +12,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -36,24 +32,30 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nexters.boolti.domain.model.Show import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Grey30 import com.nexters.boolti.presentation.theme.Grey60 -import com.nexters.boolti.presentation.theme.aggroFamily +import com.nexters.boolti.presentation.theme.point1 import java.time.LocalDate import java.time.LocalDateTime @Composable fun HostedShowScreen( - modifier: Modifier = Modifier, onClickBack: () -> Unit, onClickShow: (showId: String, showName: String) -> Unit, + modifier: Modifier = Modifier, viewModel: HostedShowViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( - topBar = { HostedShowToolbar(onClickBack) } + topBar = { + BtBackAppBar( + title = stringResource(R.string.hostedShowsTitle), + onClickBack = onClickBack, + ) + } ) { innerPadding -> if (uiState.shows.isEmpty()) { EmptyHostedShow(modifier = modifier.padding(innerPadding)) @@ -69,30 +71,6 @@ fun HostedShowScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HostedShowToolbar( - onClickBack: () -> Unit, -) { - TopAppBar( - title = { - Text(stringResource(R.string.hostedShowsTitle)) - }, navigationIcon = { - IconButton(onClick = onClickBack) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(R.string.description_navigate_back), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - titleContentColor = MaterialTheme.colorScheme.onBackground, - navigationIconContentColor = MaterialTheme.colorScheme.onBackground, - ) - ) -} - @Composable fun HostedShows( modifier: Modifier = Modifier, @@ -128,9 +106,8 @@ private fun HostedShowItem( Text( modifier = Modifier.weight(1f), text = show.name, - style = MaterialTheme.typography.bodyLarge, + style = point1, color = tint, - fontFamily = aggroFamily, ) Icon( painter = painterResource(id = R.drawable.ic_scan), diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowViewModel.kt index c7c6fd28..e831ca23 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/HostedShowViewModel.kt @@ -1,8 +1,8 @@ package com.nexters.boolti.presentation.screen.qr -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.HostRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,7 +16,7 @@ import javax.inject.Inject @HiltViewModel class HostedShowViewModel @Inject constructor( private val repository: HostRepository, -) : ViewModel() { +) : BaseViewModel() { private val _uiState = MutableStateFlow(HostedShowState()) val uiState = _uiState.asStateFlow() @@ -25,13 +25,13 @@ class HostedShowViewModel @Inject constructor( } private fun load() { - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { repository.getHostedShows() .onStart { _uiState.update { it.copy(loading = true) } }.catch { e -> - e.printStackTrace() _uiState.update { it.copy(loading = false) } + throw e }.singleOrNull()?.let { shows -> _uiState.update { it.copy(loading = false, shows = shows) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullNavigation.kt new file mode 100644 index 00000000..5ea75cd6 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullNavigation.kt @@ -0,0 +1,20 @@ +package com.nexters.boolti.presentation.screen.qr + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.data +import com.nexters.boolti.presentation.screen.ticketName + +fun NavGraphBuilder.QrFullScreen( + popBackStack: () -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = "${MainDestination.Qr.route}/{$data}?ticketName={$ticketName}", + arguments = MainDestination.Qr.arguments, + ) { + QrFullScreen(modifier = modifier) { popBackStack() } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullScreen.kt index 8111991d..c64ede2e 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullScreen.kt @@ -2,13 +2,19 @@ package com.nexters.boolti.presentation.screen.qr import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +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.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -17,6 +23,8 @@ import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.hilt.navigation.compose.hiltViewModel import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.Grey10 +import com.nexters.boolti.presentation.theme.Grey85 import com.nexters.boolti.presentation.theme.Grey90 import com.nexters.boolti.presentation.util.rememberQrBitmapPainter @@ -31,7 +39,7 @@ fun QrFullScreen( .background(Color.White) .fillMaxSize() ) { - val (closeButton, logo, qr) = createRefs() + val (closeButton, qr) = createRefs() IconButton( onClick = onClose, @@ -48,31 +56,45 @@ fun QrFullScreen( ) } - Image( - modifier = Modifier - .constrainAs(logo) { - centerHorizontallyTo(parent) - bottom.linkTo(qr.top, margin = 16.dp) - } - .width(84.dp), - painter = painterResource(R.drawable.ic_logo_boolti), - contentScale = ContentScale.FillWidth, - contentDescription = null, - ) - Image( + Column( modifier = Modifier .constrainAs(qr) { - centerVerticallyTo(parent) - centerHorizontallyTo(parent) + centerTo(parent) } - .background(Color.White) - .padding(8.dp), - painter = rememberQrBitmapPainter( - viewModel.data, - size = 260.dp, - ), - contentScale = ContentScale.Inside, - contentDescription = stringResource(R.string.description_qr), - ) + .background( + color = Grey10, + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(top = 16.dp), + text = viewModel.ticketName, + style = MaterialTheme.typography.titleMedium, + color = Grey85.copy(alpha = .85f), + ) + Image( + modifier = Modifier + .padding(vertical = 12.dp) + .clip(RoundedCornerShape(4.dp)) + .background(Color.White) + .padding(14.dp), + painter = rememberQrBitmapPainter( + viewModel.data, + size = 260.dp, + ), + contentScale = ContentScale.Inside, + contentDescription = stringResource(R.string.description_qr), + ) + Image( + modifier = Modifier + .width(84.dp) + .padding(bottom = 12.dp), + painter = painterResource(R.drawable.ic_logo_boolti), + contentScale = ContentScale.FillWidth, + contentDescription = null, + ) + } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullViewModel.kt index 597a92c3..b84f0df3 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullViewModel.kt @@ -9,6 +9,7 @@ import javax.inject.Inject class QrFullViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { + val ticketName: String = savedStateHandle["ticketName"] ?: "" val data: String = requireNotNull(savedStateHandle["data"]) { "QrFullViewModel 에 data 가 전달되지 않았습니다" } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrScanScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrScanScreen.kt index bb4b9506..56343eee 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrScanScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrScanScreen.kt @@ -7,12 +7,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize 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.RoundedCornerShape import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState @@ -30,8 +28,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.navigation.compose.hiltViewModel @@ -42,8 +38,13 @@ import com.nexters.boolti.presentation.QrScanEvent import com.nexters.boolti.presentation.QrScanViewModel import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.BTDialog +import com.nexters.boolti.presentation.component.BtCloseableAppBar +import com.nexters.boolti.presentation.component.CircleBgIcon import com.nexters.boolti.presentation.component.ToastSnackbarHost +import com.nexters.boolti.presentation.theme.Error import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.Success +import com.nexters.boolti.presentation.theme.Warning import kotlinx.coroutines.launch @Composable @@ -54,6 +55,7 @@ fun QrScanScreen( ) { var showEntryCodeDialog by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } + var snackbarIconId by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val successMessage = stringResource(R.string.message_ticket_validated) @@ -70,17 +72,18 @@ fun QrScanScreen( LaunchedEffect(viewModel.event) { scope.launch { viewModel.event.collect { event -> - val errMessage = when (event) { + val (iconId, errMessage) = when (event) { is QrScanEvent.ScanError -> { when (event.errorType) { - QrErrorType.ShowNotToday -> notTodayErrMessage - QrErrorType.UsedTicket -> usedTicketErrMessage - QrErrorType.TicketNotFound -> notMatchedErrMessage + QrErrorType.ShowNotToday -> Pair(R.drawable.ic_warning, notTodayErrMessage) + QrErrorType.UsedTicket -> Pair(R.drawable.ic_error, usedTicketErrMessage) + QrErrorType.TicketNotFound -> Pair(R.drawable.ic_error, notMatchedErrMessage) } } - is QrScanEvent.ScanSuccess -> successMessage + is QrScanEvent.ScanSuccess -> Pair(R.drawable.ic_error, successMessage) } + snackbarIconId = iconId snackbarHostState.showSnackbar(errMessage) } } @@ -88,13 +91,31 @@ fun QrScanScreen( Scaffold( topBar = { - QrScanToolbar(showName = uiState.showName, onClickClose = onClickClose) + BtCloseableAppBar( + title = uiState.showName, + onClickClose = onClickClose, + ) }, bottomBar = { QrScanBottombar { showEntryCodeDialog = true } }, snackbarHost = { - ToastSnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(bottom = 100.dp)) + ToastSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = 100.dp), + leadingIcon = { + snackbarIconId?.let { + CircleBgIcon( + painter = painterResource(it), + bgColor = when (it) { + R.drawable.ic_check -> Success + R.drawable.ic_error -> Error + else -> Warning + } + ) + } + }, + ) }, ) { innerPadding -> AndroidView( @@ -113,37 +134,6 @@ fun QrScanScreen( } } -@Composable -private fun QrScanToolbar( - showName: String, - onClickClose: () -> Unit, -) { - Row( - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .height(44.dp) - .padding(horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier - .padding(end = 20.dp) - .weight(1f), - text = showName, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - IconButton(onClick = onClickClose) { - Icon( - painter = painterResource(R.drawable.ic_close), - tint = MaterialTheme.colorScheme.onBackground, - contentDescription = stringResource(R.string.description_close_button), - ) - } - } -} - @Composable private fun QrScanBottombar(onClick: () -> Unit) { Box( diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/BankSelection.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/BankSelection.kt new file mode 100644 index 00000000..14c63134 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/BankSelection.kt @@ -0,0 +1,140 @@ +package com.nexters.boolti.presentation.screen.refund + +import androidx.compose.foundation.Image +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.PaddingValues +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.MainButton +import com.nexters.boolti.presentation.theme.Grey10 +import com.nexters.boolti.presentation.theme.Grey80 +import com.nexters.boolti.presentation.theme.Grey85 +import com.nexters.boolti.presentation.theme.marginHorizontal + +@Composable +fun BankSelection( + onDismiss: () -> Unit, + selectedBank: BankInfo?, + onClick: (bankInfo: BankInfo) -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.padding(bottom = 48.dp), + contentAlignment = Alignment.BottomCenter, + ) { + Column( + modifier = Modifier + .padding(horizontal = marginHorizontal) + .padding(bottom = 48.dp), + ) { + Text( + modifier = Modifier.padding(bottom = 12.dp), + text = stringResource(id = R.string.refund_bank_selection), + style = MaterialTheme.typography.titleLarge, + ) + LazyVerticalGrid( + contentPadding = PaddingValues(vertical = 12.dp), + columns = GridCells.Adaptive(minSize = 100.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + BankInfo.entries.forEach { bankInfo -> + item { + BackItem( + bankInfo = bankInfo, + onClick = onClick, + selected = if (selectedBank == null) null else selectedBank == bankInfo, + ) + } + } + } + } + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Grey85, + ) + ) + ) + ) + MainButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = marginHorizontal), + label = stringResource(id = R.string.refund_select_bank), + onClick = onDismiss, + enabled = selectedBank != null, + ) + } + } +} + +@Composable +fun BackItem( + onClick: (bankInfo: BankInfo) -> Unit, + bankInfo: BankInfo, + modifier: Modifier = Modifier, + selected: Boolean? = null, +) { + Box( + modifier = modifier + .height(74.dp) + .border( + shape = RoundedCornerShape(4.dp), + color = if (selected == true) Grey10 else Color.Transparent, + width = 1.dp, + ) + .clip(RoundedCornerShape(4.dp)) + .background(Grey80) + .clickable { + onClick(bankInfo) + }, + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier.alpha(alpha = if (selected == false) 0.4f else 1.0f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + modifier = modifier.size(32.dp), + painter = painterResource(bankInfo.icon), + contentDescription = null, + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = bankInfo.bankName, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/ReasonPage.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/ReasonPage.kt new file mode 100644 index 00000000..d0f0a866 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/ReasonPage.kt @@ -0,0 +1,70 @@ +package com.nexters.boolti.presentation.screen.refund + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +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.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BTTextField +import com.nexters.boolti.presentation.component.MainButton +import com.nexters.boolti.presentation.theme.marginHorizontal +import com.nexters.boolti.presentation.theme.point4 + +@Composable +fun ReasonPage( + reason: String, + onReasonChanged: (String) -> Unit, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val keyboardController = LocalSoftwareKeyboardController.current + val interactionSource = remember { MutableInteractionSource() } + + Column( + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null + ) { + keyboardController?.hide() + } + ) { + Text( + modifier = Modifier + .padding(top = 20.dp) + .padding(horizontal = marginHorizontal), + text = stringResource(id = R.string.refund_reason_label), + style = point4, + ) + BTTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = marginHorizontal) + .height(160.dp) + .padding(top = 20.dp), + text = reason, + onValueChanged = onReasonChanged, + placeholder = stringResource(id = R.string.refund_reason_hint), + ) + + Spacer(modifier = Modifier.weight(1.0f)) + MainButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = marginHorizontal) + .padding(bottom = 8.dp), + onClick = onNextClick, + enabled = reason.isNotBlank(), + label = stringResource(id = R.string.next) + ) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundInfoPage.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundInfoPage.kt new file mode 100644 index 00000000..6f225f5f --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundInfoPage.kt @@ -0,0 +1,444 @@ +package com.nexters.boolti.presentation.screen.refund + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +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.PaddingValues +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.nexters.boolti.domain.model.PaymentType +import com.nexters.boolti.domain.model.ReservationDetail +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BTTextField +import com.nexters.boolti.presentation.component.BtCheckBox +import com.nexters.boolti.presentation.component.MainButton +import com.nexters.boolti.presentation.extension.filterToPhoneNumber +import com.nexters.boolti.presentation.theme.Error +import com.nexters.boolti.presentation.theme.Grey10 +import com.nexters.boolti.presentation.theme.Grey15 +import com.nexters.boolti.presentation.theme.Grey30 +import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.Grey70 +import com.nexters.boolti.presentation.theme.Grey80 +import com.nexters.boolti.presentation.theme.Grey85 +import com.nexters.boolti.presentation.theme.marginHorizontal +import com.nexters.boolti.presentation.theme.point2 +import com.nexters.boolti.presentation.util.PhoneNumberVisualTransformation + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RefundInfoPage( + uiState: RefundUiState, + refundPolicy: List, + reservation: ReservationDetail, + onRequest: () -> Unit, + onNameChanged: (String) -> Unit, + onContactNumberChanged: (String) -> Unit, + onBankInfoChanged: (BankInfo) -> Unit, + onAccountNumberChanged: (String) -> Unit, + onRefundPolicyChecked: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var isSheetOpen by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + var showAccountError by remember { mutableStateOf(false) } + + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + ) { + Header( + reservation = reservation + ) + Section( + title = stringResource(id = R.string.refund_account_holder_info) + ) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.width(56.dp), + text = stringResource(id = R.string.name_label), + style = MaterialTheme.typography.bodySmall.copy(color = Grey30), + ) + BTTextField( + modifier = Modifier + .weight(1.0f), + text = uiState.name, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next + ), + placeholder = stringResource(id = R.string.refund_account_name_hint), + onValueChanged = onNameChanged + ) + } + + Row( + modifier = Modifier.padding(top = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.width(56.dp), + text = stringResource(id = R.string.contact_label), + style = MaterialTheme.typography.bodySmall.copy(color = Grey30), + ) + BTTextField( + modifier = Modifier + .weight(1.0f), + text = uiState.contact.filterToPhoneNumber(), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next + ), + placeholder = stringResource(id = R.string.ticketing_contact_placeholder), + onValueChanged = onContactNumberChanged, + visualTransformation = PhoneNumberVisualTransformation('-'), + ) + } + } + } + Section( + modifier = Modifier.padding(top = 12.dp), + title = stringResource(id = R.string.refund_account_info), + expandable = false, + ) { + Column { + Button( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + onClick = { isSheetOpen = true }, + shape = RoundedCornerShape(4.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceTint, + ), + contentPadding = PaddingValues(horizontal = 12.dp), + ) { + val bankSelection = stringResource(id = R.string.refund_bank_selection) + Text( + text = if (uiState.bankInfo == null) bankSelection else uiState.bankInfo.bankName, + style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), + ) + Spacer(modifier = Modifier.weight(1.0f)) + Icon( + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = stringResource(id = R.string.refund_bank_selection), + tint = Grey50, + ) + } + BTTextField( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .onFocusChanged { focusState -> + showAccountError = uiState.accountNumber.isNotEmpty() && + !uiState.isValidAccountNumber && + !focusState.isFocused + }, + text = uiState.accountNumber, + isError = showAccountError, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Done + ), + placeholder = stringResource(id = R.string.refund_account_number_hint), + onValueChanged = onAccountNumberChanged, + ) + if (showAccountError) { + Text( + modifier = Modifier.padding(top = 12.dp), + text = stringResource(id = R.string.validation_account), + style = MaterialTheme.typography.bodySmall.copy(color = Error), + ) + } + } + } + + Section( + modifier = Modifier.padding(top = 12.dp), + title = stringResource(id = R.string.refund_account_info), + expandable = false, + ) { + Column { + val paymentType = when (reservation.paymentType) { + PaymentType.ACCOUNT_TRANSFER -> stringResource(id = R.string.payment_account_transfer) + PaymentType.CARD -> stringResource(id = R.string.payment_card) + else -> stringResource(id = R.string.reservations_unknown) + } + + NormalRow( + key = stringResource(id = R.string.refund_price), + value = stringResource(id = R.string.unit_won, reservation.totalAmountPrice) + ) + NormalRow( + modifier = Modifier.padding(top = 16.dp), + key = stringResource(id = R.string.refund_method), + value = paymentType + ) + } + } + + Section( + modifier = Modifier.padding(vertical = 12.dp), + title = stringResource(id = R.string.refund_policy_label), + expandable = false, + ) { + Column { + refundPolicy.forEach { + PolicyLine(modifier = Modifier.padding(bottom = 4.dp), text = it) + } + Row( + modifier = Modifier + .padding(top = 20.dp) + .height(48.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .clickable { onRefundPolicyChecked(!uiState.refundPolicyChecked) } + .background(Grey85) + .border(width = 1.dp, color = Grey80, shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BtCheckBox( + modifier = Modifier.size(24.dp), + isSelected = uiState.refundPolicyChecked, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = stringResource(id = R.string.refund_confirm_policy), + color = Grey10, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + + Spacer(modifier = Modifier.weight(1.0f)) + MainButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = marginHorizontal) + .padding(top = 16.dp) + .padding(vertical = 8.dp), + onClick = onRequest, + enabled = uiState.isAbleToRequest, + label = stringResource(id = R.string.refund_button) + ) + } + + if (isSheetOpen) { + ModalBottomSheet( + modifier = Modifier.heightIn(max = 646.dp), + sheetState = sheetState, + onDismissRequest = { + isSheetOpen = false + }, + dragHandle = { + Box( + modifier = Modifier + .padding(top = 12.dp, bottom = 20.dp) + .size(45.dp, 4.dp) + .background(Grey70) + .clip(RoundedCornerShape(100.dp)), + ) + }, + containerColor = Grey85, + ) { + BankSelection( + selectedBank = uiState.bankInfo, + onClick = onBankInfoChanged, + onDismiss = { isSheetOpen = false }) + } + } +} + + +@Composable +private fun Header( + reservation: ReservationDetail, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(horizontal = marginHorizontal, vertical = 20.dp), + ) { + AsyncImage( + modifier = Modifier + .width(70.dp) + .height(98.dp) + .border(color = Grey80, width = 1.dp, shape = RoundedCornerShape(4.dp)) + .clip(shape = RoundedCornerShape(4.dp)), + model = reservation.showImage, + contentDescription = stringResource(id = R.string.description_poster), + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier.padding(start = 16.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = reservation.showName, + style = point2, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = stringResource( + id = R.string.reservation_ticket_info_format, + reservation.ticketName, + reservation.ticketCount + ), + style = MaterialTheme.typography.bodySmall.copy(color = Grey30), + ) + } + } +} + +@Composable +private fun Section( + title: String, + modifier: Modifier = Modifier, + defaultExpanded: Boolean = true, + expandable: Boolean = true, + content: @Composable () -> Unit, +) { + var expanded by remember { + mutableStateOf(defaultExpanded) + } + val rotation by animateFloatAsState( + targetValue = if (expanded) 0f else 180f, label = "rotationX" + ) + + Column( + modifier = modifier + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.surface), + ) { + val touchAreaModifier = if (expandable) { + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + } else { + Modifier.fillMaxWidth() + } + Row( + modifier = touchAreaModifier + .padding(horizontal = marginHorizontal) + .padding(vertical = 20.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy(color = Grey10), + ) + if (expandable) { + Icon( + modifier = Modifier.graphicsLayer { + rotationX = rotation + }, + painter = painterResource(id = R.drawable.ic_expand_24), + contentDescription = stringResource(R.string.description_expand), + tint = Grey50, + ) + } + } + AnimatedVisibility( + modifier = Modifier + .padding(horizontal = marginHorizontal) + .padding(bottom = 20.dp), + visible = expanded, + ) { + content() + } + } +} + +@Composable +private fun NormalRow( + key: String, + value: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier, + text = key, + style = MaterialTheme.typography.bodyLarge.copy(color = Grey30), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), + ) + } +} + +@Composable +private fun PolicyLine( + text: String, + modifier: Modifier = Modifier, +) { + Row(modifier = modifier) { + Box( + modifier = Modifier + .padding(horizontal = 8.dp, vertical = 7.dp) + .size(4.dp) + .clip(shape = RoundedCornerShape(2.dp)) + .background(color = Grey50), + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall.copy(color = Grey50), + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundNavigation.kt new file mode 100644 index 00000000..07509aea --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundNavigation.kt @@ -0,0 +1,19 @@ +package com.nexters.boolti.presentation.screen.refund + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.reservationId + +fun NavGraphBuilder.RefundScreen( + popBackStack: () -> Unit, +) { + composable( + route = "${MainDestination.Refund.route}/{$reservationId}", + arguments = MainDestination.Refund.arguments, + ) { + RefundScreen( + onBackPressed = popBackStack, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundScreen.kt index 3557dc7e..dda2ce89 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundScreen.kt @@ -1,44 +1,20 @@ package com.nexters.boolti.presentation.screen.refund -import android.widget.Toast -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -48,67 +24,41 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage -import com.nexters.boolti.domain.model.ReservationDetail import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.BTDialog -import com.nexters.boolti.presentation.component.BTTextField -import com.nexters.boolti.presentation.component.BtAppBar -import com.nexters.boolti.presentation.component.MainButton -import com.nexters.boolti.presentation.extension.filterToPhoneNumber -import com.nexters.boolti.presentation.theme.Error -import com.nexters.boolti.presentation.theme.Grey10 +import com.nexters.boolti.presentation.component.BtBackAppBar +import com.nexters.boolti.presentation.screen.LocalSnackbarController import com.nexters.boolti.presentation.theme.Grey15 import com.nexters.boolti.presentation.theme.Grey30 -import com.nexters.boolti.presentation.theme.Grey50 import com.nexters.boolti.presentation.theme.Grey70 import com.nexters.boolti.presentation.theme.Grey80 -import com.nexters.boolti.presentation.theme.Grey85 -import com.nexters.boolti.presentation.theme.marginHorizontal -import com.nexters.boolti.presentation.theme.point2 -import com.nexters.boolti.presentation.theme.point4 -import com.nexters.boolti.presentation.util.PhoneNumberVisualTransformation import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable fun RefundScreen( onBackPressed: () -> Unit, - modifier: Modifier = Modifier, viewModel: RefundViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val refundPolicy by viewModel.refundPolicy.collectAsStateWithLifecycle() val events = viewModel.events val scope = rememberCoroutineScope() - val context = LocalContext.current val pagerState = rememberPagerState { 2 } var openDialog by remember { mutableStateOf(false) } + val snackbarController = LocalSnackbarController.current val refundMessage = stringResource(id = R.string.refund_completed) LaunchedEffect(Unit) { events.collect { event -> when (event) { RefundEvent.SuccessfullyRefunded -> { - // TODO 스낵바로 변경 - Toast.makeText(context, refundMessage, Toast.LENGTH_LONG).show() + snackbarController.showMessage(refundMessage) onBackPressed() } } @@ -117,11 +67,12 @@ fun RefundScreen( Scaffold( topBar = { - BtAppBar( - title = stringResource(id = R.string.refund_button), onBackPressed = onBackPressed + BtBackAppBar( + title = stringResource(R.string.refund_button), + onClickBack = onBackPressed, ) }, - modifier = modifier, + modifier = Modifier, ) { innerPadding -> val reservation = uiState.reservation ?: return@Scaffold @@ -144,6 +95,7 @@ fun RefundScreen( } else { RefundInfoPage( uiState = uiState, + refundPolicy = refundPolicy, modifier = Modifier .fillMaxSize() .padding(innerPadding), @@ -153,6 +105,7 @@ fun RefundScreen( onBankInfoChanged = viewModel::updateBankInfo, onAccountNumberChanged = viewModel::updateAccountNumber, onRequest = { openDialog = true }, + onRefundPolicyChecked = viewModel::toggleRefundPolicyCheck, ) } } @@ -184,7 +137,7 @@ fun RefundScreen( value = uiState.name ) InfoRow( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 12.dp), type = stringResource(id = R.string.contact_label), value = StringBuilder(uiState.contact).apply { if (uiState.contact.length > 7) { @@ -194,472 +147,31 @@ fun RefundScreen( }.toString() ) InfoRow( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 12.dp), type = stringResource(id = R.string.bank_name), value = uiState.bankInfo?.bankName ?: "" ) InfoRow( - modifier = Modifier.padding(top = 8.dp), + modifier = Modifier.padding(top = 12.dp), type = stringResource(id = R.string.account_number), value = uiState.accountNumber ) - } - } - } - } -} - -@Composable -fun ReasonPage( - reason: String, - onReasonChanged: (String) -> Unit, - onNextClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val keyboardController = LocalSoftwareKeyboardController.current - val interactionSource = remember { MutableInteractionSource() } - - Column( - modifier = modifier.clickable( - interactionSource = interactionSource, - indication = null - ) { - keyboardController?.hide() - } - ) { - Text( - modifier = Modifier - .padding(top = 20.dp) - .padding(horizontal = marginHorizontal), - text = stringResource(id = R.string.refund_reason_label), - style = point4, - ) - BTTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = marginHorizontal) - .height(160.dp) - .padding(top = 20.dp), - text = reason, - onValueChanged = onReasonChanged, - placeholder = stringResource(id = R.string.refund_reason_hint), - ) - - Spacer(modifier = Modifier.weight(1.0f)) - MainButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = marginHorizontal) - .padding(bottom = 8.dp), - onClick = onNextClick, - enabled = reason.isNotBlank(), - label = stringResource(id = R.string.next) - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RefundInfoPage( - uiState: RefundUiState, - reservation: ReservationDetail, - onRequest: () -> Unit, - onNameChanged: (String) -> Unit, - onContactNumberChanged: (String) -> Unit, - onBankInfoChanged: (BankInfo) -> Unit, - onAccountNumberChanged: (String) -> Unit, - modifier: Modifier = Modifier, -) { - var isSheetOpen by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true - ) - var showNameError by remember { mutableStateOf(false) } - var showAccountError by remember { mutableStateOf(false) } - var showContactError by remember { mutableStateOf(false) } - - Column( - modifier = modifier.verticalScroll(rememberScrollState()), - ) { - Header( - reservation = reservation - ) - Section( - title = stringResource(id = R.string.refund_account_holder_info) - ) { - Column { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.width(56.dp), - text = stringResource(id = R.string.name_label), - style = MaterialTheme.typography.bodySmall.copy(color = Grey30), - ) - BTTextField( - modifier = Modifier - .weight(1.0f) - .onFocusChanged { focusState -> - showNameError = uiState.name.isNotEmpty() && - !uiState.isValidName && - !focusState.isFocused - }, - isError = showNameError, - text = uiState.name, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Next - ), - placeholder = stringResource(id = R.string.refund_account_name_hint), - onValueChanged = onNameChanged - ) - } - if (showNameError) { - Text( - modifier = Modifier.padding(start = 56.dp, top = 12.dp), - text = stringResource(id = R.string.validation_name), - style = MaterialTheme.typography.bodySmall.copy(color = Error), - ) - } - - Row( - modifier = Modifier.padding(top = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.width(56.dp), - text = stringResource(id = R.string.contact_label), - style = MaterialTheme.typography.bodySmall.copy(color = Grey30), - ) - BTTextField( - modifier = Modifier - .weight(1.0f) - .onFocusChanged { focusState -> - showContactError = - uiState.contact.isNotEmpty() && - !uiState.isValidContact && - !focusState.isFocused - }, - text = uiState.contact.filterToPhoneNumber(), - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Phone, - imeAction = ImeAction.Next - ), - isError = showContactError, - placeholder = stringResource(id = R.string.ticketing_contact_placeholder), - onValueChanged = onContactNumberChanged, - visualTransformation = PhoneNumberVisualTransformation('-'), - ) - } - if (showContactError) { - Text( - modifier = Modifier.padding(start = 56.dp, top = 12.dp), - text = stringResource(id = R.string.validation_contact), - style = MaterialTheme.typography.bodySmall.copy(color = Error), - ) - } - } - } - Section( - modifier = Modifier.padding(top = 12.dp), - title = stringResource(id = R.string.refund_account_info), - expandable = false, - ) { - Column { - Button( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - onClick = { isSheetOpen = true }, - shape = RoundedCornerShape(4.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceTint, - ), - contentPadding = PaddingValues(horizontal = 12.dp), - ) { - val bankSelection = stringResource(id = R.string.refund_bank_selection) - Text( - text = if (uiState.bankInfo == null) bankSelection else uiState.bankInfo.bankName, - style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), - ) - Spacer(modifier = Modifier.weight(1.0f)) - Icon( - painter = painterResource(id = R.drawable.ic_arrow_down), - contentDescription = stringResource(id = R.string.refund_bank_selection), - tint = Grey50, - ) - } - BTTextField( - modifier = Modifier - .fillMaxWidth() - .padding(top = 12.dp) - .onFocusChanged { focusState -> - showAccountError = uiState.accountNumber.isNotEmpty() && - !uiState.isValidAccountNumber && - !focusState.isFocused - }, - text = uiState.accountNumber, - isError = showAccountError, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Decimal, - imeAction = ImeAction.Done - ), - placeholder = stringResource(id = R.string.refund_account_number_hint), - onValueChanged = onAccountNumberChanged, - ) - if (showAccountError) { - Text( + HorizontalDivider( modifier = Modifier.padding(top = 12.dp), - text = stringResource(id = R.string.validation_account), - style = MaterialTheme.typography.bodySmall.copy(color = Error), + thickness = 1.dp, + color = Grey70, ) - } - } - } - - Spacer(modifier = Modifier.weight(1.0f)) - MainButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = marginHorizontal) - .padding(bottom = 8.dp), - onClick = onRequest, - enabled = uiState.isAbleToRequest, - label = stringResource(id = R.string.refund_button) - ) - } - - if (isSheetOpen) { - ModalBottomSheet( - modifier = Modifier.heightIn(max = 646.dp), - sheetState = sheetState, - onDismissRequest = { - isSheetOpen = false - }, - dragHandle = { - Box( - modifier = Modifier - .padding(top = 12.dp, bottom = 20.dp) - .size(45.dp, 4.dp) - .background(Grey70) - .clip(RoundedCornerShape(100.dp)), - ) - }, - containerColor = Grey85, - ) { - BankSelection( - selectedBank = uiState.bankInfo, - onClick = onBankInfoChanged, - onDismiss = { isSheetOpen = false }) - } - } -} - -@Composable -private fun Header( - reservation: ReservationDetail, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.padding(horizontal = marginHorizontal, vertical = 20.dp), - ) { - AsyncImage( - modifier = Modifier - .width(70.dp) - .height(98.dp) - .border(color = Grey80, width = 1.dp, shape = RoundedCornerShape(4.dp)) - .clip(shape = RoundedCornerShape(4.dp)), - model = reservation.showImage, - contentDescription = stringResource(id = R.string.description_poster), - contentScale = ContentScale.Crop, - ) - Column( - modifier = Modifier.padding(start = 16.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = reservation.showName, - style = point2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = stringResource( - id = R.string.reservation_ticket_info_format, - reservation.ticketName, - reservation.ticketCount - ), - style = MaterialTheme.typography.bodySmall.copy(color = Grey30), - ) - } - } -} - -@Composable -private fun Section( - title: String, - modifier: Modifier = Modifier, - defaultExpanded: Boolean = true, - expandable: Boolean = true, - content: @Composable () -> Unit, -) { - var expanded by remember { - mutableStateOf(defaultExpanded) - } - val rotation by animateFloatAsState( - targetValue = if (expanded) 0f else 180f, label = "rotationX" - ) - - Column( - modifier = modifier - .fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surface), - ) { - val touchAreaModifier = if (expandable) { - Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - } else { - Modifier.fillMaxWidth() - } - Row( - modifier = touchAreaModifier - .padding(horizontal = marginHorizontal) - .padding(vertical = 20.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = title, - style = MaterialTheme.typography.titleLarge.copy(color = Grey10), - ) - if (expandable) { - Icon( - modifier = Modifier.graphicsLayer { - rotationX = rotation - }, - painter = painterResource(id = R.drawable.ic_expand_24), - contentDescription = stringResource(R.string.description_expand), - tint = Grey50, - ) - } - } - AnimatedVisibility( - modifier = Modifier - .padding(horizontal = marginHorizontal) - .padding(bottom = 20.dp), - visible = expanded, - ) { - content() - } - } -} - -@Composable -fun BankSelection( - onDismiss: () -> Unit, - selectedBank: BankInfo?, - onClick: (bankInfo: BankInfo) -> Unit, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier.padding(bottom = 48.dp), - contentAlignment = Alignment.BottomCenter, - ) { - Column( - modifier = Modifier - .padding(horizontal = marginHorizontal) - .padding(bottom = 48.dp), - ) { - Text( - modifier = Modifier.padding(bottom = 12.dp), - text = stringResource(id = R.string.refund_bank_selection), - style = MaterialTheme.typography.titleLarge, - ) - LazyVerticalGrid( - contentPadding = PaddingValues(vertical = 12.dp), - columns = GridCells.Adaptive(minSize = 100.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - BankInfo.entries.forEach { bankInfo -> - item { - BackItem( - bankInfo = bankInfo, - onClick = onClick, - selected = if (selectedBank == null) null else selectedBank == bankInfo, + InfoRow( + modifier = Modifier.padding(top = 12.dp), + type = stringResource(id = R.string.refund_price), + value = stringResource( + id = R.string.unit_won, + uiState.reservation!!.totalAmountPrice, ) - } + ) } } } - Column { - Box( - modifier = Modifier - .fillMaxWidth() - .height(16.dp) - .background( - brush = Brush.verticalGradient( - colors = listOf( - Color.Transparent, - Grey85, - ) - ) - ) - ) - MainButton( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = marginHorizontal), - label = stringResource(id = R.string.refund_select_bank), - onClick = onDismiss, - enabled = selectedBank != null, - ) - } - } -} - -@Composable -fun BackItem( - onClick: (bankInfo: BankInfo) -> Unit, - bankInfo: BankInfo, - modifier: Modifier = Modifier, - selected: Boolean? = null, -) { - Box( - modifier = modifier - .height(74.dp) - .border( - shape = RoundedCornerShape(4.dp), - color = if (selected == true) Grey10 else Color.Transparent, - width = 1.dp, - ) - .clip(RoundedCornerShape(4.dp)) - .background(Grey80) - .clickable { - onClick(bankInfo) - }, - contentAlignment = Alignment.Center, - ) { - Column( - modifier = Modifier.alpha(alpha = if (selected == false) 0.4f else 1.0f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Image( - modifier = modifier.size(32.dp), - painter = painterResource(bankInfo.icon), - contentDescription = null, - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = bankInfo.bankName, - style = MaterialTheme.typography.bodySmall, - ) - } } } @@ -678,10 +190,9 @@ fun InfoRow( style = MaterialTheme.typography.bodySmall.copy(color = Grey30), ) Text( - modifier = Modifier.weight(1.0f), + modifier = Modifier.padding(start = 12.dp), text = value, style = MaterialTheme.typography.bodySmall.copy(color = Grey15), - textAlign = TextAlign.End, ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundUiState.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundUiState.kt index 20df78d4..6e7fdc5c 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundUiState.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundUiState.kt @@ -9,23 +9,23 @@ data class RefundUiState( val bankInfo: BankInfo? = null, val accountNumber: String = "", val reservation: ReservationDetail? = null, + val refundPolicyChecked: Boolean = false ) { - val isValidName: Boolean get() { - val regex = "^[가-힣]{2,10}$".toRegex() - return regex.matches(name) - } + val isValidName: Boolean get() = name.isNotBlank() - val isValidContact: Boolean get() { - val regex = "^0[0-9]{8,10}$".toRegex() - return regex.matches(contact) - } + val isValidContact: Boolean get() = contact.isNotBlank() val isValidAccountNumber: Boolean get() { val regex = "^[0-9]{11,14}$".toRegex() return regex.matches(accountNumber) } - val isAbleToRequest: Boolean get() { - return reason.isNotBlank() && isValidName && isValidContact && (bankInfo != null) && isValidAccountNumber - } -} \ No newline at end of file + val isAbleToRequest: Boolean get() = + reason.isNotBlank() && + isValidName && + isValidContact && + (bankInfo != null) && + isValidAccountNumber && + refundPolicyChecked + +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundViewModel.kt index d46bf03f..a5bb87a9 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/refund/RefundViewModel.kt @@ -1,10 +1,11 @@ package com.nexters.boolti.presentation.screen.refund import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.ReservationRepository import com.nexters.boolti.domain.request.RefundRequest +import com.nexters.boolti.domain.usecase.GetRefundPolicyUsecase +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -12,18 +13,19 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel class RefundViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val reservationRepository: ReservationRepository, -) : ViewModel() { + private val getRefundPolicyUsecase: GetRefundPolicyUsecase, +) : BaseViewModel() { private val reservationId: String = checkNotNull(savedStateHandle["reservationId"]) { "reservationId가 전달되어야 합니다." } @@ -31,11 +33,15 @@ class RefundViewModel @Inject constructor( private val _uiState: MutableStateFlow = MutableStateFlow(RefundUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _refundPolicy = MutableStateFlow>(emptyList()) + val refundPolicy = _refundPolicy.asStateFlow() + private val _events = MutableSharedFlow() val events: SharedFlow = _events.asSharedFlow() init { fetchReservation() + fetchRefundPolicy() } private fun sendEvent(event: RefundEvent) { @@ -49,10 +55,7 @@ class RefundViewModel @Inject constructor( .onEach { reservation -> _uiState.update { it.copy(reservation = reservation) } } - .catch { - it.printStackTrace() - } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } fun refund() { @@ -66,9 +69,7 @@ class RefundViewModel @Inject constructor( ) reservationRepository.refund(request).onEach { sendEvent(RefundEvent.SuccessfullyRefunded) - }.catch { - it.printStackTrace() - }.launchIn(viewModelScope) + }.launchIn(viewModelScope + recordExceptionHandler) } fun updateReason(newReason: String) { @@ -90,4 +91,16 @@ class RefundViewModel @Inject constructor( fun updateAccountNumber(newAccountNumber: String) { _uiState.update { it.copy(accountNumber = newAccountNumber) } } -} \ No newline at end of file + + fun toggleRefundPolicyCheck(selected: Boolean) { + _uiState.update { it.copy(refundPolicyChecked = selected) } + } + + private fun fetchRefundPolicy() { + getRefundPolicyUsecase() + .onEach { refundPolicy -> + _refundPolicy.value = refundPolicy + } + .launchIn(viewModelScope + recordExceptionHandler) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportNavigation.kt new file mode 100644 index 00000000..969e5657 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportNavigation.kt @@ -0,0 +1,21 @@ +package com.nexters.boolti.presentation.screen.report + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable + +fun NavGraphBuilder.ReportScreen( + navigateToHome: () -> Unit, + popBackStack: () -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = "report/{showId}", + ) { + ReportScreen( + onBackPressed = popBackStack, + popupToHome = navigateToHome, + modifier = modifier, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportScreen.kt index 5a099ec7..d92cdab4 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/report/ReportScreen.kt @@ -8,18 +8,13 @@ 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.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -28,12 +23,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.BTTextField -import com.nexters.boolti.presentation.component.BtAppBar +import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.component.MainButton -import com.nexters.boolti.presentation.theme.Grey10 import com.nexters.boolti.presentation.theme.Grey30 -import com.nexters.boolti.presentation.theme.Grey70 -import com.nexters.boolti.presentation.theme.Grey85 import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point4 @@ -58,7 +50,7 @@ fun ReportScreen( keyboardController?.hide() }, topBar = { - BtAppBar(title = stringResource(id = R.string.report), onBackPressed = onBackPressed) + BtBackAppBar(title = stringResource(id = R.string.report), onClickBack = onBackPressed) } ) { innerPadding -> Column( @@ -103,4 +95,4 @@ fun ReportScreen( ) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailNavigation.kt new file mode 100644 index 00000000..3ff013a4 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailNavigation.kt @@ -0,0 +1,21 @@ +package com.nexters.boolti.presentation.screen.reservations + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.reservationId + +fun NavGraphBuilder.ReservationDetailScreen( + navigateTo: (String) -> Unit, + popBackStack: () -> Unit, +) { + composable( + route = "${MainDestination.ReservationDetail.route}/{$reservationId}", + arguments = MainDestination.ReservationDetail.arguments, + ) { + ReservationDetailScreen( + onBackPressed = popBackStack, + navigateToRefund = { id -> navigateTo("${MainDestination.Refund.route}/$id") }, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailScreen.kt index e757f6d3..5e847038 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailScreen.kt @@ -24,17 +24,14 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -45,6 +42,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -54,10 +52,10 @@ import com.nexters.boolti.domain.model.PaymentType import com.nexters.boolti.domain.model.ReservationDetail import com.nexters.boolti.domain.model.ReservationState import com.nexters.boolti.presentation.R -import com.nexters.boolti.presentation.component.CopyButton -import com.nexters.boolti.presentation.component.ToastSnackbarHost +import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.constants.datetimeFormat import com.nexters.boolti.presentation.extension.toDescriptionAndColorPair +import com.nexters.boolti.presentation.screen.LocalSnackbarController import com.nexters.boolti.presentation.theme.Grey10 import com.nexters.boolti.presentation.theme.Grey15 import com.nexters.boolti.presentation.theme.Grey20 @@ -67,7 +65,7 @@ import com.nexters.boolti.presentation.theme.Grey80 import com.nexters.boolti.presentation.theme.Grey90 import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point2 -import kotlinx.coroutines.launch +import java.time.LocalDateTime @Composable fun ReservationDetailScreen( @@ -78,8 +76,6 @@ fun ReservationDetailScreen( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val refundPolicy by viewModel.refundPolicy.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() LaunchedEffect(Unit) { viewModel.fetchReservation() @@ -87,13 +83,13 @@ fun ReservationDetailScreen( Scaffold( modifier = modifier, - snackbarHost = { - ToastSnackbarHost( - modifier = Modifier.padding(bottom = 80.dp), - hostState = snackbarHostState, + topBar = { + BtBackAppBar( + title = stringResource(id = R.string.reservation_detail), + onClickBack = onBackPressed, ) }, - topBar = { ReservationDetailAppBar(onBackPressed = onBackPressed) }) { innerPadding -> + ) { innerPadding -> val state = uiState if (state !is ReservationDetailUiState.Success) return@Scaffold @@ -104,29 +100,45 @@ fun ReservationDetailScreen( .padding(innerPadding) .verticalScroll(scrollState) ) { - Text( - modifier = Modifier - .padding(horizontal = marginHorizontal) - .padding(top = 12.dp), - text = "No. ${state.reservation.id}", - style = MaterialTheme.typography.bodySmall.copy(color = Grey50), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val (textId, textColor) = state.reservation.reservationState.toDescriptionAndColorPair() + + Text( + modifier = Modifier + .padding(start = marginHorizontal) + .padding(top = 12.dp), + text = "No. ${state.reservation.csReservationId}", + style = MaterialTheme.typography.bodySmall.copy(color = Grey50), + ) + Text( + modifier = Modifier + .padding(end = marginHorizontal) + .padding(top = 12.dp), + text = stringResource(id = textId), + style = MaterialTheme.typography.bodySmall.copy(color = textColor) + ) + } Header(reservation = state.reservation) if (!state.reservation.isInviteTicket) { - DepositInfo( - reservation = state.reservation, - showMessage = { message -> - scope.launch { snackbarHostState.showSnackbar(message) } - }) + DepositInfo(reservation = state.reservation) } - PaymentInfo(reservation = state.reservation) - TicketInfo(reservation = state.reservation) TicketHolderInfo(reservation = state.reservation) if (!state.reservation.isInviteTicket) DepositorInfo(reservation = state.reservation) + TicketInfo(reservation = state.reservation) + PaymentInfo(reservation = state.reservation) + if (state.reservation.reservationState == ReservationState.REFUNDED) { + RefundInfo(reservation = state.reservation) + } if (!state.reservation.isInviteTicket) RefundPolicy(refundPolicy = refundPolicy) Spacer(modifier = Modifier.height(40.dp)) - if (state.reservation.reservationState == ReservationState.RESERVED && - !state.reservation.isInviteTicket + if ( + state.reservation.reservationState == ReservationState.RESERVED && + !state.reservation.isInviteTicket && + state.reservation.totalAmountPrice > 0 && + state.reservation.salesEndDateTime >= LocalDateTime.now() ) { RefundButton( modifier = Modifier.padding(horizontal = marginHorizontal, vertical = 8.dp), @@ -137,36 +149,6 @@ fun ReservationDetailScreen( } } -@Composable -private fun ReservationDetailAppBar( - onBackPressed: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .height(44.dp) - .background(color = MaterialTheme.colorScheme.background), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - modifier = Modifier.size(width = 48.dp, height = 44.dp), onClick = onBackPressed - ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(id = R.string.description_navigate_back), - modifier - .padding(start = marginHorizontal) - .size(width = 24.dp, height = 24.dp) - ) - } - Text( - text = stringResource(id = R.string.reservation_detail), - style = MaterialTheme.typography.titleMedium.copy(color = Grey10), - ) - } -} - @Composable private fun Header( reservation: ReservationDetail, @@ -211,50 +193,59 @@ private fun Header( @Composable private fun DepositInfo( - reservation: ReservationDetail, - showMessage: (message: String) -> Unit, modifier: Modifier = Modifier, + reservation: ReservationDetail, ) { + val snackbarController = LocalSnackbarController.current + Section( modifier = modifier, title = stringResource(id = R.string.reservation_account_info), ) { Column { - DepositInfoRow( + NormalRow( modifier = Modifier .height(32.dp) .padding(bottom = 8.dp), key = stringResource(id = R.string.bank_name), value = reservation.bankName, ) - DepositInfoRow( + Row( modifier = Modifier + .fillMaxWidth() .height(40.dp) .padding(bottom = 2.dp), - key = stringResource(id = R.string.account_number), - value = reservation.accountNumber, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { val clipboardManager = LocalClipboardManager.current val copiedMessage = stringResource(id = R.string.account_number_copied_message) - CopyButton( - label = stringResource(id = R.string.copy), - onClick = { + Text( + modifier = Modifier, + text = stringResource(id = R.string.account_number), + style = MaterialTheme.typography.bodyLarge.copy(color = Grey30), + ) + Text( + modifier = Modifier.clickable { clipboardManager.setText(AnnotatedString(reservation.accountNumber)) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { - showMessage(copiedMessage) + snackbarController.showMessage(copiedMessage) } }, + text = reservation.accountNumber, + style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), + textDecoration = TextDecoration.Underline, ) } - DepositInfoRow( + NormalRow( modifier = Modifier .height(40.dp) .padding(bottom = 2.dp), key = stringResource(id = R.string.account_holder), value = reservation.accountHolder, ) - DepositInfoRow( + NormalRow( modifier = Modifier .height(40.dp) .padding(bottom = 2.dp), @@ -265,33 +256,6 @@ private fun DepositInfo( } } -@Composable -private fun DepositInfoRow( - key: String, - value: String, - modifier: Modifier = Modifier, - content: (@Composable () -> Unit)? = null, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier - .padding(end = 20.dp) - .width(80.dp), - text = key, - style = MaterialTheme.typography.bodyLarge.copy(color = Grey30), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), - ) - Spacer(modifier = Modifier.weight(1.0f)) - content?.invoke() - } -} - @Composable private fun PaymentInfo( reservation: ReservationDetail, @@ -299,7 +263,7 @@ private fun PaymentInfo( ) { Section( modifier = modifier.padding(top = 12.dp), - title = stringResource(id = R.string.payment_info_label), + title = stringResource(id = R.string.payment_state_label), ) { val paymentType = when (reservation.paymentType) { PaymentType.ACCOUNT_TRANSFER -> stringResource(id = R.string.payment_account_transfer) @@ -307,23 +271,46 @@ private fun PaymentInfo( else -> stringResource(id = R.string.reservations_unknown) } - val (stateStringId, _) = reservation.reservationState.toDescriptionAndColorPair() - Column { NormalRow( - modifier = Modifier.padding(bottom = 8.dp), + modifier = Modifier.padding(bottom = 10.dp), + key = stringResource(id = R.string.total_payment_amount_label), + value = stringResource(id = R.string.unit_won, reservation.totalAmountPrice) + ) + NormalRow( + modifier = Modifier.padding(top = 8.dp, bottom = 8.dp), key = stringResource(id = R.string.payment_type_label), value = if (reservation.isInviteTicket) stringResource(id = R.string.invite_code_label) else paymentType ) + } + } +} + +@Composable +private fun RefundInfo( + reservation: ReservationDetail, +) { + Section( + modifier = Modifier.padding(top = 12.dp), + title = stringResource(id = R.string.reservation_breakdown_of_refund), + ) { + Column { + val paymentType = when (reservation.paymentType) { + PaymentType.ACCOUNT_TRANSFER -> stringResource(id = R.string.payment_account_transfer) + PaymentType.CARD -> stringResource(id = R.string.payment_card) + else -> stringResource(id = R.string.reservations_unknown) + } + NormalRow( - modifier = Modifier.padding(top = 8.dp, bottom = 10.dp), - key = stringResource(id = R.string.total_payment_amount_label), - value = stringResource(id = R.string.unit_won, reservation.totalAmountPrice) + modifier = Modifier.padding(bottom = 8.dp), + key = stringResource(id = R.string.reservation_price_of_refund), + value = stringResource(id = R.string.unit_won, reservation.totalAmountPrice), ) + NormalRow( modifier = Modifier.padding(top = 8.dp, bottom = 10.dp), - key = stringResource(id = R.string.payment_state_label), - value = stringResource(id = stateStringId) + key = stringResource(id = R.string.refund_method), + value = paymentType, ) } } @@ -352,12 +339,6 @@ private fun TicketInfo( reservation.ticketCount ), ) - NormalRow( - modifier = Modifier.padding(top = 8.dp, bottom = 10.dp), - key = stringResource(id = R.string.reservation_datetime), - value = reservation.completedDateTime?.format(datetimeFormat) - ?: stringResource(id = R.string.reservation_before_completion), - ) } } } @@ -549,4 +530,4 @@ private fun RefundButton( style = MaterialTheme.typography.titleMedium ) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailViewModel.kt index 2f2bd52a..81c5fb96 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationDetailViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.ConfigRepository import com.nexters.boolti.domain.repository.ReservationRepository import com.nexters.boolti.domain.usecase.GetRefundPolicyUsecase +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel @@ -22,7 +24,7 @@ class ReservationDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val reservationRepository: ReservationRepository, private val getRefundPolicyUsecase: GetRefundPolicyUsecase, -) : ViewModel() { +) : BaseViewModel() { private val reservationId: String = checkNotNull(savedStateHandle["reservationId"]) { "reservationId가 전달되어야 합니다." } @@ -46,18 +48,17 @@ class ReservationDetailViewModel @Inject constructor( _uiState.update { ReservationDetailUiState.Success(reservation) } } .catch { - it.printStackTrace() _uiState.update { ReservationDetailUiState.Error() } + throw it } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } private fun fetchRefundPolicy() { getRefundPolicyUsecase() - .catch { it.printStackTrace() } .onEach { refundPolicy -> _refundPolicy.value = refundPolicy } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsNavigation.kt new file mode 100644 index 00000000..86a64157 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsNavigation.kt @@ -0,0 +1,21 @@ +package com.nexters.boolti.presentation.screen.reservations + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.reservationId + +fun NavGraphBuilder.ReservationsScreen( + navigateTo: (String) -> Unit, + popBackStack: () -> Unit, +) { + composable( + route = MainDestination.Reservations.route, + ) { + ReservationsScreen( + onBackPressed = popBackStack, + navigateToDetail = { reservationId -> + navigateTo("${MainDestination.Reservations.route}/$reservationId") + }) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsScreen.kt index 4180fab3..304b00ba 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsScreen.kt @@ -35,7 +35,7 @@ import coil.compose.AsyncImage import com.nexters.boolti.domain.model.Reservation import com.nexters.boolti.domain.model.ReservationState import com.nexters.boolti.presentation.R -import com.nexters.boolti.presentation.component.BtAppBar +import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.component.BtCircularProgressIndicator import com.nexters.boolti.presentation.extension.toDescriptionAndColorPair import com.nexters.boolti.presentation.theme.Grey05 @@ -52,21 +52,17 @@ import java.time.format.DateTimeFormatter fun ReservationsScreen( onBackPressed: () -> Unit, navigateToDetail: (reservationId: String) -> Unit, - modifier: Modifier = Modifier, viewModel: ReservationsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( topBar = { - BtAppBar( - title = stringResource(id = R.string.my_ticketing_history), - onBackPressed = onBackPressed, - ) + BtBackAppBar(title = stringResource(id = R.string.my_ticketing_history), onClickBack = onBackPressed) } ) { innerPadding -> Box( - modifier = modifier + modifier = Modifier .padding(innerPadding) .fillMaxSize(), ) { @@ -236,4 +232,4 @@ fun ReservationStateLabel( text = stringResource(id = stringId), style = MaterialTheme.typography.bodySmall.copy(color = color), ) -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsViewModel.kt index 390686a1..3e3cf723 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/reservations/ReservationsViewModel.kt @@ -1,10 +1,9 @@ package com.nexters.boolti.presentation.screen.reservations -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.ReservationRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -13,12 +12,13 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel class ReservationsViewModel @Inject constructor( private val reservationRepository: ReservationRepository, -) : ViewModel() { +) : BaseViewModel() { private val _uiState: MutableStateFlow = MutableStateFlow(ReservationsUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() @@ -36,9 +36,9 @@ class ReservationsViewModel @Inject constructor( _uiState.update { ReservationsUiState.Success(reservations) } } .catch { - it.printStackTrace() _uiState.update { ReservationsUiState.Error } + throw it } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentNavigation.kt new file mode 100644 index 00000000..8a3f347b --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentNavigation.kt @@ -0,0 +1,25 @@ +package com.nexters.boolti.presentation.screen.show + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable + +fun NavGraphBuilder.ShowDetailContentScreen( + popBackStack: () -> Unit, + getSharedViewModel: @Composable (NavBackStackEntry) -> ShowDetailViewModel, + modifier: Modifier = Modifier, +) { + composable( + route = "content", + ) { entry -> + val showViewModel: ShowDetailViewModel = getSharedViewModel(entry) + + ShowDetailContentScreen( + modifier = modifier, + viewModel = showViewModel, + onBackPressed = popBackStack + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentScreen.kt index b85253e5..3a9b77e3 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailContentScreen.kt @@ -1,29 +1,20 @@ package com.nexters.boolti.presentation.screen.show -import androidx.compose.foundation.background -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.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme 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.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nexters.boolti.presentation.R -import com.nexters.boolti.presentation.theme.Grey10 +import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.theme.Grey30 import com.nexters.boolti.presentation.theme.marginHorizontal @@ -38,7 +29,12 @@ fun ShowDetailContentScreen( Scaffold( modifier = modifier, - topBar = { ShowDetailContentAppBar(onBackPressed = onBackPressed) } + topBar = { + BtBackAppBar( + title = stringResource(id = R.string.ticketing_all_content_title), + onClickBack = onBackPressed, + ) + }, ) { innerPadding -> Text( modifier = Modifier @@ -51,33 +47,3 @@ fun ShowDetailContentScreen( ) } } - -@Composable -private fun ShowDetailContentAppBar( - modifier: Modifier = Modifier, - onBackPressed: () -> Unit, -) { - Row( - modifier = modifier - .fillMaxWidth() - .height(44.dp) - .background(color = MaterialTheme.colorScheme.background), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - modifier = Modifier.size(width = 48.dp, height = 44.dp), onClick = onBackPressed - ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(id = R.string.description_navigate_back), - modifier - .padding(start = marginHorizontal) - .size(width = 24.dp, height = 24.dp) - ) - } - Text( - text = stringResource(id = R.string.ticketing_all_content_title), - style = MaterialTheme.typography.titleMedium.copy(color = Grey10), - ) - } -} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailEvent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailEvent.kt new file mode 100644 index 00000000..e6abfb2f --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailEvent.kt @@ -0,0 +1,6 @@ +package com.nexters.boolti.presentation.screen.show + +sealed interface ShowDetailEvent { + data object PopBackStack : ShowDetailEvent + data class NavigateToImages(val index: Int) : ShowDetailEvent +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailNavigation.kt new file mode 100644 index 00000000..f07e20d2 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailNavigation.kt @@ -0,0 +1,38 @@ +package com.nexters.boolti.presentation.screen.show + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable + +fun NavGraphBuilder.ShowDetailScreen( + navigateTo: (String) -> Unit, + popBackStack: () -> Unit, + navigateToHome: () -> Unit, + getSharedViewModel: @Composable (NavBackStackEntry) -> ShowDetailViewModel, + modifier: Modifier = Modifier, +) { + composable( + route = "detail", + ) { entry -> + val showViewModel: ShowDetailViewModel = getSharedViewModel(entry) + + ShowDetailScreen( + modifier = modifier, + onBack = popBackStack, + onClickHome = navigateToHome, + onClickContent = { navigateTo("content") }, + onTicketSelected = { showId, ticketId, ticketCount, isInviteTicket -> + navigateTo("ticketing/$showId?salesTicketId=$ticketId&ticketCount=$ticketCount&inviteTicket=$isInviteTicket") + }, + viewModel = showViewModel, + navigateToLogin = { navigateTo("login") }, + navigateToImages = { index -> navigateTo("images/$index") }, + navigateToReport = { + val showId = entry.arguments?.getString("showId") + navigateTo("report/$showId") + } + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailScreen.kt index cd59ec8a..30518e75 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailScreen.kt @@ -1,7 +1,9 @@ package com.nexters.boolti.presentation.screen.show import android.content.Intent +import android.net.Uri import android.os.Build +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -16,7 +18,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -24,13 +25,11 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Divider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,31 +44,35 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.firebase.Firebase +import com.google.firebase.dynamiclinks.androidParameters +import com.google.firebase.dynamiclinks.dynamicLinks +import com.google.firebase.dynamiclinks.iosParameters +import com.google.firebase.dynamiclinks.shortLinkAsync import com.nexters.boolti.domain.model.ShowDetail import com.nexters.boolti.domain.model.ShowState import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BtAppBar +import com.nexters.boolti.presentation.component.BtAppBarDefaults import com.nexters.boolti.presentation.component.CopyButton import com.nexters.boolti.presentation.component.MainButton -import com.nexters.boolti.presentation.component.ToastSnackbarHost import com.nexters.boolti.presentation.extension.requireActivity +import com.nexters.boolti.presentation.screen.LocalSnackbarController import com.nexters.boolti.presentation.screen.ticketing.ChooseTicketBottomSheet -import com.nexters.boolti.presentation.theme.Grey05 import com.nexters.boolti.presentation.theme.Grey20 import com.nexters.boolti.presentation.theme.Grey30 import com.nexters.boolti.presentation.theme.Grey50 import com.nexters.boolti.presentation.theme.Grey80 import com.nexters.boolti.presentation.theme.Grey85 -import com.nexters.boolti.presentation.theme.aggroFamily import com.nexters.boolti.presentation.theme.marginHorizontal +import com.nexters.boolti.presentation.theme.point3 import kotlinx.coroutines.launch import timber.log.Timber import java.time.format.DateTimeFormatter @@ -95,23 +98,36 @@ fun ShowDetailScreen( val isLoggedIn by viewModel.loggedIn.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } var showBottomSheet by remember { mutableStateOf(false) } val window = LocalContext.current.requireActivity().window window.statusBarColor = MaterialTheme.colorScheme.surface.toArgb() + LaunchedEffect(Unit) { + viewModel.events.collect { event -> + when (event) { + is ShowDetailEvent.NavigateToImages -> { + navigateToImages(event.index) + } + + ShowDetailEvent.PopBackStack -> { + onBack() + viewModel.preventEvents() + } + } + } + } + + BackHandler { + viewModel.sendEvent(ShowDetailEvent.PopBackStack) + } + Scaffold( modifier = modifier, - snackbarHost = { - ToastSnackbarHost( - modifier = Modifier.padding(bottom = 80.dp), - hostState = snackbarHostState, - ) - }, topBar = { ShowDetailAppBar( - onBack = onBack, + showId = uiState.showDetail.id, + onBack = { viewModel.sendEvent(ShowDetailEvent.PopBackStack) }, onClickHome = onClickHome, navigateToReport = navigateToReport, ) @@ -129,7 +145,7 @@ fun ShowDetailScreen( ) { Poster( modifier = modifier.fillMaxWidth(), - navigateToImages = navigateToImages, + navigateToImages = { viewModel.sendEvent(ShowDetailEvent.NavigateToImages(it)) }, title = uiState.showDetail.name, images = uiState.showDetail.images.map { it.originImage } ) @@ -137,7 +153,6 @@ fun ShowDetailScreen( modifier = Modifier .padding(horizontal = marginHorizontal) .padding(bottom = 114.dp), - snackbarHost = snackbarHostState, showDetail = uiState.showDetail, host = stringResource( id = R.string.ticketing_host_format, @@ -167,12 +182,6 @@ fun ShowDetailScreen( ) ShowDetailCtaButton( showState = uiState.showDetail.state, - purchased = uiState.showDetail.isReserved, - showMessage = { message -> - scope.launch { - snackbarHostState.showSnackbar(message = message) - } - }, onClick = { scope.launch { if (isLoggedIn == true) { @@ -207,85 +216,73 @@ fun ShowDetailScreen( @Composable private fun ShowDetailAppBar( + showId: String, onBack: () -> Unit, onClickHome: () -> Unit, navigateToReport: () -> Unit, - modifier: Modifier = Modifier, ) { val context = LocalContext.current var isContextMenuVisible by rememberSaveable { mutableStateOf(false) } - - Row( - modifier = modifier - .height(44.dp) - .fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surface), - verticalAlignment = Alignment.CenterVertically, - ) { - IconButton( - modifier = Modifier.size(width = 48.dp, height = 44.dp), - onClick = onBack, - ) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(id = R.string.description_navigate_back), - Modifier - .padding(start = marginHorizontal) - .size(width = 24.dp, height = 24.dp) + BtAppBar( + colors = BtAppBarDefaults.appBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + navigateButtons = { + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_arrow_back, + description = stringResource(id = R.string.description_navigate_back), + onClick = onBack, ) - } - IconButton( - modifier = Modifier.size(width = 64.dp, height = 44.dp), - onClick = onClickHome, - ) { - Icon( - painter = painterResource(R.drawable.ic_home), - contentDescription = stringResource(id = R.string.description_toolbar_home), - Modifier.size(width = 24.dp, height = 24.dp) + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_home, + description = stringResource(id = R.string.description_toolbar_home), + onClick = onClickHome, ) - } - Spacer(modifier = Modifier.weight(1.0f)) - IconButton( - modifier = Modifier - .padding(end = 10.dp) - .size(44.dp), - onClick = { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra( - Intent.EXTRA_TEXT, - "https://play.google.com/store/apps/details?id=${context.applicationContext.packageName}" - ) - type = "text/plain" - } + }, + actionButtons = { + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_share, + description = stringResource(id = R.string.ticketing_share), + onClick = { + Firebase.dynamicLinks.shortLinkAsync { + val uri = Uri.parse("https://preview.boolti.in/show/$showId") + link = uri + domainUriPrefix = "https://boolti.page.link" - val shareIntent = Intent.createChooser(sendIntent, null) - context.startActivity(shareIntent) - }, - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(R.drawable.ic_share), - contentDescription = stringResource(id = R.string.ticketing_share), + androidParameters { + fallbackUrl = uri + } + iosParameters("com.nexters.boolti") { + setFallbackUrl(uri) + } + }.addOnSuccessListener { + it.shortLink?.let { link -> + println(link) + + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra( + Intent.EXTRA_TEXT, + link.toString() + ) + type = "text/plain" + } + + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } + } + }, ) - } - IconButton( - modifier = Modifier - .padding(end = marginHorizontal) - .size(24.dp), - onClick = { - isContextMenuVisible = true - }, - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(R.drawable.ic_verticle_more), - contentDescription = stringResource(id = R.string.description_more_menu), + BtAppBarDefaults.AppBarIconButton( + iconRes = R.drawable.ic_verticle_more, + description = stringResource(id = R.string.description_more_menu), + onClick = { isContextMenuVisible = true }, ) - } - } + }, + ) Box( modifier = Modifier @@ -315,13 +312,12 @@ private fun ShowDetailAppBar( @Composable private fun ContentScaffold( - snackbarHost: SnackbarHostState, showDetail: ShowDetail, host: String, onClickContent: () -> Unit, modifier: Modifier = Modifier, ) { - val scope = rememberCoroutineScope() + val snackbarController = LocalSnackbarController.current Column( modifier = modifier, @@ -361,9 +357,7 @@ private fun ContentScaffold( onClick = { clipboardManager.setText(AnnotatedString(showDetail.streetAddress)) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { - scope.launch { - snackbarHost.showSnackbar(copiedMessage) - } + snackbarController.showMessage(copiedMessage) } } ) @@ -448,10 +442,7 @@ private fun Poster( Text( modifier = Modifier.padding(top = 24.dp, bottom = 30.dp), text = title, - fontFamily = aggroFamily, - color = Grey05, - fontSize = 24.sp, - lineHeight = 34.sp, + style = point3, ) } } @@ -498,25 +489,16 @@ private fun SectionContent( @Composable fun ShowDetailCtaButton( onClick: () -> Unit, - showMessage: (message: String) -> Unit, - purchased: Boolean, showState: ShowState, modifier: Modifier = Modifier, ) { - val enabled = showState is ShowState.TicketingInProgress && !purchased + val enabled = showState is ShowState.TicketingInProgress val text = when (showState) { is ShowState.WaitingTicketing -> stringResource( id = R.string.ticketing_button_upcoming_ticket, showState.dDay ) - ShowState.TicketingInProgress -> { - if (purchased) { - stringResource(id = R.string.ticketing_button_purchased_ticket) - } else { - stringResource(id = R.string.ticketing_button_label) - } - } - + ShowState.TicketingInProgress -> stringResource(id = R.string.ticketing_button_label) ShowState.ClosedTicketing -> stringResource(id = R.string.ticketing_button_closed_ticket) ShowState.FinishedShow -> stringResource(id = R.string.ticketing_button_finished_show) } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailViewModel.kt index 726acd08..cd75e8b8 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowDetailViewModel.kt @@ -3,19 +3,19 @@ package com.nexters.boolti.presentation.screen.show import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.nexters.boolti.domain.model.ShowDetail -import com.nexters.boolti.domain.model.TicketingTicket import com.nexters.boolti.domain.repository.AuthRepository import com.nexters.boolti.domain.repository.ShowRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import java.util.UUID import javax.inject.Inject @HiltViewModel @@ -29,6 +29,9 @@ class ShowDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(ShowDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val _events = Channel() + val events: Flow = _events.receiveAsFlow() + val loggedIn = authRepository.loggedIn.stateIn( viewModelScope, SharingStarted.WhileSubscribed(5000), @@ -39,6 +42,8 @@ class ShowDetailViewModel @Inject constructor( fetchShowDetail() } + fun sendEvent(event: ShowDetailEvent) = viewModelScope.launch { _events.send(event) } + private fun fetchShowDetail() { viewModelScope.launch { showRepository.searchById(id = showId) @@ -51,4 +56,8 @@ class ShowDetailViewModel @Inject constructor( } } } + + fun preventEvents() { + _events.cancel() + } } \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesNavigation.kt new file mode 100644 index 00000000..b3252c49 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesNavigation.kt @@ -0,0 +1,27 @@ +package com.nexters.boolti.presentation.screen.show + +import androidx.compose.runtime.Composable +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument + +fun NavGraphBuilder.ShowImagesScreen( + popBackStack: () -> Unit, + getSharedViewModel: @Composable (NavBackStackEntry) -> ShowDetailViewModel, +) { + composable( + route = "images/{index}", + arguments = listOf(navArgument("index") { type = NavType.IntType }), + ) { entry -> + val showViewModel: ShowDetailViewModel = getSharedViewModel(entry) + val index = entry.arguments!!.getInt("index") + + ShowImagesScreen( + index = index, + viewModel = showViewModel, + onBackPressed = popBackStack, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesScreen.kt index e8260185..21a5c4a4 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowImagesScreen.kt @@ -25,8 +25,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage -import com.nexters.boolti.presentation.R -import com.nexters.boolti.presentation.component.BtAppBar +import com.nexters.boolti.presentation.component.BtCloseableAppBar +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable @OptIn(ExperimentalFoundationApi::class) @Composable @@ -43,13 +44,7 @@ fun ShowImagesScreen( ) { uiState.showDetail.images.size } Scaffold( - topBar = { - BtAppBar( - title = "", - onBackPressed = onBackPressed, - navIconRes = R.drawable.ic_close, - ) - }, + topBar = { BtCloseableAppBar(onClickClose = onBackPressed) }, modifier = modifier, ) { innerPadding -> Column( @@ -67,7 +62,8 @@ fun ShowImagesScreen( ) { AsyncImage( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .zoomable(rememberZoomState()), model = uiState.showDetail.images[it].originImage, contentDescription = null, contentScale = ContentScale.FillWidth, @@ -100,4 +96,4 @@ private fun Indicator( ) } } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowScreen.kt index 51a76114..5660e6ce 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions @@ -43,32 +45,30 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BusinessInformation import com.nexters.boolti.presentation.component.ShowFeed +import com.nexters.boolti.presentation.extension.toPx import com.nexters.boolti.presentation.theme.Grey15 import com.nexters.boolti.presentation.theme.Grey60 import com.nexters.boolti.presentation.theme.Grey70 import com.nexters.boolti.presentation.theme.Grey85 -import com.nexters.boolti.presentation.theme.aggroFamily import com.nexters.boolti.presentation.theme.marginHorizontal +import com.nexters.boolti.presentation.theme.point4 @Composable fun ShowScreen( navigateToReservations: () -> Unit, + navigateToBusiness: () -> Unit, onClickShowItem: (showId: String) -> Unit, modifier: Modifier = Modifier, viewModel: ShowViewModel = hiltViewModel() @@ -76,12 +76,12 @@ fun ShowScreen( val user by viewModel.user.collectAsStateWithLifecycle() val nickname = user?.nickname ?: "" val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val lazyGridState = rememberLazyGridState() val appbarHeight = if (uiState.hasPendingTicket) 196.dp + 52.dp else 196.dp val searchBarHeight = 80.dp - val changeableAppBarHeightPx = - with(LocalDensity.current) { (appbarHeight - searchBarHeight).roundToPx().toFloat() } + val changeableAppBarHeightPx = (appbarHeight - searchBarHeight).toPx() var appbarOffsetHeightPx by rememberSaveable { mutableFloatStateOf(0f) } - var changeableAppBarHeight by remember { mutableFloatStateOf(0f) } val nestedScrollConnection = remember { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { @@ -89,6 +89,15 @@ fun ShowScreen( return Offset.Zero } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + appbarOffsetHeightPx -= available.y + return super.onPostScroll(consumed, available, source) + } } } @@ -111,6 +120,7 @@ fun ShowScreen( LazyVerticalGrid( modifier = Modifier .padding(horizontal = marginHorizontal), + state = lazyGridState, columns = GridCells.Adaptive(minSize = 150.dp), horizontalArrangement = Arrangement.spacedBy(15.dp), verticalArrangement = Arrangement.spacedBy(28.dp), @@ -125,12 +135,21 @@ fun ShowScreen( .clickable { onClickShowItem(uiState.shows[index].id) }, ) } + + item( + span = { GridItemSpan(2) }, + ) { + BusinessInformation( + modifier = Modifier.padding(bottom = 12.dp), + onClick = navigateToBusiness + ) + } } ShowAppBar( modifier = Modifier.offset { IntOffset( x = 0, - y = appbarOffsetHeightPx.coerceIn(-changeableAppBarHeightPx, 0f).toInt() + y = appbarOffsetHeightPx.coerceAtLeast(-changeableAppBarHeightPx).toInt(), ) }, navigateToReservations = navigateToReservations, @@ -138,18 +157,12 @@ fun ShowScreen( nickname = nickname.ifBlank { stringResource(id = R.string.nickname_default) }, text = uiState.keyword, onKeywordChanged = viewModel::updateKeyword, - onChangeableSizeChanged = { size -> - changeableAppBarHeight = size.height.toFloat() - }, search = viewModel::search, ) } } } -/** - * @param onChangeableSizeChanged 변할 수 있는 최대 사이즈를 전달 app bar height - search bar - */ @Composable fun ShowAppBar( text: String, @@ -157,20 +170,14 @@ fun ShowAppBar( navigateToReservations: () -> Unit, nickname: String, onKeywordChanged: (keyword: String) -> Unit, - onChangeableSizeChanged: (size: IntSize) -> Unit, search: () -> Unit, modifier: Modifier = Modifier, ) { - var appBarHeight by remember { mutableFloatStateOf(0f) } - val searchBarHeight = with(LocalDensity.current) { 80.dp.toPx() } Column( modifier = modifier .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) .padding(horizontal = marginHorizontal) - .onSizeChanged(onSizeChanged = { size -> - appBarHeight = size.height.toFloat() - onChangeableSizeChanged(IntSize(0, size.height - searchBarHeight.toInt())) - }) ) { Spacer(modifier = Modifier.height(20.dp)) if (hasPendingTicket) Banner( @@ -182,12 +189,7 @@ fun ShowAppBar( modifier = Modifier .fillMaxWidth(), text = stringResource(id = R.string.home_sub_title, nickname), - style = TextStyle( - lineHeight = 34.sp, - fontWeight = FontWeight.Normal, - fontSize = 24.sp, - fontFamily = aggroFamily, - ), + style = point4, ) SearchBar( modifier = Modifier @@ -281,4 +283,4 @@ private fun Banner( ) Icon(painter = painterResource(id = R.drawable.ic_arrow_right), contentDescription = null) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowViewModel.kt index 30f21de4..46dafb73 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/ShowViewModel.kt @@ -1,13 +1,12 @@ package com.nexters.boolti.presentation.screen.show -import android.view.SearchEvent -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.model.ReservationState -import com.nexters.boolti.domain.repository.ReservationRepository import com.nexters.boolti.domain.model.User import com.nexters.boolti.domain.repository.AuthRepository +import com.nexters.boolti.domain.repository.ReservationRepository import com.nexters.boolti.domain.repository.ShowRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -16,13 +15,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import timber.log.Timber import javax.inject.Inject @@ -31,7 +30,7 @@ class ShowViewModel @Inject constructor( private val showRepository: ShowRepository, private val reservationRepository: ReservationRepository, authRepository: AuthRepository, -) : ViewModel() { +) : BaseViewModel() { val user: StateFlow = authRepository.cachedUser.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -79,9 +78,6 @@ class ShowViewModel @Inject constructor( it.copy(hasPendingTicket = hasPendingTicket) } } - .catch { - // TODO 예외 처리 - } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } -} \ No newline at end of file +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/SwipeableImage.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/SwipeableImage.kt index 2d4d9d8d..f3ce0395 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/SwipeableImage.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/show/SwipeableImage.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import com.nexters.boolti.presentation.constants.posterRatio import com.nexters.boolti.presentation.theme.Grey95 @OptIn(ExperimentalFoundationApi::class) @@ -47,7 +48,7 @@ fun SwipeableImage( AsyncImage( modifier = Modifier .fillMaxWidth() - .aspectRatio(210f / 297f) + .aspectRatio(posterRatio) .clickable( interactionSource = interactionSource, indication = null, diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutEvent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutEvent.kt new file mode 100644 index 00000000..6216d0d2 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutEvent.kt @@ -0,0 +1,5 @@ +package com.nexters.boolti.presentation.screen.signout + +sealed interface SignoutEvent { + data object SignoutSuccess : SignoutEvent +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutNavigation.kt new file mode 100644 index 00000000..011753c3 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutNavigation.kt @@ -0,0 +1,19 @@ +package com.nexters.boolti.presentation.screen.signout + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination + +fun NavGraphBuilder.SignoutScreen( + navigateToHome: () -> Unit, + popBackStack: () -> Unit, +) { + composable( + route = MainDestination.SignOut.route, + ) { + SignoutScreen( + navigateToHome = navigateToHome, + navigateBack = popBackStack, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutNotice.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutNotice.kt new file mode 100644 index 00000000..9b3bbf02 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutNotice.kt @@ -0,0 +1,55 @@ +package com.nexters.boolti.presentation.screen.signout + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.marginHorizontal +import com.nexters.boolti.presentation.theme.point4 + +@Composable +fun SignoutNotice( + modifier: Modifier, +) { + Column( + modifier = modifier, + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 20.dp, horizontal = marginHorizontal), + text = stringResource(R.string.signout_notice_title), + style = point4, + color = MaterialTheme.colorScheme.onPrimary, + ) + stringArrayResource(R.array.signout_notice).forEach { notice -> + Row( + modifier = Modifier + .wrapContentWidth() + .padding(horizontal = marginHorizontal), + ) { + Text( + text = stringResource(R.string.bullet), + style = MaterialTheme.typography.bodySmall, + color = Grey50, + ) + Text( + modifier = Modifier.padding(start = 2.dp), + text = notice, + style = MaterialTheme.typography.bodySmall, + color = Grey50, + ) + } + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutReason.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutReason.kt new file mode 100644 index 00000000..4a2f5697 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutReason.kt @@ -0,0 +1,49 @@ +package com.nexters.boolti.presentation.screen.signout + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BTTextField +import com.nexters.boolti.presentation.theme.marginHorizontal +import com.nexters.boolti.presentation.theme.point4 + +@Composable +fun SignoutReason( + modifier: Modifier, + viewModel: SignoutViewModel = hiltViewModel(), +) { + val reason by viewModel.reason.collectAsStateWithLifecycle() + + Column( + modifier = modifier, + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 20.dp, horizontal = marginHorizontal), + text = stringResource(R.string.signout_reason_title), + style = point4, + color = MaterialTheme.colorScheme.onPrimary, + ) + BTTextField( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 160.dp, max = 300.dp) + .padding(horizontal = marginHorizontal), + text = reason, + placeholder = stringResource(R.string.signout_reason_placeholder), + onValueChanged = viewModel::setReason, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutScreen.kt new file mode 100644 index 00000000..5d7c97da --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutScreen.kt @@ -0,0 +1,66 @@ +package com.nexters.boolti.presentation.screen.signout + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BtBackAppBar +import com.nexters.boolti.presentation.component.MainButton +import com.nexters.boolti.presentation.theme.marginHorizontal + +@Composable +fun SignoutScreen( + navigateToHome: () -> Unit, + navigateBack: () -> Unit, + viewModel: SignoutViewModel = hiltViewModel(), +) { + var firstPage by remember { mutableStateOf(true) } + val reason by viewModel.reason.collectAsStateWithLifecycle() + + BackHandler { + if (firstPage) navigateBack() else firstPage = true + } + + LaunchedEffect(viewModel.event) { + viewModel.event.collect { + when (it) { + SignoutEvent.SignoutSuccess -> navigateToHome() + } + } + } + + Scaffold( + topBar = { BtBackAppBar(title = stringResource(R.string.signout), onClickBack = navigateBack) }, + bottomBar = { + MainButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = marginHorizontal) + .padding(bottom = 42.dp), + label = if (firstPage) stringResource(R.string.next) else stringResource(R.string.signout_button), + enabled = firstPage || reason.isNotBlank(), + onClick = { + if (firstPage) firstPage = false else viewModel.signout() + }, + ) + } + ) { innerPadding -> + if (firstPage) { + SignoutNotice(modifier = Modifier.padding(innerPadding)) + } else { + SignoutReason(modifier = Modifier.padding(innerPadding)) + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutViewModel.kt new file mode 100644 index 00000000..04ebb3f0 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/signout/SignoutViewModel.kt @@ -0,0 +1,46 @@ +package com.nexters.boolti.presentation.screen.signout + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.boolti.domain.repository.AuthRepository +import com.nexters.boolti.domain.request.SignoutRequest +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SignoutViewModel @Inject constructor( + private val repository: AuthRepository, +) : ViewModel() { + private val _reason = MutableStateFlow("") + val reason = _reason.asStateFlow() + + private val _event = Channel() + val event = _event.receiveAsFlow() + + fun signout() { + viewModelScope.launch { + repository.signout( + request = SignoutRequest(reason.value) + ).onSuccess { + event(SignoutEvent.SignoutSuccess) + }.onFailure { + it.printStackTrace() + } + } + } + + fun setReason(reason: String) { + _reason.value = reason + } + + private fun event(event: SignoutEvent) { + viewModelScope.launch { + _event.send(event) + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashActivity.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashActivity.kt index 5b4719f8..b819d605 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashActivity.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashActivity.kt @@ -21,21 +21,17 @@ 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 androidx.core.app.NotificationManagerCompat import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import com.nexters.boolti.domain.repository.ConfigRepository import com.nexters.boolti.presentation.BuildConfig import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.BTDialog import com.nexters.boolti.presentation.screen.MainActivity +import com.nexters.boolti.presentation.service.BtNotification import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Grey50 import dagger.hilt.android.AndroidEntryPoint -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject @AndroidEntryPoint class SplashActivity : ComponentActivity() { @@ -52,10 +48,21 @@ class SplashActivity : ComponentActivity() { shouldUpdate = shouldUpdate, onSuccessVersionCheck = { startActivity(Intent(this, MainActivity::class.java)) + intent.extras?.getString("type")?.let { type -> + val notification = BtNotification(type) + + notification.deepLink?.let { + viewModel.sendDeepLinkEvent(it) + } + + NotificationManagerCompat.from(this).cancel(notification.id) + } + finish() }, onClickUpdate = { - val playStoreUrl = "http://play.google.com/store/apps/details?id=${BuildConfig.PACKAGE_NAME}" + val playStoreUrl = + "http://play.google.com/store/apps/details?id=${BuildConfig.PACKAGE_NAME}" startActivity( Intent(Intent.ACTION_VIEW).apply { data = Uri.parse(playStoreUrl) @@ -121,16 +128,3 @@ fun UpdateDialogPreview() { } } } - -@HiltViewModel -class SplashViewModel @Inject constructor( - configRepository: ConfigRepository, -) : ViewModel() { - init { - viewModelScope.launch { - configRepository.cacheRefundPolicy() - } - } - - val shouldUpdate = configRepository.shouldUpdate() -} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashViewModel.kt new file mode 100644 index 00000000..60a60a72 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/splash/SplashViewModel.kt @@ -0,0 +1,30 @@ +package com.nexters.boolti.presentation.screen.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.boolti.domain.repository.ConfigRepository +import com.nexters.boolti.presentation.screen.DeepLinkEvent +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + configRepository: ConfigRepository, + private val deepLinkEvent: DeepLinkEvent, +) : ViewModel() { + init { + viewModelScope.launch { + configRepository.cacheRefundPolicy() + } + } + + val shouldUpdate = configRepository.shouldUpdate() + + fun sendDeepLinkEvent(deeplink: String) { + viewModelScope.launch { + deepLinkEvent.sendEvent(deeplink) + } + } +} + diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketContent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketContent.kt index 06661312..679acba0 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketContent.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketContent.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,7 +26,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -39,6 +39,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -53,6 +54,7 @@ import com.nexters.boolti.presentation.extension.toPx import com.nexters.boolti.presentation.theme.Grey30 import com.nexters.boolti.presentation.theme.Grey40 import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.Grey70 import com.nexters.boolti.presentation.theme.Grey80 import com.nexters.boolti.presentation.theme.Grey95 import com.nexters.boolti.presentation.theme.marginHorizontal @@ -65,7 +67,7 @@ import java.time.LocalDateTime @Composable fun TicketContent( ticket: Ticket, - onClickQr: (data: String) -> Unit, + onClickQr: (data: String, ticketName: String) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -130,7 +132,7 @@ fun TicketContent( ), ) Column { - Title(ticket.ticketName, 1) // TODO 개수 정보 생기면 업데이트 + Title(ticket.ticketName, ticket.csTicketId) AsyncImage( modifier = Modifier .fillMaxWidth() @@ -138,7 +140,7 @@ fun TicketContent( .padding(top = 20.dp, start = 20.dp, end = 20.dp) .clip(RoundedCornerShape(8.dp)), model = ticket.poster, - contentScale = ContentScale.FillWidth, + contentScale = ContentScale.Crop, contentDescription = stringResource(R.string.description_poster), ) DottedDivider( @@ -155,7 +157,7 @@ fun TicketContent( placeName = ticket.placeName, entryCode = ticket.entryCode, ticketState = ticket.ticketState, - onClickQr = onClickQr, + onClickQr = { onClickQr(it, ticket.ticketName) }, ) } // 티켓 좌상단 꼭지점 그라데이션 @@ -177,24 +179,31 @@ fun TicketContent( @Composable private fun Title( ticketName: String = "", - count: Int = 0, + csTicketId: String = "", ) { Row( modifier = Modifier .background(White.copy(alpha = 0.3f)) + .alpha(0.65f) .padding(horizontal = 20.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { + Icon( + modifier = Modifier.padding(end = 4.dp).size(20.dp), + painter = painterResource(R.drawable.ic_logo), + tint = Grey80, + contentDescription = null, + ) Text( modifier = Modifier.weight(1f), - text = if (count > 1) stringResource(R.string.ticket_title, ticketName, count) else ticketName, - style = MaterialTheme.typography.bodySmall, + text = ticketName, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), color = Grey80, ) - Image( - painter = painterResource(R.drawable.ic_logo), - colorFilter = ColorFilter.tint(Grey80), - contentDescription = null, + Text( + text = csTicketId, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = Grey80, ) } } @@ -291,7 +300,7 @@ private fun TicketQr( .clip(RoundedCornerShape(4.dp)) .size(70.dp) .background( - brush = SolidColor(Color.Black), + brush = SolidColor(Black), alpha = 0.8f, ) ) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketScreen.kt index ea8d313e..f479ce81 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketScreen.kt @@ -2,6 +2,7 @@ package com.nexters.boolti.presentation.screen.ticket import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -27,12 +28,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nexters.boolti.presentation.component.BtCircularProgressIndicator import kotlin.math.absoluteValue @Composable fun TicketScreen( onClickTicket: (String) -> Unit, - onClickQr: (entryCode: String) -> Unit, + onClickQr: (entryCode: String, ticketName: String) -> Unit, modifier: Modifier = Modifier, viewModel: TicketViewModel = hiltViewModel(), ) { @@ -43,10 +45,19 @@ fun TicketScreen( viewModel.load() } - if (uiState.tickets.isNotEmpty()) { - TicketNotEmptyScreen(modifier, uiState, onClickQr, onClickTicket = onClickTicket) - } else { - TicketEmptyScreen(modifier) + when { + uiState.loading -> Box(modifier = Modifier.fillMaxSize()) { + BtCircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + + uiState.tickets.isNotEmpty() -> TicketNotEmptyScreen( + modifier, + uiState, + onClickQr, + onClickTicket = onClickTicket + ) + + else -> TicketEmptyScreen(modifier) } } @@ -55,7 +66,7 @@ fun TicketScreen( private fun TicketNotEmptyScreen( modifier: Modifier, uiState: TicketUiState, - onClickQr: (entryCode: String) -> Unit, + onClickQr: (entryCode: String, ticketName: String) -> Unit, onClickTicket: (ticketId: String) -> Unit, ) { Column( diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketUiState.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketUiState.kt index 551c4297..a9003ec8 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketUiState.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketUiState.kt @@ -3,6 +3,6 @@ package com.nexters.boolti.presentation.screen.ticket import com.nexters.boolti.domain.model.Ticket data class TicketUiState( - // TODO 로딩 중 어떻게 표시할 지 결정되면 Loading 필드 추가. + val loading: Boolean = false, val tickets: List = emptyList(), ) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketViewModel.kt index cf8212c2..e440d9b1 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketViewModel.kt @@ -3,10 +3,12 @@ package com.nexters.boolti.presentation.screen.ticket import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.repository.TicketRepository +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.singleOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -15,19 +17,23 @@ import javax.inject.Inject @HiltViewModel class TicketViewModel @Inject constructor( private val ticketRepository: TicketRepository, -) : ViewModel() { - private val _uiState = MutableStateFlow(TicketUiState()) +) : BaseViewModel() { + private val _uiState = MutableStateFlow(TicketUiState(loading = true)) val uiState = _uiState.asStateFlow() fun load() { - viewModelScope.launch { - ticketRepository.getTicket().catch { e -> - e.printStackTrace() - }.singleOrNull()?.let { tickets -> - _uiState.update { - it.copy(tickets = tickets) + viewModelScope.launch(recordExceptionHandler) { + _uiState.update { it.copy(loading = true) } + ticketRepository.getTicket() + .onCompletion { + _uiState.update { it.copy(loading = false) } + }.catch { e -> + e.printStackTrace() + }.singleOrNull()?.let { tickets -> + _uiState.update { + it.copy(tickets = tickets) + } } - } } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt index e4a5943b..94ec03ab 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailScreen.kt @@ -29,15 +29,12 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshContainer import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable @@ -69,6 +66,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -77,16 +75,21 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable import coil.compose.AsyncImage import com.nexters.boolti.domain.model.TicketState import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.BTDialog +import com.nexters.boolti.presentation.component.BtBackAppBar import com.nexters.boolti.presentation.component.DottedDivider import com.nexters.boolti.presentation.component.ToastSnackbarHost import com.nexters.boolti.presentation.extension.dayOfWeekString import com.nexters.boolti.presentation.extension.format import com.nexters.boolti.presentation.extension.toDp import com.nexters.boolti.presentation.extension.toPx +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.ticketId import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Grey20 import com.nexters.boolti.presentation.theme.Grey30 @@ -104,13 +107,35 @@ import kotlinx.coroutines.launch import java.time.LocalDate import java.time.LocalDateTime +fun NavGraphBuilder.TicketDetailScreen( + navigateTo: (String) -> Unit, + popBackStack: () -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = "${MainDestination.TicketDetail.route}/{$ticketId}", + arguments = MainDestination.TicketDetail.arguments, + ) { + TicketDetailScreen( + modifier = modifier, + onBackClicked = popBackStack, + onClickQr = { code, ticketName -> + navigateTo( + "${MainDestination.Qr.route}/${code.filter { c -> c.isLetterOrDigit() }}?ticketName=$ticketName" + ) + }, + navigateToShowDetail = { navigateTo("${MainDestination.ShowDetail.route}/$it") } + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable -fun TicketDetailScreen( +private fun TicketDetailScreen( modifier: Modifier = Modifier, viewModel: TicketDetailViewModel = hiltViewModel(), onBackClicked: () -> Unit, - onClickQr: (entryCode: String) -> Unit, + onClickQr: (entryCode: String, ticketName: String) -> Unit, navigateToShowDetail: (showId: String) -> Unit, ) { val scrollState = rememberScrollState() @@ -125,7 +150,8 @@ fun TicketDetailScreen( var contentWidth by remember { mutableFloatStateOf(0f) } var ticketSectionHeight by remember { mutableFloatStateOf(0f) } var ticketSectionHeightUntilTicketInfo by remember { mutableFloatStateOf(0f) } - val bottomAreaHeight = ticketSectionHeight - ticketSectionHeightUntilTicketInfo + ticketInfoHeight.toPx() + val bottomAreaHeight = + ticketSectionHeight - ticketSectionHeightUntilTicketInfo + ticketInfoHeight.toPx() val uiState by viewModel.uiState.collectAsStateWithLifecycle() val managerCodeState by viewModel.managerCodeState.collectAsStateWithLifecycle() @@ -137,14 +163,17 @@ fun TicketDetailScreen( LaunchedEffect(viewModel.event) { viewModel.event.collect { when (it) { - TicketDetailEvent.ManagerCodeValid -> snackbarHostState.showSnackbar(entranceSuccessMsg) + TicketDetailEvent.ManagerCodeValid -> snackbarHostState.showSnackbar( + entranceSuccessMsg + ) + TicketDetailEvent.OnRefresh -> showEnterCodeDialog = false } } } Scaffold( - topBar = { TicketDetailToolbar(onBackClicked = onBackClicked) }, + topBar = { BtBackAppBar(onClickBack = onBackClicked) }, snackbarHost = { ToastSnackbarHost( modifier = Modifier.padding(bottom = 54.dp), @@ -181,7 +210,12 @@ fun TicketDetailScreen( .clip(ticketShape) .border( width = 1.dp, - brush = Brush.verticalGradient(listOf(White.copy(.5f), White.copy(.2f))), + brush = Brush.verticalGradient( + listOf( + White.copy(.5f), + White.copy(.2f) + ) + ), shape = ticketShape, ), ) { @@ -207,19 +241,26 @@ fun TicketDetailScreen( ) .background( brush = Brush.linearGradient( - colors = listOf(Color(0x33C5CACD), Grey95.copy(alpha = .2f)), + colors = listOf( + Color(0x33C5CACD), + Grey95.copy(alpha = .2f) + ), start = Offset.Zero, - end = Offset(x = contentWidth, y = ticketSectionHeightUntilTicketInfo), + end = Offset( + x = contentWidth, + y = ticketSectionHeightUntilTicketInfo + ), ), ) ) Column( modifier = Modifier .onGloballyPositioned { coordinates -> - ticketSectionHeightUntilTicketInfo = coordinates.size.height.toFloat() + ticketSectionHeightUntilTicketInfo = + coordinates.size.height.toFloat() } ) { - Title(ticketName = ticket.ticketName) + Title(ticketName = ticket.ticketName, csTicketId = ticket.csTicketId) AsyncImage( modifier = Modifier @@ -246,7 +287,7 @@ fun TicketDetailScreen( placeName = ticket.placeName, entryCode = ticket.entryCode, ticketState = ticket.ticketState, - onClickQr = onClickQr, + onClickQr = { onClickQr(it, ticket.ticketName) }, ) } // 티켓 좌상단 꼭지점 그라데이션 @@ -266,12 +307,13 @@ fun TicketDetailScreen( Notice(notice = ticket.ticketNotice) - val copiedMessage = stringResource(id = R.string.ticketing_address_copied_message) + val copiedMessage = + stringResource(id = R.string.ticketing_address_copied_message) Inquiry( hostName = ticket.hostName, hostPhoneNumber = ticket.hostPhoneNumber, onClickCopyPlace = { - clipboardManager.setText(AnnotatedString(ticket.streetAddress + ticket.detailAddress)) + clipboardManager.setText(AnnotatedString(ticket.streetAddress)) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { scope.launch { snackbarHostState.showSnackbar(copiedMessage) @@ -333,47 +375,34 @@ fun TicketDetailScreen( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun TicketDetailToolbar( - onBackClicked: () -> Unit, -) { - TopAppBar( - title = {}, - navigationIcon = { - IconButton(onClick = onBackClicked) { - Icon( - painter = painterResource(R.drawable.ic_arrow_back), - contentDescription = stringResource(R.string.description_navigate_back), - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - ), - ) -} - @Composable private fun Title( ticketName: String = "", + csTicketId: String = "", ) { Row( modifier = Modifier .background(White.copy(alpha = 0.3f)) + .alpha(0.65f) .padding(horizontal = 20.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { + Image( + modifier = Modifier.padding(end = 4.dp), + painter = painterResource(R.drawable.ic_logo), + colorFilter = ColorFilter.tint(Grey70.copy(alpha = 0.5f)), + contentDescription = null, + ) Text( modifier = Modifier.weight(1f), text = ticketName, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), color = Grey80, ) - Image( - painter = painterResource(R.drawable.ic_logo), - colorFilter = ColorFilter.tint(Grey80), - contentDescription = null, + Text( + text = csTicketId, + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = Grey80, ) } } @@ -458,7 +487,7 @@ private fun TicketQr( modifier = Modifier .padding(vertical = 8.dp) .clip(RoundedCornerShape(4.dp)) - .background(Color.White) + .background(White) .padding(2.dp) .clickable { if (ticketState == TicketState.Ready) onClickQr(entryCode) @@ -695,7 +724,11 @@ private fun SectionTitle(title: String) { fun TicketDetailPreview() { BooltiTheme { Surface { - TicketDetailScreen(modifier = Modifier, onBackClicked = {}, onClickQr = {}, navigateToShowDetail = {}) + TicketDetailScreen( + modifier = Modifier, + onBackClicked = {}, + onClickQr = { _, _ -> }, + navigateToShowDetail = {}) } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt index 1ce79556..49a5bd4c 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailViewModel.kt @@ -7,6 +7,7 @@ import com.nexters.boolti.domain.exception.ManagerCodeException import com.nexters.boolti.domain.repository.TicketRepository import com.nexters.boolti.domain.request.ManagerCodeRequest import com.nexters.boolti.domain.usecase.GetRefundPolicyUsecase +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.singleOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel @@ -26,7 +28,7 @@ class TicketDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val repository: TicketRepository, private val getRefundPolicyUsecase: GetRefundPolicyUsecase, -) : ViewModel() { +) : BaseViewModel() { private val ticketId: String = requireNotNull(savedStateHandle["ticketId"]) { "TicketDetailViewModel 에 ticketId 가 전달되지 않았습니다." } @@ -45,18 +47,14 @@ class TicketDetailViewModel @Inject constructor( } private fun load(): Job { - return viewModelScope.launch { - repository.getTicket(ticketId).catch { e -> - e.printStackTrace() - }.singleOrNull()?.let { ticket -> + return viewModelScope.launch(recordExceptionHandler) { + repository.getTicket(ticketId).singleOrNull()?.let { ticket -> _uiState.update { it.copy(ticket = ticket) } } - getRefundPolicyUsecase().catch { e -> - e.printStackTrace() - }.onEach { refundPolicy -> + getRefundPolicyUsecase().onEach { refundPolicy -> _uiState.update { it.copy(refundPolicy = refundPolicy) } - }.launchIn(viewModelScope) + }.launchIn(viewModelScope + recordExceptionHandler) } } @@ -64,15 +62,15 @@ class TicketDetailViewModel @Inject constructor( fun requestEntrance(managerCode: String) { val ticket = uiState.value.ticket - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { repository.requestEntrance( ManagerCodeRequest(showId = ticket.showId, ticketId = ticket.ticketId, managerCode = managerCode) ).catch { e -> - e.printStackTrace() when (e) { is ManagerCodeException -> { _managerCodeState.update { it.copy(error = e.errorType) } } + else -> throw e } }.singleOrNull()?.let { event(TicketDetailEvent.ManagerCodeValid) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/ChooseTicketBottomSheet.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/ChooseTicketBottomSheet.kt index 22a98873..568cc139 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/ChooseTicketBottomSheet.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/ChooseTicketBottomSheet.kt @@ -14,16 +14,16 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.IconButton -import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -31,6 +31,9 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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 @@ -46,8 +49,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.nexters.boolti.domain.model.SalesTicket import com.nexters.boolti.domain.model.TicketWithQuantity import com.nexters.boolti.presentation.R -import com.nexters.boolti.presentation.component.MainButton import com.nexters.boolti.presentation.component.Badge +import com.nexters.boolti.presentation.component.HorizontalCountStepper +import com.nexters.boolti.presentation.component.MainButton import com.nexters.boolti.presentation.extension.sliceAtMost import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Grey15 @@ -65,7 +69,6 @@ fun ChooseTicketBottomSheet( onDismissRequest: () -> Unit, ) { val uiState by viewModel.uiState.collectAsState() - val listState = rememberLazyListState() val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( @@ -89,27 +92,24 @@ fun ChooseTicketBottomSheet( ) { Column( modifier = Modifier - .padding(bottom = 20.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) + .padding( + bottom = 20.dp + WindowInsets.navigationBars + .asPaddingValues() + .calculateBottomPadding() + ) .heightIn(max = 564.dp) ) { - Text( - text = stringResource(id = R.string.choose_ticket_bottomsheet_title), - style = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurface), - modifier = Modifier - .padding(top = 20.dp, start = 24.dp, end = 24.dp, bottom = 12.dp) - ) uiState.selected?.let { ChooseTicketBottomSheetContent2( ticket = it, onCloseClicked = viewModel::unSelectTicket, - onTicketingClicked = { - onTicketingClicked(it.ticket, 1) // TODO 추후 개수 선택 기획 들어오면 변경 + onTicketingClicked = { ticket, count -> + onTicketingClicked(ticket.ticket, count) viewModel.unSelectTicket() }, ) } ?: run { ChooseTicketBottomSheetContent1( - listState = listState, tickets = uiState.tickets, onSelectItem = viewModel::selectTicket, ) @@ -121,19 +121,27 @@ fun ChooseTicketBottomSheet( @Composable private fun ChooseTicketBottomSheetContent1( modifier: Modifier = Modifier, - listState: LazyListState, tickets: List, onSelectItem: (TicketWithQuantity) -> Unit, ) { - LazyColumn( - modifier = modifier.nestedScroll(rememberNestedScrollInteropConnection()), - state = listState - ) { - items(tickets, key = { it.ticket.id }) { - TicketingTicketItem( - ticket = it, - onClick = onSelectItem, - ) + val listState = rememberLazyListState() + Column { + Text( + text = stringResource(id = R.string.choose_ticket_bottomsheet_title), + style = MaterialTheme.typography.titleLarge.copy(color = Grey30), + modifier = Modifier + .padding(top = 20.dp, start = 24.dp, end = 24.dp, bottom = 12.dp) + ) + LazyColumn( + modifier = modifier.nestedScroll(rememberNestedScrollInteropConnection()), + state = listState + ) { + items(tickets, key = { it.ticket.id }) { + TicketingTicketItem( + ticket = it, + onClick = onSelectItem, + ) + } } } } @@ -143,34 +151,28 @@ private fun ChooseTicketBottomSheetContent2( modifier: Modifier = Modifier, ticket: TicketWithQuantity, onCloseClicked: () -> Unit, - onTicketingClicked: (TicketWithQuantity) -> Unit, + onTicketingClicked: (ticket: TicketWithQuantity, count: Int) -> Unit, ) { + var ticketCount by remember { mutableIntStateOf(1) } + Column(modifier) { Row( - modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier + .padding(top = 16.dp, end = 8.dp, start = 24.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = ticket.ticket.ticketName.sliceAtMost(12), - style = MaterialTheme.typography.headlineSmall.copy( - color = MaterialTheme.colorScheme.onPrimary, - ), - ) - if (!ticket.ticket.isInviteTicket) { - Badge( - stringResource(R.string.badge_left_ticket_amount, ticket.quantity), - modifier = Modifier.padding(start = 8.dp), - ) - } - } - Text( - text = stringResource(R.string.format_price, ticket.ticket.price), - style = MaterialTheme.typography.bodyLarge.copy( - color = Grey15, - ), - modifier = Modifier.padding(top = 12.dp), + Text( + text = ticket.ticket.ticketName.sliceAtMost(12), + style = MaterialTheme.typography.headlineSmall.copy( + color = MaterialTheme.colorScheme.onPrimary, + ), + ) + if (!ticket.ticket.isInviteTicket) { + Badge( + stringResource(R.string.badge_left_ticket_amount, ticket.quantity), + color = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.padding(start = 8.dp), ) } Spacer(modifier = Modifier.weight(1F)) @@ -182,21 +184,46 @@ private fun ChooseTicketBottomSheetContent2( ) } } + Row( + modifier = Modifier.padding(top = 8.dp, bottom = 20.dp, start = 24.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (ticket.ticket.isInviteTicket) { + Text( + text = stringResource(R.string.ticketing_limit_per_person, 1), + style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), + ) + } else { + HorizontalCountStepper( + modifier = Modifier.width(100.dp), + currentCount = ticketCount, + minCount = 1, + maxCount = ticket.quantity, + onClickMinus = { ticketCount-- }, + onClickPlus = { ticketCount++ }, + ) + } + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.format_price, ticket.ticket.price), + style = MaterialTheme.typography.bodyLarge.copy(color = Grey15), + ) + } - Divider(color = Grey80, thickness = 1.dp, modifier = Modifier.fillMaxWidth()) + HorizontalDivider(modifier = Modifier.fillMaxWidth(), thickness = 1.dp, color = Grey80) Row( modifier = Modifier.padding(vertical = 16.dp, horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.ticketing_limit_per_person, 1), + text = stringResource(R.string.total_payment_amount_label), style = MaterialTheme.typography.bodyLarge.copy(color = Grey30), ) Spacer(modifier = Modifier.weight(1F)) Text( - text = stringResource(R.string.format_total_price, ticket.ticket.price), - style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary), + text = stringResource(R.string.format_total_price, ticket.ticket.price * ticketCount), + style = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.primary), ) } @@ -206,7 +233,7 @@ private fun ChooseTicketBottomSheetContent2( .fillMaxWidth() .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 24.dp) .height(48.dp), - onClick = { onTicketingClicked(ticket) }, + onClick = { onTicketingClicked(ticket, ticketCount) }, ) } } @@ -217,6 +244,7 @@ private fun TicketingTicketItem( onClick: (TicketWithQuantity) -> Unit, ) { val enabled = ticket.ticket.isInviteTicket || ticket.quantity > 0 + val textColor = if (enabled) MaterialTheme.colorScheme.onPrimary else Grey70 Row( modifier = Modifier @@ -227,13 +255,15 @@ private fun TicketingTicketItem( ) { Text( text = ticket.ticket.ticketName.sliceAtMost(12), - style = MaterialTheme.typography.bodyLarge.copy(color = if (enabled) Grey30 else Grey70), + style = MaterialTheme.typography.bodyLarge.copy(color = textColor), overflow = TextOverflow.Ellipsis, ) if (!ticket.ticket.isInviteTicket && ticket.quantity > 0) { Badge( stringResource(R.string.badge_left_ticket_amount, ticket.quantity), - Modifier.padding(start = 8.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier.padding(start = 8.dp), ) } Text( @@ -245,7 +275,7 @@ private fun TicketingTicketItem( } else { stringResource(R.string.sold_out_label) }, - style = MaterialTheme.typography.bodyLarge.copy(color = if (enabled) Grey15 else Grey70), + style = MaterialTheme.typography.bodyLarge.copy(color = textColor), textAlign = TextAlign.End, maxLines = 1, ) @@ -254,23 +284,75 @@ private fun TicketingTicketItem( @Preview @Composable -fun TicketingTicketItemPreview() { - val ticket = SalesTicket("", "", "상운이쇼상운이쇼상운이쇼상운이쇼", 1000, false) +private fun TicketingTicket1Preview() { + BooltiTheme { + ChooseTicketBottomSheetContent1( + tickets = listOf( + TicketWithQuantity( + ticket = SalesTicket( + id = "legimus", + showId = "repudiandae", + ticketName = "Nadine Faulkner", + price = 4358, + isInviteTicket = false, + ), + quantity = 100, + ), + TicketWithQuantity( + ticket = SalesTicket( + id = "putent", + showId = "qui", + ticketName = "Beth Small", + price = 6979, + isInviteTicket = false, + ), + quantity = 7168, + ), + TicketWithQuantity( + ticket = SalesTicket( + id = "verear", + showId = "non", + ticketName = "Dante Keith", + price = 9397, + isInviteTicket = true, + ), quantity = 4817 + ) + ) + ) {} + } +} +@Preview +@Composable +private fun TicketingTicket2Preview() { BooltiTheme { - TicketingTicketItem( + ChooseTicketBottomSheetContent2( ticket = TicketWithQuantity( - ticket = ticket, + ticket = SalesTicket( + id = "legimus", + showId = "repudiandae", + ticketName = "Nadine Faulkner", + price = 4358, + isInviteTicket = false, + ), quantity = 100, ), - ) {} + onCloseClicked = {}, + ) { _, _ -> } } } @Preview @Composable -fun BadgePreview() { +private fun TicketingTicketItemPreview() { + val ticket = SalesTicket("", "", "상운이쇼상운이쇼상운이쇼상운이쇼", 1000, false) + BooltiTheme { - Badge("3개 남음") + TicketingTicketItem( + ticket = TicketWithQuantity( + ticket = ticket, + quantity = 100, + ), + ) {} } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingConfirmDialog.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingConfirmDialog.kt new file mode 100644 index 00000000..08a0adf8 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingConfirmDialog.kt @@ -0,0 +1,145 @@ +package com.nexters.boolti.presentation.screen.ticketing + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.AbsoluteAlignment +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nexters.boolti.domain.model.PaymentType +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.BTDialog +import com.nexters.boolti.presentation.extension.toContactFormat +import com.nexters.boolti.presentation.theme.BooltiTheme +import com.nexters.boolti.presentation.theme.Grey15 +import com.nexters.boolti.presentation.theme.Grey30 +import com.nexters.boolti.presentation.theme.marginHorizontal + +@Composable +fun TicketingConfirmDialog( + isInviteTicket: Boolean, + reservationName: String, + reservationContact: String, + depositor: String, + depositorContact: String, + ticketName: String, + ticketCount: Int, + totalPrice: Int, + paymentType: PaymentType, + onClick: () -> Unit, + onDismiss: () -> Unit, +) { + BTDialog( + positiveButtonLabel = stringResource(R.string.ticketing_payment_button_label_short), + onClickPositiveButton = onClick, + onDismiss = onDismiss, + ) { + Text( + text = stringResource(R.string.ticketing_confirm_dialog_title), + style = MaterialTheme.typography.headlineSmall + ) + Column( + modifier = Modifier + .padding(top = 24.dp) + .clip(RoundedCornerShape(4.dp)) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = marginHorizontal, vertical = 16.dp), + ) { + // 예매자 + InfoRow( + label = stringResource(R.string.ticket_holder), + value1 = reservationName, + value2 = reservationContact.toContactFormat(), + ) + // 입금자 + if (!isInviteTicket && totalPrice > 0) { + InfoRow( + modifier = Modifier.padding(top = 16.dp), + label = stringResource(R.string.depositor), + value1 = depositor, + value2 = depositorContact.toContactFormat(), + ) + } + // 티켓 + InfoRow( + modifier = Modifier.padding(top = 16.dp), + label = stringResource(R.string.ticket), + value1 = ticketName, + value2 = stringResource(R.string.reservations_ticket_count_price_format_short, ticketCount, totalPrice), + ) + // 결제 수단 + if (isInviteTicket || totalPrice > 0) { + InfoRow( + modifier = Modifier.padding(top = 16.dp), + label = stringResource(R.string.payment_type_label), + value1 = if (isInviteTicket) { + stringResource(R.string.invite_code_label) + } else { + when (paymentType) { + PaymentType.ACCOUNT_TRANSFER -> stringResource(R.string.payment_account_transfer) + PaymentType.CARD -> stringResource(R.string.payment_card) + PaymentType.UNDEFINED -> "" + } + }, + ) + } + } + } +} + +@Composable +private fun InfoRow( + modifier: Modifier = Modifier, + label: String, + value1: String, + value2: String? = null, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.bodySmall, color = Grey30) + Text(text = value1, style = MaterialTheme.typography.bodySmall, color = Grey15) + } + value2?.let { + Text( + modifier = Modifier + .padding(top = 4.dp) + .align(AbsoluteAlignment.Right), + text = value2, + style = MaterialTheme.typography.bodySmall, + color = Grey15, + ) + } + } +} + +@Preview +@Composable +private fun InfoRowPreview() { + Surface { + BooltiTheme { + InfoRow( + modifier = Modifier.fillMaxWidth(), + label = "예매자", + value1 = "박명범", + value2 = "01020302030" + ) + } + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingNavigation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingNavigation.kt new file mode 100644 index 00000000..6acf1033 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingNavigation.kt @@ -0,0 +1,30 @@ +package com.nexters.boolti.presentation.screen.ticketing + +import androidx.compose.ui.Modifier +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.isInviteTicket +import com.nexters.boolti.presentation.screen.salesTicketId +import com.nexters.boolti.presentation.screen.showId +import com.nexters.boolti.presentation.screen.ticketCount + +fun NavGraphBuilder.TicketingScreen( + navigateTo: (String) -> Unit, + popBackStack: () -> Unit, + modifier: Modifier = Modifier, +) { + composable( + route = "${MainDestination.Ticketing.route}/{$showId}?salesTicketId={$salesTicketId}&ticketCount={$ticketCount}&inviteTicket={$isInviteTicket}", + arguments = MainDestination.Ticketing.arguments, + ) { + TicketingScreen( + modifier = modifier, + onBackClicked = popBackStack, + onReserved = { reservationId, showId -> + navigateTo("${MainDestination.Payment.route}/$reservationId?showId=$showId") + }, + navigateToBusiness = { navigateTo(MainDestination.Business.route) }, + ) + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingScreen.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingScreen.kt index cf761671..2334bd6f 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingScreen.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -35,8 +34,6 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -55,10 +52,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -68,71 +67,68 @@ import coil.compose.AsyncImage import com.nexters.boolti.domain.model.InviteCodeStatus import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.BTTextField +import com.nexters.boolti.presentation.component.BtBackAppBar +import com.nexters.boolti.presentation.component.BusinessInformation import com.nexters.boolti.presentation.component.MainButton import com.nexters.boolti.presentation.component.ToastSnackbarHost -import com.nexters.boolti.presentation.extension.dayOfWeekString import com.nexters.boolti.presentation.extension.filterToPhoneNumber -import com.nexters.boolti.presentation.extension.format +import com.nexters.boolti.presentation.extension.showDateTimeString import com.nexters.boolti.presentation.theme.BooltiTheme import com.nexters.boolti.presentation.theme.Error import com.nexters.boolti.presentation.theme.Grey05 +import com.nexters.boolti.presentation.theme.Grey10 import com.nexters.boolti.presentation.theme.Grey20 import com.nexters.boolti.presentation.theme.Grey30 import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.Grey70 import com.nexters.boolti.presentation.theme.Grey80 import com.nexters.boolti.presentation.theme.Grey90 import com.nexters.boolti.presentation.theme.Success +import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point2 import com.nexters.boolti.presentation.util.PhoneNumberVisualTransformation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.time.LocalDateTime -@OptIn(ExperimentalMaterial3Api::class) @Composable fun TicketingScreen( modifier: Modifier = Modifier, viewModel: TicketingViewModel = hiltViewModel(), onBackClicked: () -> Unit = {}, onReserved: (reservationId: String, showId: String) -> Unit, + navigateToBusiness: () -> Unit, ) { val scrollState = rememberScrollState() val snackbarHostState = remember { SnackbarHostState() } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + var showConfirmDialog by remember { mutableStateOf(false) } + val specOut = false // TODO 다음 버전(페이 들어오는 버전)에서 추가될 기능 마킹 LaunchedEffect(viewModel.event) { viewModel.event.collect { when (it) { - is TicketingEvent.TicketingSuccess -> onReserved(it.reservationId, it.showId) + is TicketingEvent.TicketingSuccess -> { + showConfirmDialog = false + onReserved(it.reservationId, it.showId) + } } } } Scaffold( topBar = { - TopAppBar( - modifier = Modifier.padding(start = 20.dp), - title = { - Text( - text = stringResource(R.string.ticketing_toolbar_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - Icon( - painter = painterResource(id = R.drawable.ic_arrow_back), - contentDescription = stringResource(R.string.description_navigate_back), - modifier = Modifier.clickable(role = Role.Button) { onBackClicked() }, - ) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.background, - ), + BtBackAppBar( + title = stringResource(R.string.ticketing_toolbar_title), + onClickBack = onBackClicked, ) }, snackbarHost = { - ToastSnackbarHost(hostState = snackbarHostState, modifier = Modifier.padding(bottom = 100.dp)) + ToastSnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottom = 100.dp) + ) } ) { innerPadding -> Box(modifier = modifier.padding(innerPadding)) { @@ -146,34 +142,76 @@ fun TicketingScreen( showName = uiState.showName, showDate = uiState.showDate, ) + // 예매자 정보 TicketHolderSection( name = uiState.reservationName, - phoneNumber = uiState.reservationPhoneNumber, + phoneNumber = uiState.reservationContact, isSameContactInfo = uiState.isSameContactInfo, onNameChanged = viewModel::setReservationName, onPhoneNumberChanged = viewModel::setReservationPhoneNumber, - ) // 예매자 정보 - if (!uiState.isInviteTicket) DeposorSection( - name = uiState.depositorName, - phoneNumber = uiState.depositorPhoneNumber, - isSameContactInfo = uiState.isSameContactInfo, - onClickSameContact = viewModel::toggleIsSameContactInfo, - onNameChanged = viewModel::setDepositorName, - onPhoneNumberChanged = viewModel::setDepositorPhoneNumber, - ) // 입금자 정보 + ) + + // 입금자 정보 + if (!uiState.isInviteTicket && uiState.totalPrice > 0) { + DepositorSection( + name = uiState.depositorName, + phoneNumber = uiState.depositorContact, + isSameContactInfo = uiState.isSameContactInfo, + onClickSameContact = viewModel::toggleIsSameContactInfo, + onNameChanged = viewModel::setDepositorName, + onPhoneNumberChanged = viewModel::setDepositorPhoneNumber, + ) + } + + // 티켓 정보 TicketInfoSection( ticketName = uiState.ticketName, ticketCount = uiState.ticketCount, totalPrice = uiState.totalPrice, - ) // 티켓 정보 - if (uiState.isInviteTicket) InviteCodeSection( - uiState.inviteCode, - uiState.inviteCodeStatus, - onClickCheckInviteCode = viewModel::checkInviteCode, - onInviteCodeChanged = viewModel::setInviteCode, - ) // 초청 코드 - if (!uiState.isInviteTicket) PaymentSection(scope, snackbarHostState) // 결제 수단 + ) + + // 초청 코드 + if (uiState.isInviteTicket) { + InviteCodeSection( + uiState.inviteCode, + uiState.inviteCodeStatus, + onClickCheckInviteCode = viewModel::checkInviteCode, + onInviteCodeChanged = viewModel::setInviteCode, + ) + } + + if (!uiState.isInviteTicket && uiState.totalPrice > 0) PaymentSection( + scope, + snackbarHostState + ) // 결제 수단 if (!uiState.isInviteTicket) RefundPolicySection(uiState.refundPolicy) // 취소/환불 규정 + + Text( + modifier = Modifier + .padding(top = 24.dp, bottom = 20.dp) + .padding(horizontal = marginHorizontal), + text = stringResource(R.string.business_responsibility), + style = MaterialTheme.typography.labelMedium, + color = Grey70, + ) + + if (specOut) { + // 주문내용 확인 및 결제 동의 + OrderAgreementSection( + totalAgreed = uiState.orderAgreed, + agreement = uiState.orderAgreement, + agreementLabels = uiState.orderAgreementInfos, + onClickTotalAgree = viewModel::toggleAgreement, + onClickAgree = viewModel::toggleAgreement, + onClickShow = {}, // TODO 기획 확정되면 구현 + ) + } + + // 사업자 정보 + BusinessInformation( + modifier = Modifier.fillMaxWidth(), + onClick = navigateToBusiness + ) Spacer(modifier = Modifier.height(120.dp)) } @@ -200,11 +238,31 @@ fun TicketingScreen( .background(MaterialTheme.colorScheme.background) .padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 24.dp), enabled = uiState.reservationButtonEnabled, - label = stringResource(R.string.ticketing_payment_button_label, uiState.totalPrice), - onClick = viewModel::reservation, + label = stringResource( + R.string.ticketing_payment_button_label, + uiState.totalPrice + ), + onClick = { + showConfirmDialog = true + }, ) } } + if (showConfirmDialog) { + TicketingConfirmDialog( + isInviteTicket = uiState.isInviteTicket, + reservationName = uiState.reservationName, + reservationContact = uiState.reservationContact, + depositor = if (uiState.isSameContactInfo) uiState.reservationName else uiState.depositorName, + depositorContact = if (uiState.isSameContactInfo) uiState.reservationContact else uiState.depositorContact, + ticketName = uiState.ticketName, + ticketCount = uiState.ticketCount, + totalPrice = uiState.totalPrice, + paymentType = uiState.paymentType, + onClick = viewModel::reservation, + onDismiss = { showConfirmDialog = false }, + ) + } } } @@ -232,14 +290,17 @@ private fun Header( contentScale = ContentScale.Crop, ) - Column(verticalArrangement = Arrangement.Center, modifier = Modifier.padding(start = 16.dp)) { + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier.padding(start = 16.dp) + ) { Text( text = showName, style = point2, modifier = Modifier.padding(bottom = 4.dp) ) Text( - text = showDate.format("yyyy.MM.dd (${showDate.dayOfWeekString}) HH:mm"), + text = showDate.showDateTimeString, style = MaterialTheme.typography.bodySmall, color = Grey30, ) @@ -250,7 +311,6 @@ private fun Header( @Composable private fun RefundPolicySection(refundPolicy: List) { var expanded by remember { mutableStateOf(false) } -// val refundPolicy = stringArrayResource(R.array.refund_policy) val rotation by animateFloatAsState( targetValue = if (expanded) 0F else 180F, animationSpec = tween(), @@ -289,6 +349,7 @@ private fun RefundPolicySection(refundPolicy: List) { color = Grey50, ) Text( + modifier = Modifier.padding(start = 2.dp), text = it, style = MaterialTheme.typography.bodySmall, color = Grey50, @@ -366,7 +427,10 @@ private fun InviteCodeSection( text = inviteCode.uppercase(), singleLine = true, enabled = inviteCodeStatus !is InviteCodeStatus.Valid, - isError = inviteCodeStatus is InviteCodeStatus.Invalid, + isError = inviteCodeStatus in listOf( + InviteCodeStatus.Invalid, + InviteCodeStatus.Duplicated, + ), placeholder = stringResource(R.string.ticketing_invite_code_placeholder), keyboardOptions = KeyboardOptions.Default.copy( keyboardType = KeyboardType.Password, @@ -427,16 +491,29 @@ private fun TicketInfoSection(ticketName: String, ticketCount: Int, totalPrice: label = stringResource(R.string.ticket_count_label), value = stringResource(R.string.ticket_count, ticketCount), ) - SectionTicketInfo( - label = stringResource(R.string.total_payment_amount_label), - value = stringResource(R.string.unit_won, totalPrice), - ) + Row( + modifier = Modifier + .padding(top = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(R.string.total_payment_amount_label), + style = MaterialTheme.typography.bodyLarge, + color = Grey30 + ) + Text( + text = stringResource(R.string.unit_won, totalPrice), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + } Spacer(modifier = Modifier.padding(bottom = 8.dp)) } } @Composable -private fun DeposorSection( +private fun DepositorSection( name: String = "", phoneNumber: String = "", isSameContactInfo: Boolean, @@ -541,6 +618,110 @@ private fun TicketHolderSection( } } +@Composable +private fun OrderAgreementSection( + totalAgreed: Boolean, + agreementLabels: List, + agreement: List, + onClickTotalAgree: () -> Unit, + onClickAgree: (index: Int) -> Unit, + onClickShow: (index: Int) -> Unit, +) { + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .padding(20.dp), + ) { + Row( + modifier = Modifier.clickable(onClick = onClickTotalAgree), + verticalAlignment = Alignment.CenterVertically, + ) { + if (totalAgreed) { + Icon( + painter = painterResource(R.drawable.ic_checkbox_selected), + tint = Grey05, + contentDescription = null, + modifier = Modifier + .size(24.dp) + .padding(3.dp) + .background(MaterialTheme.colorScheme.primary, shape = CircleShape), + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_checkbox_18), + tint = Grey50, + contentDescription = null, + ) + } + Text( + text = stringResource(R.string.order_agreement_label), + color = Grey10, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 4.dp) + ) + } + + Spacer(modifier = Modifier.size(16.dp)) + agreementLabels.forEachIndexed { index, labelRes -> + OrderAgreementItem( + modifier = Modifier.padding(top = 4.dp), + index = index, + agreed = agreement[index], + label = stringResource(labelRes), + onClickAgree = onClickAgree, + onClickShow = onClickShow, + ) + } + } +} + +@Composable +private fun OrderAgreementItem( + modifier: Modifier = Modifier, + index: Int, + agreed: Boolean, + label: String, + onClickAgree: (index: Int) -> Unit, + onClickShow: (index: Int) -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.clickable { onClickAgree(index) }, + ) { + Icon( + modifier = Modifier.padding(end = 4.dp), + painter = painterResource(R.drawable.ic_check), contentDescription = label, + tint = if (agreed) MaterialTheme.colorScheme.primary else Grey50, + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = Grey50, + ) + } + Spacer(modifier = Modifier.weight(1f)) + ShowButton { onClickShow(index) } + } +} + +@Composable +private fun ShowButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Text( + modifier = modifier.clickable(onClick = onClick), + text = stringResource(R.string.show), + style = MaterialTheme.typography.bodySmall.copy( + fontWeight = FontWeight.SemiBold, + textDecoration = TextDecoration.Underline, + ), + color = Grey50, + ) +} + @Composable private fun Section( modifier: Modifier = Modifier, @@ -635,7 +816,27 @@ private fun SectionTicketInfo(label: String, value: String, marginTop: Dp = 16.d private fun TicketingDetailScreenPreview() { BooltiTheme { Surface { - TicketingScreen { _, _ -> } + TicketingScreen( + onReserved = { _, _ -> }, + navigateToBusiness = {} + ) + } + } +} + +@Preview +@Composable +private fun OrderAgreementItemPreview() { + BooltiTheme { + Surface { + var agreed by remember { mutableStateOf(false) } + OrderAgreementItem( + index = 0, + agreed = agreed, + label = "test", + onClickAgree = { agreed = !agreed }, + onClickShow = {}, + ) } } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingState.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingState.kt index a5d3ab57..05b40266 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingState.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingState.kt @@ -2,6 +2,7 @@ package com.nexters.boolti.presentation.screen.ticketing import com.nexters.boolti.domain.model.InviteCodeStatus import com.nexters.boolti.domain.model.PaymentType +import com.nexters.boolti.presentation.R import java.time.LocalDateTime data class TicketingState( @@ -17,21 +18,52 @@ data class TicketingState( val inviteCodeStatus: InviteCodeStatus = InviteCodeStatus.Default, val paymentType: PaymentType = PaymentType.ACCOUNT_TRANSFER, val reservationName: String = "", - val reservationPhoneNumber: String = "", + val reservationContact: String = "", val depositorName: String = "", - val depositorPhoneNumber: String = "", + val depositorContact: String = "", val inviteCode: String = "", val refundPolicy: List = emptyList(), + val orderAgreement: List = listOf(false, false, false), ) { + val orderAgreementInfos = listOf( + R.string.order_agreement_privacy_collection, + R.string.order_agreement_privacy_offer, + R.string.order_agreement_payment_agency, + ) + + val orderAgreed: Boolean + get() = orderAgreement.none { !it } + val reservationButtonEnabled: Boolean - get() = if (isInviteTicket) { - reservationName.isNotBlank() && - reservationPhoneNumber.isNotBlank() && - inviteCodeStatus is InviteCodeStatus.Valid + get() = when { + isInviteTicket -> // orderAgreed && + reservationName.isNotBlank() && + reservationContact.isNotBlank() && + inviteCodeStatus is InviteCodeStatus.Valid + + totalPrice == 0 -> // orderAgreed &7 + reservationName.isNotBlank() && + reservationContact.isNotBlank() + + else -> // orderAgreed && + reservationName.isNotBlank() && + reservationContact.isNotBlank() && + (isSameContactInfo || depositorName.isNotBlank()) && + (isSameContactInfo || depositorContact.isNotBlank()) + } + + fun toggleAgreement(index: Int): TicketingState { + val updated = orderAgreement.toMutableList().apply { + set(index, !orderAgreement[index]) + } + return copy(orderAgreement = updated) + } + + fun toggleAgreement(): TicketingState { + return if (orderAgreed) { + copy(orderAgreement = orderAgreement.map { false }) } else { - reservationName.isNotBlank() && - reservationPhoneNumber.isNotBlank() && - (isSameContactInfo || depositorName.isNotBlank()) && - (isSameContactInfo || depositorPhoneNumber.isNotBlank()) + copy(orderAgreement = orderAgreement.map { true }) } + } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingViewModel.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingViewModel.kt index 2edb718d..eea72878 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingViewModel.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticketing/TicketingViewModel.kt @@ -1,7 +1,6 @@ package com.nexters.boolti.presentation.screen.ticketing import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.boolti.domain.model.InviteCodeStatus import com.nexters.boolti.domain.repository.TicketingRepository @@ -10,6 +9,7 @@ import com.nexters.boolti.domain.request.TicketingInfoRequest import com.nexters.boolti.domain.request.TicketingRequest import com.nexters.boolti.domain.usecase.GetRefundPolicyUsecase import com.nexters.boolti.domain.usecase.GetUserUsecase +import com.nexters.boolti.presentation.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.singleOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import timber.log.Timber import javax.inject.Inject @@ -31,7 +32,7 @@ class TicketingViewModel @Inject constructor( private val repository: TicketingRepository, getUserUsecase: GetUserUsecase, private val getRefundPolicyUsecase: GetRefundPolicyUsecase, -) : ViewModel() { +) : BaseViewModel() { private val showId: String = requireNotNull(savedStateHandle["showId"]) private val salesTicketTypeId: String = requireNotNull(savedStateHandle["salesTicketId"]) private val ticketCount: Int = savedStateHandle["ticketCount"] ?: 1 @@ -54,20 +55,20 @@ class TicketingViewModel @Inject constructor( showId = showId, salesTicketTypeId = salesTicketTypeId, reservationName = state.reservationName, - reservationPhoneNumber = state.reservationPhoneNumber, + reservationPhoneNumber = state.reservationContact, ) false -> TicketingRequest.Normal( ticketCount = uiState.value.ticketCount, depositorName = if (uiState.value.isSameContactInfo) state.reservationName else state.depositorName, - depositorPhoneNumber = if (uiState.value.isSameContactInfo) state.reservationPhoneNumber else state.depositorPhoneNumber, + depositorPhoneNumber = if (uiState.value.isSameContactInfo) state.reservationContact else state.depositorContact, paymentAmount = uiState.value.totalPrice, paymentType = uiState.value.paymentType, userId = userId, showId = showId, salesTicketTypeId = salesTicketTypeId, reservationName = state.reservationName, - reservationPhoneNumber = state.reservationPhoneNumber, + reservationPhoneNumber = state.reservationContact, ) } @@ -76,12 +77,12 @@ class TicketingViewModel @Inject constructor( } fun reservation() { - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { repository.requestReservation(reservationRequest) .onStart { _uiState.update { it.copy(loading = true) } } .catch { e -> - e.printStackTrace() _uiState.update { it.copy(loading = false) } + throw e } .singleOrNull()?.let { reservationId -> Timber.tag("MANGBAAM-TicketingViewModel(reservation)").d("예매 성공: $reservationId") @@ -92,9 +93,8 @@ class TicketingViewModel @Inject constructor( } private fun load() { - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { repository.getTicketingInfo(TicketingInfoRequest(showId, salesTicketTypeId, ticketCount)) - .catch { e -> e.printStackTrace() } .onStart { _uiState.update { it.copy(loading = true) } } @@ -114,13 +114,12 @@ class TicketingViewModel @Inject constructor( } } getRefundPolicyUsecase() - .catch { e -> e.printStackTrace() } .onEach { refundPolicy -> _uiState.update { it.copy(refundPolicy = refundPolicy) } } - .launchIn(viewModelScope) + .launchIn(viewModelScope + recordExceptionHandler) } } @@ -131,7 +130,7 @@ class TicketingViewModel @Inject constructor( } fun checkInviteCode() { - viewModelScope.launch { + viewModelScope.launch(recordExceptionHandler) { repository.checkInviteCode( CheckInviteCodeRequest( showId = showId, @@ -141,8 +140,8 @@ class TicketingViewModel @Inject constructor( ).onStart { _uiState.update { it.copy(loading = true) } }.catch { e -> - e.printStackTrace() _uiState.update { it.copy(loading = false) } + throw e }.singleOrNull()?.let { status -> _uiState.update { it.copy(loading = false, inviteCodeStatus = status) @@ -156,7 +155,7 @@ class TicketingViewModel @Inject constructor( } fun setReservationPhoneNumber(number: String) { - _uiState.update { it.copy(reservationPhoneNumber = number) } + _uiState.update { it.copy(reservationContact = number) } } fun setDepositorName(name: String) { @@ -164,13 +163,21 @@ class TicketingViewModel @Inject constructor( } fun setDepositorPhoneNumber(number: String) { - _uiState.update { it.copy(depositorPhoneNumber = number) } + _uiState.update { it.copy(depositorContact = number) } } fun setInviteCode(code: String) { _uiState.update { it.copy(inviteCode = code, inviteCodeStatus = InviteCodeStatus.Default) } } + fun toggleAgreement(index: Int) { + _uiState.update { it.toggleAgreement(index) } + } + + fun toggleAgreement() { + _uiState.update { it.toggleAgreement() } + } + private fun event(event: TicketingEvent) { viewModelScope.launch { _event.send(event) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/service/BtFirebaseMessagingService.kt b/presentation/src/main/java/com/nexters/boolti/presentation/service/BtFirebaseMessagingService.kt new file mode 100644 index 00000000..0ce2ba05 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/service/BtFirebaseMessagingService.kt @@ -0,0 +1,71 @@ +package com.nexters.boolti.presentation.service + +import android.Manifest +import android.app.PendingIntent +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.nexters.boolti.domain.repository.AuthRepository +import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.extension.checkGrantedPermission +import com.nexters.boolti.presentation.screen.DeepLinkEvent +import com.nexters.boolti.presentation.screen.splash.SplashActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@AndroidEntryPoint +class BtFirebaseMessagingService : FirebaseMessagingService() { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Inject + lateinit var authRepository: AuthRepository + + @Inject + lateinit var deepLinkEvent: DeepLinkEvent + + override fun onNewToken(token: String) { + scope.launch { + authRepository.sendFcmToken() + } + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + if (!checkGrantedPermission(Manifest.permission.POST_NOTIFICATIONS)) return + + val notification = remoteMessage.notification ?: return + val btNotification = BtNotification(remoteMessage.data["type"]) + + val defaultChannelId = getString(R.string.default_notification_channel_id) + val pendingIntent = PendingIntent.getActivity( + applicationContext, + UUID.randomUUID().hashCode(), + Intent(this, SplashActivity::class.java).putExtra( + "type", + btNotification.type + ), + PendingIntent.FLAG_IMMUTABLE + ) + val builder = + NotificationCompat.Builder(this, notification.channelId ?: defaultChannelId) + .setContentTitle(notification.title) + .setContentText(notification.body) + .setColor(0xFF6827) + .setSmallIcon(R.drawable.ic_logo) + .setContentIntent(pendingIntent) + + NotificationManagerCompat.from(this).notify(btNotification.id, builder.build()) + } + + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/service/BtNotification.kt b/presentation/src/main/java/com/nexters/boolti/presentation/service/BtNotification.kt new file mode 100644 index 00000000..73bc7819 --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/service/BtNotification.kt @@ -0,0 +1,15 @@ +package com.nexters.boolti.presentation.service + +enum class BtNotification(val id: Int, val type: String, val deepLink: String?) { + RESERVATION_COMPLETED(id = 0, type = "RESERVATION_COMPLETED", deepLink = "https://app.boolti.in/home/tickets"), + ENTER_NOTIFICATION(id = 3, type = "ENTER_NOTIFICATION", deepLink = "https://app.boolti.in/home/tickets"), + UNDEFINED(id = -1, type = "UNDEFINED", deepLink = null), +} + +fun BtNotification(type: String?): BtNotification { + return when(type) { + BtNotification.RESERVATION_COMPLETED.type -> BtNotification.RESERVATION_COMPLETED + BtNotification.ENTER_NOTIFICATION.type -> BtNotification.ENTER_NOTIFICATION + else -> BtNotification.UNDEFINED + } +} diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/theme/Color.kt b/presentation/src/main/java/com/nexters/boolti/presentation/theme/Color.kt index 327d787f..3584c857 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/theme/Color.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/theme/Color.kt @@ -13,6 +13,7 @@ val Orange01 = Color(0xFFFF6827) val Error = Color(0xFFFF4D4F) val Success = Color(0xFF52C41A) +val Warning = Color(0xFFFAAD14) val Grey05 = Color(0xFFF6F7FF) val Grey10 = Color(0xFFE7EAF2) diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/theme/Type.kt b/presentation/src/main/java/com/nexters/boolti/presentation/theme/Type.kt index 2b57ce7c..f81a5bfc 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/theme/Type.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/theme/Type.kt @@ -5,9 +5,9 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.LineHeightStyle import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.nexters.boolti.presentation.R @@ -21,7 +21,8 @@ val pretendardFamily = FontFamily( ) val aggroFamily = FontFamily( - Font(R.font.sb_aggro_m, FontWeight.Normal), + Font(R.font.sb_aggro_m, FontWeight.Medium), + Font(R.font.sb_aggro_b, FontWeight.Bold), ) private val headline3 = createTextStyle( @@ -96,23 +97,34 @@ private val caption = createTextStyle( val point1 = createTextStyle( fontFamily = aggroFamily, - fontWeight = FontWeight.Normal, + fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 26.sp, + letterSpacing = (-0.03).em, ) val point2 = createTextStyle( fontFamily = aggroFamily, - fontWeight = FontWeight.Normal, + fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 30.sp, + letterSpacing = (-0.03).em, +) + +val point3 = createTextStyle( + fontFamily = aggroFamily, + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + lineHeight = 34.sp, + letterSpacing = (-0.03).em, ) val point4 = createTextStyle( fontFamily = aggroFamily, - fontWeight = FontWeight.Normal, + fontWeight = FontWeight.Medium, fontSize = 24.sp, lineHeight = 34.sp, + letterSpacing = (-0.03).em, ) private fun createTextStyle( @@ -120,6 +132,7 @@ private fun createTextStyle( lineHeight: TextUnit, fontFamily: FontFamily = pretendardFamily, fontWeight: FontWeight = FontWeight.Normal, + letterSpacing: TextUnit = TextUnit.Unspecified, ): TextStyle { return TextStyle( fontFamily = fontFamily, @@ -127,10 +140,10 @@ private fun createTextStyle( fontSize = fontSize, lineHeight = lineHeight, lineHeightStyle = LineHeightStyle( - alignment = LineHeightStyle.Alignment.Proportional, + alignment = LineHeightStyle.Alignment.Center, trim = LineHeightStyle.Trim.None ), - baselineShift = BaselineShift(0.12f), + letterSpacing = letterSpacing, ) } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/util/SnackbarController.kt b/presentation/src/main/java/com/nexters/boolti/presentation/util/SnackbarController.kt new file mode 100644 index 00000000..0dfbeb4e --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/util/SnackbarController.kt @@ -0,0 +1,19 @@ +package com.nexters.boolti.presentation.util + +import androidx.compose.material3.SnackbarHostState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class SnackbarController( + private val snackbarHostState: SnackbarHostState, + private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob()), +) { + fun showMessage( + message: String, + ) { + coroutineScope.launch { + snackbarHostState.showSnackbar(message) + } + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/util/VisualTransformation.kt b/presentation/src/main/java/com/nexters/boolti/presentation/util/VisualTransformation.kt index e7baa61e..f6a2c033 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/util/VisualTransformation.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/util/VisualTransformation.kt @@ -5,20 +5,13 @@ import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import com.nexters.boolti.presentation.extension.filterToPhoneNumber +import com.nexters.boolti.presentation.extension.toContactFormat class PhoneNumberVisualTransformation( private val sep: Char = '-', ) : VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val filtered = text.text.filterToPhoneNumber() - val annotatedString = AnnotatedString.Builder().run { - filtered.forEachIndexed { i, n -> - if (i in listOf(3, 7)) append(sep) - append(n) - } - toAnnotatedString() - } - + val annotatedString = AnnotatedString(text.text.toContactFormat(sep)) return TransformedText(annotatedString, phoneNumberOffsetMapping) } diff --git a/presentation/src/main/res/drawable/ic_check.xml b/presentation/src/main/res/drawable/ic_check.xml new file mode 100644 index 00000000..7ff24ca5 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/presentation/src/main/res/drawable/ic_error.xml b/presentation/src/main/res/drawable/ic_error.xml new file mode 100644 index 00000000..9d606708 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_error.xml @@ -0,0 +1,14 @@ + + + + diff --git a/presentation/src/main/res/drawable/ic_logo.xml b/presentation/src/main/res/drawable/ic_logo.xml index fa642826..1371b49f 100644 --- a/presentation/src/main/res/drawable/ic_logo.xml +++ b/presentation/src/main/res/drawable/ic_logo.xml @@ -1,15 +1,9 @@ - - - - + android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + diff --git a/presentation/src/main/res/drawable/ic_logo_boolti.xml b/presentation/src/main/res/drawable/ic_logo_boolti.xml index fb70f7cc..c9ef8e49 100644 --- a/presentation/src/main/res/drawable/ic_logo_boolti.xml +++ b/presentation/src/main/res/drawable/ic_logo_boolti.xml @@ -1,36 +1,28 @@ - - - - - - - - - - - - + android:width="71dp" + android:height="26dp" + android:viewportWidth="71" + android:viewportHeight="26"> + + + + + + diff --git a/presentation/src/main/res/drawable/ic_stepper_minus.xml b/presentation/src/main/res/drawable/ic_stepper_minus.xml new file mode 100644 index 00000000..89b12fc9 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_stepper_minus.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/presentation/src/main/res/drawable/ic_stepper_plus.xml b/presentation/src/main/res/drawable/ic_stepper_plus.xml new file mode 100644 index 00000000..dac41b0d --- /dev/null +++ b/presentation/src/main/res/drawable/ic_stepper_plus.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/presentation/src/main/res/drawable/ic_warning.xml b/presentation/src/main/res/drawable/ic_warning.xml new file mode 100644 index 00000000..27d50942 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,13 @@ + + + + diff --git a/presentation/src/main/res/font/sb_aggro_b.otf b/presentation/src/main/res/font/sb_aggro_b.otf new file mode 100644 index 00000000..f909eae5 Binary files /dev/null and b/presentation/src/main/res/font/sb_aggro_b.otf differ diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index c85fe8c0..bc0c64ba 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -3,6 +3,8 @@ 티켓 마이 + + 뒤로 가기 포스터 사진 닫기 @@ -27,6 +29,7 @@ 공연 종료 공연명으로 검색해 주세요 입금 확인 중인 티켓이 있어요! + 예매 중 @@ -49,6 +52,15 @@ 알 수 없는 에러가 발생했습니다 복사 다음 + 보기 + + 예매자 + 결제자 + 티켓 + 초청 티켓 + + 감소 + 증가 불티나게 팔리는 티켓, 불티 @@ -59,12 +71,21 @@ 약관 동의하고 시작하기 불티를 찾아주셔서 감사합니다 불티 유저 + 탈퇴 후 30일 이내에 로그인하여, 계정 삭제가 취소되었어요\n불티를 다시 찾아주셔서 감사해요! - 회원 탈퇴 - 탈퇴 + 회원 탈퇴 + 탈퇴하기 탈퇴하시겠어요? 탈퇴일로부터 30일 이내로 로그인 시 계정 삭제를 취소할 수 있습니다. 30일이 지나면 계정 및 정보가 영구 삭제됩니다. + 탈퇴 전, 꼭 읽어보세요! + + 주최한 공연 정보는 사라지지 않아요 + 예매한 티켓은 전부 사라지며 복구할 수 없어요 + 탈퇴 일로부터 30일 이내 재 로그인 시 계정 삭제를 취소할 수 있어요 + + 탈퇴 이유를 입력해주세요 + 예) 계정 탈퇴 후 재가입할게요 %,d원 총 %,d원 @@ -83,7 +104,7 @@ 공연 정보 보기 - 티켓 선택 + 옵션 선택 공유하기 티켓 예매 기간 일시 @@ -98,12 +119,12 @@ 이미 예매한 공연 1인 1매만 예매할 수 있어요 예매 시작 D-%d - 예매 마감 + 예매 종료 공연 종료 1인 %d매 결제하기 예매자 정보 - 입금자 정보 + 결제자 정보 티켓 정보 초청 코드 결제 수단 @@ -118,17 +139,24 @@ 사용하기 사용되었습니다 이미 사용된 초청 코드입니다 - 초청 코드가 올바르지 않아요 + 올바른 초청 코드를 입력해 주세요 초청 코드를 입력해 주세요 - 예매자와 입금자가 같아요 + 예매자와 결제자가 같아요 티켓 종류 티켓 매수 총 결제 금액 + 결제 금액 다음 페이지에서 계좌 번호를 안내해 드릴게요 지금은 계좌 이체로만 결제할 수 있어요 %,d원 결제하기 + 결제하기 + 결제 정보를 확인해주세요 공연장 주소가 복사되었어요 %s (%s) + 주문내용 확인 및 결제 동의 + [필수] 개인정보 수집・이용 동의 + [필수] 개인정보 제 3자 정보 제공 동의 + [필수] 결제대행 서비스 이용약관 동의 입장 코드 @@ -148,7 +176,7 @@ 계좌번호 예금주 입금 마감일 - 결제가 완료되었어요 + 결제를 완료했어요 예매자 정보 확인 후 티켓이 발권됩니다. @@ -158,6 +186,7 @@ 예매 내역 QR 스캔 정말 로그아웃 하시겠어요? + 공연 등록 QR 스캔 @@ -169,28 +198,35 @@ 예매 내역이 없어요 티켓을 예매하고 공연을 즐겨보세요! 입금 확인 중 - 환불 진행 중 + 취소 진행 중 예매 취소 - 티켓 발권 완료 - 환불 완료 + 발권 완료 + 취소 완료 상세 보기 알 수 없음 "%s / %d매 / %,d원" + %d매 / %,d원" 예매 내역 상세 입금 계좌 정보 결제 정보 - 결제 상태 + 결제 수단 티켓 정보 발권 일시 발권 전 %d매 "%s / %d매" + 주문 번호 + 주문 티켓 + 환불 내역 + 총 환불 금액 + 티켓 보기 + 예매 내역보기 - 환불 요청하기 - 환불 이유를 입력해 주세요 + 취소 요청하기 + 취소 사유를 입력해 주세요 예) 티켓 종류 재 선택 후 다시 예매할게요 예금주 정보 실명을 입력해 주세요 @@ -199,7 +235,11 @@ 계좌번호를 입력해 주세요 선택 완료하기 환불 정보를 확인해 주세요 - 환불 요청이 완료되었어요 + 취소 요청이 완료되었어요 + 환불 정보 + 환불 예정 금액 + 환불 수단 + 취소/환불 규정을 확인했습니다 신고하기 @@ -212,4 +252,28 @@ 업데이트 알림 지금 업데이트하고\n더 편리해진 불티를 만나보세요 업데이트 하러가기 - \ No newline at end of file + + + fcm + 불티 알림 + 티켓 발권 알림을 받습니다. + + + 사업자 정보 + 스튜디오 불티는 통신판매중개자로 통신판매의 당사자가 아닙니다. 불티에서 판매되는 상품에 대한 광고, 상품주문, 배송, 환불의 의무와 책임은 각 판매자에게 있습니다. + 스튜디오 불티 사업자 정보 + ⓒ Boolti. All Rights Reserved + 스튜디오 불티 + 서비스 이용약관 + 개인정보 처리방침 + 환불 정책 + + 대표 : 김혜선 + 사업자 등록번호 : 202–43–63442 + 주소 : 경기도 남양주시 화도읍 묵현로 25번길 12–5 + 호스팅 서비스 : 스튜디오 불티 + 통신판매업 신고번호 : 2024-화도수동-0518 + 문의전화 : 0507–1363–5690 + 이메일 : studio.boolti@gmail.com + +