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 9be339f0..cb764293 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 @@ -2,13 +2,17 @@ package com.nexters.boolti.data.datasource import com.nexters.boolti.data.network.api.TicketService import com.nexters.boolti.data.network.response.TicketDetailDto -import com.nexters.boolti.domain.model.Ticket +import com.nexters.boolti.data.network.response.TicketGroupDto +import com.nexters.boolti.domain.model.LegacyTicket +import com.nexters.boolti.domain.model.TicketGroup import retrofit2.Response import javax.inject.Inject internal class TicketDataSource @Inject constructor( private val apiService: TicketService, ) { - suspend fun getTickets(): List = apiService.getTickets().map { it.toDomain() } - suspend fun getTicket(ticketId: String): Response = apiService.getTicket(ticketId) + suspend fun legacyGetTickets(): List = apiService.legacyGetTickets().map { it.toDomain() } + suspend fun legacyGetTicket(ticketId: String): Response = apiService.legacyGetTicket(ticketId) + suspend fun getTickets(): List = apiService.getTickets().map { it.toDomain() } + suspend fun getTicket(reservationId: String): Response = apiService.getTicket(reservationId) } 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 4ec690a8..5caf31f3 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 @@ -2,16 +2,26 @@ package com.nexters.boolti.data.network.api import com.nexters.boolti.data.network.response.TicketDetailDto import com.nexters.boolti.data.network.response.TicketDto +import com.nexters.boolti.data.network.response.TicketGroupDto +import com.nexters.boolti.data.network.response.TicketsDto import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Path internal interface TicketService { @GET("/app/api/v1/tickets") - suspend fun getTickets(): List + suspend fun legacyGetTickets(): List @GET("/app/api/v1/ticket/{ticketId}") - suspend fun getTicket( + suspend fun legacyGetTicket( @Path("ticketId") ticketId: String, ): Response + + @GET("/app/api/v1/reservation/tickets") + suspend fun getTickets(): List + + @GET("/app/api/v1/ticket/reservation/{reservationId}") + suspend fun getTicket( + @Path("reservationId") reservationId: String, + ): Response } 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 05529d50..ecede50a 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 @@ -1,7 +1,7 @@ package com.nexters.boolti.data.network.response import com.nexters.boolti.data.util.toLocalDateTime -import com.nexters.boolti.domain.model.Ticket +import com.nexters.boolti.domain.model.LegacyTicket import kotlinx.serialization.Serializable import java.time.LocalDateTime import java.time.format.DateTimeFormatter @@ -21,7 +21,7 @@ internal data class TicketDto( val ticketCreatedAt: String, val csTicketId: String, ) { - fun toDomain(): Ticket = Ticket( + fun toDomain(): LegacyTicket = LegacyTicket( userId = userId, ticketId = ticketId, showName = showName, @@ -61,7 +61,7 @@ data class TicketDetailDto( val csReservationId: String = "", val csTicketId: String = "", ) { - fun toDomain(): Ticket = Ticket( + fun toDomain(): LegacyTicket = LegacyTicket( userId = userId, showId = showId, ticketId = ticketId, diff --git a/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt b/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt new file mode 100644 index 00000000..2520cc98 --- /dev/null +++ b/data/src/main/java/com/nexters/boolti/data/network/response/TicketGroupDto.kt @@ -0,0 +1,138 @@ +package com.nexters.boolti.data.network.response + + +import com.nexters.boolti.data.util.toLocalDateTime +import com.nexters.boolti.domain.model.TicketGroup +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.time.LocalDateTime + +@Serializable +internal data class TicketsDto( + @SerialName("userId") + val userId: String? = null, + @SerialName("reservationId") + val reservationId: String? = null, + @SerialName("showName") + val showName: String? = null, + @SerialName("placeName") + val placeName: String? = null, + @SerialName("showDate") + val showDate: String? = null, + @SerialName("showImgPath") + val showImgPath: String? = null, + @SerialName("ticketType") + val ticketType: String? = null, + @SerialName("ticketName") + val ticketName: String? = null, + @SerialName("ticketCount") + val ticketCount: Int? = null, +) { + fun toDomain(): TicketGroup = TicketGroup( + userId = userId ?: "", + showId = "", + reservationId = reservationId ?: "", + showName = showName ?: "", + placeName = placeName ?: "", + streetAddress = "", + detailAddress = "", + showDate = showDate?.toLocalDateTime() ?: LocalDateTime.now(), + notice = "", + ticketNotice = "", + poster = showImgPath ?: "", + ticketType = TicketGroup.TicketType.convert(ticketType), + ticketName = ticketName ?: "", + hostName = "", + hostPhoneNumber = "", + tickets = List(ticketCount ?: 0) { + TicketGroup.Ticket( + ticketId = "", + entryCode = "", + usedAt = null, + ticketCreatedAt = LocalDateTime.MIN, + csTicketId = "", + showDate = LocalDateTime.MIN, + ) + } + ) +} + +@Serializable +internal data class TicketGroupDto( + @SerialName("detailAddress") + val detailAddress: String? = null, + @SerialName("hostName") + val hostName: String? = null, + @SerialName("hostPhoneNumber") + val hostPhoneNumber: String? = null, + @SerialName("notice") + val notice: String? = null, + @SerialName("placeName") + val placeName: String? = null, + @SerialName("reservationId") + val reservationId: String? = null, + @SerialName("showDate") + val showDate: String? = null, + @SerialName("showId") + val showId: String? = null, + @SerialName("showImgPath") + val showImgPath: String? = null, + @SerialName("showName") + val showName: String? = null, + @SerialName("streetAddress") + val streetAddress: String? = null, + @SerialName("ticketName") + val ticketName: String? = null, + @SerialName("ticketNotice") + val ticketNotice: String? = null, + @SerialName("ticketType") + val ticketType: String? = null, + @SerialName("tickets") + val tickets: List? = null, + @SerialName("userId") + val userId: String? = null, +) { + fun toDomain(): TicketGroup = TicketGroup( + userId = userId ?: "", + showId = showId ?: "", + reservationId = reservationId ?: "", + showName = showName ?: "", + placeName = placeName ?: "", + streetAddress = streetAddress ?: "", + detailAddress = detailAddress ?: "", + showDate = showDate?.toLocalDateTime() ?: LocalDateTime.MIN, + notice = notice ?: "", + ticketNotice = ticketNotice ?: "", + poster = showImgPath ?: "", + ticketType = TicketGroup.TicketType.convert(ticketType), + ticketName = ticketName ?: "", + hostName = hostName ?: "", + hostPhoneNumber = hostPhoneNumber ?: "", + tickets = tickets?.map { + it.toDomain(showDate = showDate?.toLocalDateTime() ?: LocalDateTime.MIN) + } ?: emptyList(), + ) + + @Serializable + data class TicketDto( + @SerialName("csTicketId") + val csTicketId: String? = null, + @SerialName("entryCode") + val entryCode: String? = null, + @SerialName("ticketCreatedAt") + val ticketCreatedAt: String? = null, + @SerialName("ticketId") + val ticketId: String? = null, + @SerialName("usedAt") + val usedAt: String? = null, + ) { + fun toDomain(showDate: LocalDateTime): TicketGroup.Ticket = TicketGroup.Ticket( + ticketId = ticketId ?: "", + entryCode = entryCode ?: "", + usedAt = usedAt?.toLocalDateTime(), + ticketCreatedAt = ticketCreatedAt?.toLocalDateTime() ?: LocalDateTime.MIN, + csTicketId = csTicketId ?: "", + showDate = showDate, + ) + } +} 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 3f9cc274..546bcda0 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 @@ -6,7 +6,8 @@ import com.nexters.boolti.domain.exception.ManagerCodeErrorType import com.nexters.boolti.domain.exception.ManagerCodeException import com.nexters.boolti.domain.exception.TicketException import com.nexters.boolti.domain.extension.errorType -import com.nexters.boolti.domain.model.Ticket +import com.nexters.boolti.domain.model.LegacyTicket +import com.nexters.boolti.domain.model.TicketGroup import com.nexters.boolti.domain.repository.TicketRepository import com.nexters.boolti.domain.request.ManagerCodeRequest import kotlinx.coroutines.flow.Flow @@ -17,12 +18,27 @@ internal class TicketRepositoryImpl @Inject constructor( private val dataSource: TicketDataSource, private val hostDataSource: HostDataSource, ) : TicketRepository { - override suspend fun getTicket(): Flow> = flow { + override suspend fun legacyGetTicket(): Flow> = flow { + emit(dataSource.legacyGetTickets()) + } + + override suspend fun legacyGetTicket(ticketId: String): Flow = flow { + val response = dataSource.legacyGetTicket(ticketId) + if (response.isSuccessful) { + response.body()?.toDomain()?.let { emit(it) } + } else { + when (response.code()) { + 404 -> throw TicketException.TicketNotFound + } + } + } + + override fun getTickets(): Flow> = flow { emit(dataSource.getTickets()) } - override suspend fun getTicket(ticketId: String): Flow = flow { - val response = dataSource.getTicket(ticketId) + override fun getTicket(reservationId: String): Flow = flow { + val response = dataSource.getTicket(reservationId) if (response.isSuccessful) { response.body()?.toDomain()?.let { emit(it) } } else { diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/LegacyTicket.kt b/domain/src/main/java/com/nexters/boolti/domain/model/LegacyTicket.kt new file mode 100644 index 00000000..32a28e19 --- /dev/null +++ b/domain/src/main/java/com/nexters/boolti/domain/model/LegacyTicket.kt @@ -0,0 +1,62 @@ +package com.nexters.boolti.domain.model + +import java.time.LocalDateTime + +/** + * @property userId + * @property showId + * @property ticketId + * @property reservationId + * @property salesTicketTypeId + * @property showName + * @property streetAddress + * @property detailAddress + * @property showDate + * @property poster + * @property isInviteTicket + * @property ticketName + * @property notice 공연 내용 (공연 상세에서 사용) + * @property ticketNotice 안내사항 for 주최자 + * @property placeName + * @property entryCode QR 에 담길 정보 + * @property usedAt + * @property hostName + * @property hostPhoneNumber + */ +data class LegacyTicket( + val userId: String = "", + val showId: String = "", + val ticketId: String = "", + val reservationId: String = "", + val salesTicketTypeId: String = "", + val showName: String = "", + val streetAddress: String = "", + val detailAddress: String = "", + val showDate: LocalDateTime = LocalDateTime.now(), + val poster: String = "", + val isInviteTicket: Boolean = false, + val ticketName: String = "", + val notice: String = "", + val ticketNotice: String = "", + val placeName: String = "", + val entryCode: String = "", + 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 + else -> TicketState.Ready + } + } +} + +enum class TicketState { + Ready, Used, Finished +} 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 ed87dcd1..c33ab8e7 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 @@ -2,61 +2,64 @@ package com.nexters.boolti.domain.model import java.time.LocalDateTime -/** - * @property userId - * @property showId - * @property ticketId - * @property reservationId - * @property salesTicketTypeId - * @property showName - * @property streetAddress - * @property detailAddress - * @property showDate - * @property poster - * @property isInviteTicket - * @property ticketName - * @property notice 공연 내용 (공연 상세에서 사용) - * @property ticketNotice 안내사항 for 주최자 - * @property placeName - * @property entryCode QR 에 담길 정보 - * @property usedAt - * @property hostName - * @property hostPhoneNumber - */ data class Ticket( + val userId: String, + val reservationId: String, + val showName: String, + val placeName: String, + val showDate: LocalDateTime, + val poster: String, + val ticketType: TicketGroup.TicketType, + val ticketName: String, + val ticketCount: Int, +) + +data class TicketGroup( val userId: String = "", val showId: String = "", - val ticketId: String = "", val reservationId: String = "", - val salesTicketTypeId: String = "", val showName: String = "", + val placeName: String = "", val streetAddress: String = "", val detailAddress: String = "", - val showDate: LocalDateTime = LocalDateTime.now(), - val poster: String = "", - val isInviteTicket: Boolean = false, - val ticketName: String = "", + val showDate: LocalDateTime = LocalDateTime.MIN, val notice: String = "", val ticketNotice: String = "", - val placeName: String = "", - val entryCode: String = "", - val usedAt: LocalDateTime? = null, + val poster: String = "", + val ticketType: TicketType = TicketType.Unknown, + val ticketName: String = "", val hostName: String = "", val hostPhoneNumber: String = "", - val csReservationId: String = "", - val csTicketId: String = "", + val tickets: List = emptyList(), ) { - val ticketState: TicketState - get() = run { - val now = LocalDateTime.now() - when { - usedAt != null && now > usedAt -> TicketState.Used - now.toLocalDate() > showDate.toLocalDate() -> TicketState.Finished - else -> TicketState.Ready + data class Ticket( + val ticketId: String = "", + val entryCode: String = "", + val usedAt: LocalDateTime? = null, + val ticketCreatedAt: LocalDateTime = LocalDateTime.MIN, + val csTicketId: String = "", + val showDate: LocalDateTime = LocalDateTime.MIN, + ) { + val ticketState: TicketState + get() = run { + val now = LocalDateTime.now() + when { + usedAt != null && now > usedAt -> TicketState.Used + now.toLocalDate() > showDate.toLocalDate() -> TicketState.Finished + else -> TicketState.Ready + } } - } -} + } -enum class TicketState { - Ready, Used, Finished + enum class TicketType { + Unknown, Sale, Invite; + + companion object { + fun convert(type: String?): TicketType = when (type?.trim()?.uppercase()) { + "SALE", "SALES" -> Sale + "INVITE", "INVITED" -> Invite + else -> Unknown + } + } + } } diff --git a/domain/src/main/java/com/nexters/boolti/domain/model/TicketingTicket.kt b/domain/src/main/java/com/nexters/boolti/domain/model/TicketingTicket.kt deleted file mode 100644 index a8d6b1ca..00000000 --- a/domain/src/main/java/com/nexters/boolti/domain/model/TicketingTicket.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.nexters.boolti.domain.model - -data class TicketingTicket( // TODO Remove - val id: String, - val isInviteTicket: Boolean, - val title: String, - val price: Int, -) diff --git a/domain/src/main/java/com/nexters/boolti/domain/repository/TicketRepository.kt b/domain/src/main/java/com/nexters/boolti/domain/repository/TicketRepository.kt index 1aff3f74..b9ec3f93 100644 --- a/domain/src/main/java/com/nexters/boolti/domain/repository/TicketRepository.kt +++ b/domain/src/main/java/com/nexters/boolti/domain/repository/TicketRepository.kt @@ -1,11 +1,14 @@ package com.nexters.boolti.domain.repository -import com.nexters.boolti.domain.model.Ticket +import com.nexters.boolti.domain.model.LegacyTicket +import com.nexters.boolti.domain.model.TicketGroup import com.nexters.boolti.domain.request.ManagerCodeRequest import kotlinx.coroutines.flow.Flow interface TicketRepository { - suspend fun getTicket(): Flow> - suspend fun getTicket(ticketId: String): Flow + suspend fun legacyGetTicket(): Flow> + suspend fun legacyGetTicket(ticketId: String): Flow + fun getTickets(): Flow> + fun getTicket(reservationId: String): Flow suspend fun requestEntrance(request: ManagerCodeRequest): Flow } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/component/InstagramIndicator.kt b/presentation/src/main/java/com/nexters/boolti/presentation/component/InstagramIndicator.kt new file mode 100644 index 00000000..1a44ef8f --- /dev/null +++ b/presentation/src/main/java/com/nexters/boolti/presentation/component/InstagramIndicator.kt @@ -0,0 +1,253 @@ +package com.nexters.boolti.presentation.component + +import android.animation.ArgbEvaluator +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Card +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nexters.boolti.presentation.theme.BooltiTheme +import kotlin.math.absoluteValue + +private data class IndicatorRange( + override val start: Int, + override val endInclusive: Int, +) : ClosedRange { + init { + check(start <= endInclusive) + } + + val size: Int + get() = (endInclusive - start).absoluteValue + 1 + + operator fun minus(amount: Int): IndicatorRange = copy(start = start - amount, endInclusive = endInclusive - amount) + operator fun plus(amount: Int): IndicatorRange = copy(start = start + amount, endInclusive = endInclusive + amount) +} + +private class IndicatorUtil( + val dotCount: Int = 5, + val dotSize: Dp = 7.dp, + val mediumDotSize: Dp = 5.dp, + val smallDotSize: Dp = 4.dp, + val spacedBy: Dp = 8.dp, + val activeColor: Color = White, + val inactiveColor: Color = White.copy(alpha = 0.5f), +) { + private val evaluator = ArgbEvaluator() + + fun calculateWidth(pageCount: Int): Dp { + if (pageCount == 0) return 0.dp + + val visibleCount = minOf(pageCount, dotCount) + 4 + return dotSize * visibleCount + spacedBy * (visibleCount - 1) + } + + fun calculateHeight(): Dp = maxOf(dotSize, mediumDotSize, smallDotSize) + + fun calculateDotSize( + index: Int, + range: IndicatorRange, + offsetFraction: Float, + ): Dp { + if (range.contains(index)) return dotSize / 2 + + val diff = when (index < range.start) { + true -> offsetFraction - index + false -> index - (offsetFraction + dotCount - 1) + } + + return when { + diff < 1f -> (dotSize + (mediumDotSize - dotSize) * diff) / 2 + diff < 2f -> (mediumDotSize + (smallDotSize - mediumDotSize) * (diff - 1f)) / 2 + diff < 3f -> (smallDotSize - mediumDotSize * (diff - 2f)) / 2 + else -> 0.dp + } + } + + fun calculateDotColor(index: Int, pageCount: Int, pageFraction: Float): Color = when { + index in 0 until pageCount -> { + val fraction = 1 - (index - pageFraction).absoluteValue.coerceAtMost(1f) + Color(evaluator.evaluate(fraction, inactiveColor.toArgb(), activeColor.toArgb()) as Int) + } + + else -> Color.Transparent + } + + fun calculateX( + index: Int, + offsetFraction: Float, + ): Dp = dotSize / 2 + (dotSize + spacedBy) * index + + (dotSize + spacedBy) * 2 - + (dotSize + spacedBy) * offsetFraction +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun InstagramIndicator( + pagerState: PagerState, + modifier: Modifier = Modifier, + dotCount: Int = 6, + dotSize: Dp = 7.dp, + spacedBy: Dp = 8.dp, + activeColor: Color = White, + inactiveColor: Color = White.copy(alpha = 0.5f), +) { + val indicatorUtil = remember { + IndicatorUtil( + dotCount = dotCount, + dotSize = dotSize, + mediumDotSize = dotSize * 0.714f, + smallDotSize = dotSize * 0.571f, + spacedBy = spacedBy, + activeColor = activeColor, + inactiveColor = inactiveColor, + ) + } + + /** + * Normal 크기의 Dot 범위 + * + * 이 범위 내에서는 인디케이터가 슬라이딩 되지 않고 active dot 을 이동할 수 있다. + */ + var range by remember { + val s = when { + pagerState.currentPage >= dotCount -> (pagerState.currentPage - dotCount) + else -> 0 + } + mutableStateOf( + IndicatorRange( + start = s, + endInclusive = s + dotCount - 1, + ) + ) + } + + val pageFraction by remember { + derivedStateOf { + pagerState.currentPage + pagerState.currentPageOffsetFraction + } + } + + /** + * 화면에 보이는 모든 Dot 범위 + * + * [range] 양 옆에 2개의 추가 Dot 이 있으며, 표시할 수 있는 페이지 범위에서 벗어난 경우 화면에 그리지 않는다. + */ + val visibleRange by remember { + derivedStateOf { + IndicatorRange(range.start - 2, range.endInclusive + 2) + } + } + + val offsetFraction by animateFloatAsState( + targetValue = range.start.toFloat(), + animationSpec = spring(), + label = "offsetFraction", + ) + + /** + * 현재 페이지가 [range]를 벗어난 경우 [range]를 조정 + */ + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + range = when { + page > range.endInclusive -> range + 1 + page < range.start -> range - 1 + else -> range + } + } + } + + val indicatorWidth = remember(pagerState.pageCount) { + indicatorUtil.calculateWidth(pagerState.pageCount) + } + + val indicatorHeight = remember(indicatorUtil) { + indicatorUtil.calculateHeight() + } + + Canvas( + modifier = modifier + .size(width = indicatorWidth, height = indicatorHeight), + ) { + repeat(visibleRange.size) { i -> + val index = visibleRange.start + i + drawCircle( + color = indicatorUtil.calculateDotColor(index, pagerState.pageCount, pageFraction), + radius = indicatorUtil.calculateDotSize(index, range, offsetFraction).toPx(), + center = Offset( + x = indicatorUtil.calculateX(index, offsetFraction).toPx(), + y = center.y, + ), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +private fun InstagramIndicatorPreview() { + BooltiTheme { + Surface { + val pagerState = rememberPagerState { 20 } + + Column( + modifier = Modifier.padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HorizontalPager( + modifier = Modifier + .height(400.dp), + state = pagerState, + contentPadding = PaddingValues(horizontal = 24.dp), + pageSpacing = 16.dp, + ) { page -> + Card( + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text("page: ${page + 1}") + } + } + } + InstagramIndicator( + pagerState = pagerState, + modifier = Modifier.padding(top = 16.dp) + ) + } + } + } +} 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 6195fb56..b37ffaaf 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 @@ -65,7 +65,7 @@ fun ShowFeed( contentScale = ContentScale.Crop, ) - if (showState is ShowState.WaitingTicketing || showState is ShowState.FinishedShow) { + if (showState is ShowState.WaitingTicketing) { Box( modifier = Modifier .fillMaxWidth() 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 f96e8f1e..59624498 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 @@ -142,17 +142,30 @@ fun MainNavigation(modifier: Modifier, onClickQrScan: (showId: String, showName: ) } - TicketDetailScreen( - modifier = modifier, - navigateTo = navController::navigateTo, - popBackStack = navController::popBackStack - ) TicketingScreen( modifier = modifier, navigateTo = navController::navigateTo, popBackStack = navController::popBackStack, ) - QrFullScreen(modifier = modifier, popBackStack = navController::popBackStack) + + navigation( + route = "${MainDestination.TicketDetail.route}/{$ticketId}", + startDestination = "detail", + arguments = MainDestination.TicketDetail.arguments, + ) { + TicketDetailScreen( + modifier = modifier, + navigateTo = navController::navigateTo, + popBackStack = navController::popBackStack, + getSharedViewModel = { entry -> entry.sharedViewModel(navController) }, + ) + QrFullScreen( + modifier = modifier, + popBackStack = navController::popBackStack, + getSharedViewModel = { entry -> entry.sharedViewModel(navController) }, + ) + } + HostedShowScreen( modifier = modifier, onClickShow = onClickQrScan, 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 index 032eb015..cfe36e87 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainDestination.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/MainDestination.kt @@ -29,12 +29,7 @@ sealed class MainDestination(val route: String) { 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 Qr : MainDestination(route = "qr") data object Reservations : MainDestination(route = "reservations") data object ReservationDetail : MainDestination(route = "reservations") { 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 index 186dfce7..15ef653d 100644 --- 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 @@ -16,11 +16,6 @@ fun NavGraphBuilder.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) }, 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 9c20a35c..071d80f9 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 @@ -7,7 +7,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar @@ -40,13 +40,11 @@ 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( onClickShowItem: (showId: String) -> Unit, onClickTicket: (ticketId: String) -> Unit, - onClickQr: (data: String, ticketName: String) -> Unit, onClickQrScan: () -> Unit, onClickSignout: () -> Unit, navigateToReservations: () -> Unit, @@ -108,7 +106,6 @@ fun HomeScreen( when (loggedIn) { true -> TicketScreen( onClickTicket = onClickTicket, - onClickQr = onClickQr, modifier = modifier.padding(innerPadding), ) @@ -153,7 +150,7 @@ private fun HomeNavigationBar( modifier: Modifier = Modifier, ) { Column { - Divider( + HorizontalDivider( modifier = Modifier.fillMaxWidth(), thickness = 1.dp, color = Grey85, 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 deleted file mode 100644 index 5ea75cd6..00000000 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullNavigation.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 c64ede2e..4ae70449 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 @@ -1,100 +1,237 @@ package com.nexters.boolti.presentation.screen.qr +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxHeight 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.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState 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.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.White +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.nexters.boolti.domain.model.TicketState import com.nexters.boolti.presentation.R +import com.nexters.boolti.presentation.component.InstagramIndicator +import com.nexters.boolti.presentation.screen.MainDestination +import com.nexters.boolti.presentation.screen.ticket.detail.TicketDetailViewModel import com.nexters.boolti.presentation.theme.Grey10 -import com.nexters.boolti.presentation.theme.Grey85 +import com.nexters.boolti.presentation.theme.Grey20 +import com.nexters.boolti.presentation.theme.Grey60 +import com.nexters.boolti.presentation.theme.Grey70 import com.nexters.boolti.presentation.theme.Grey90 +import com.nexters.boolti.presentation.theme.point4 import com.nexters.boolti.presentation.util.rememberQrBitmapPainter +fun NavGraphBuilder.QrFullScreen( + popBackStack: () -> Unit, + getSharedViewModel: @Composable (NavBackStackEntry) -> TicketDetailViewModel, + modifier: Modifier = Modifier, +) { + composable( + route = MainDestination.Qr.route, + ) { entry -> + QrFullScreen( + modifier = modifier, + viewModel = getSharedViewModel(entry), + ) { popBackStack() } + } +} + +@OptIn(ExperimentalFoundationApi::class) @Composable fun QrFullScreen( modifier: Modifier = Modifier, - viewModel: QrFullViewModel = hiltViewModel(), + viewModel: TicketDetailViewModel = hiltViewModel(), onClose: () -> Unit, ) { - ConstraintLayout( - modifier = modifier - .background(Color.White) - .fillMaxSize() - ) { - val (closeButton, qr) = createRefs() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(uiState.currentPage) { uiState.ticketGroup.tickets.size } - IconButton( - onClick = onClose, + Scaffold( + modifier = modifier, + topBar = { Toolbar(onClose = onClose) }, + ) { innerPadding -> + Box( modifier = Modifier - .constrainAs(closeButton) { - top.linkTo(parent.top, margin = 10.dp) - end.linkTo(parent.end, margin = 20.dp) - } + .fillMaxSize() + .background(Color.White) + .padding(innerPadding), ) { - Icon( - painter = painterResource(R.drawable.ic_close), - tint = Grey90, - contentDescription = stringResource(R.string.description_close_button), - ) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxHeight(), + ) { page -> + QrContent( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 55.dp), + ticketState = uiState.ticketGroup.tickets[page].ticketState, + ticketName = uiState.ticketGroup.ticketName, + entryCode = uiState.ticketGroup.tickets[page].entryCode, + csTicketId = uiState.ticketGroup.tickets[page].csTicketId, + ) + } + if (pagerState.pageCount > 1) { + InstagramIndicator( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 20.dp), + pagerState = pagerState, + activeColor = Color.Black, + inactiveColor = Grey20, + ) + } } + } +} - Column( +@Composable +fun QrContent( + modifier: Modifier = Modifier, + ticketState: TicketState, + ticketName: String, + entryCode: String, + csTicketId: String, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( modifier = Modifier - .constrainAs(qr) { - centerTo(parent) + .drawWithCache { + val color = Grey10 + onDrawBehind { + drawRoundRect( + color = color, + cornerRadius = CornerRadius(100.dp.toPx(), 100.dp.toPx()), + ) + } } - .background( - color = Grey10, - shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, + .padding(vertical = 4.dp, horizontal = 16.dp), + text = ticketName, + style = MaterialTheme.typography.titleMedium, + color = Grey70, + ) + + Box( + modifier = Modifier + .height(IntrinsicSize.Max) + .width(IntrinsicSize.Max), ) { - 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), + modifier = Modifier.padding(top = 16.dp), painter = rememberQrBitmapPainter( - viewModel.data, - size = 260.dp, + content = entryCode, + size = 240.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, + when (ticketState) { + TicketState.Used -> QrCoverView( + MaterialTheme.colorScheme.primary, + stringResource(R.string.ticket_used_state), + ) + + TicketState.Finished -> QrCoverView( + Grey60, + stringResource(R.string.ticket_show_finished_state), + ) + + TicketState.Ready -> Unit + } + } + + Text( + modifier = Modifier.padding(top = 12.dp), + text = csTicketId, + style = MaterialTheme.typography.bodySmall, + color = Grey70, + ) + } +} + +@Composable +fun BoxScope.QrCoverView( + color: Color, + text: String, +) { + Box( + modifier = Modifier.Companion + .matchParentSize() + .background(White.copy(0.9f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(42.dp), + imageVector = ImageVector.vectorResource(R.drawable.ic_logo), + tint = color, contentDescription = null, ) + Text( + text = text, + style = point4, + fontSize = 20.sp, + color = color, + ) + } + } +} + +@Composable +private fun Toolbar(modifier: Modifier = Modifier, onClose: () -> Unit) { + Box( + modifier = modifier + .fillMaxWidth() + .height(44.dp), + ) { + IconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 8.dp), + onClick = onClose + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + tint = Grey90, + contentDescription = stringResource(R.string.description_close_button), + ) } } } 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 deleted file mode 100644 index b84f0df3..00000000 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/qr/QrFullViewModel.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.nexters.boolti.presentation.screen.qr - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -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/ticket/TicketContent.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/TicketContent.kt index d455c684..03d8f4a9 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 @@ -28,33 +28,32 @@ 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.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.Black import androidx.compose.ui.graphics.Color.Companion.White import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale 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.res.vectorResource 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 import coil.compose.AsyncImage -import com.nexters.boolti.domain.model.Ticket -import com.nexters.boolti.domain.model.TicketState +import com.nexters.boolti.domain.model.TicketGroup import com.nexters.boolti.presentation.R import com.nexters.boolti.presentation.component.DottedDivider import com.nexters.boolti.presentation.extension.dayOfWeekString import com.nexters.boolti.presentation.extension.format 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.Grey80 import com.nexters.boolti.presentation.theme.Grey95 @@ -62,30 +61,27 @@ import com.nexters.boolti.presentation.theme.marginHorizontal import com.nexters.boolti.presentation.theme.point2 import com.nexters.boolti.presentation.util.TicketShape import com.nexters.boolti.presentation.util.asyncImageBlurModel -import com.nexters.boolti.presentation.util.rememberQrBitmapPainter import java.time.LocalDateTime @Composable fun Ticket( modifier: Modifier = Modifier, - ticket: Ticket, + ticket: TicketGroup, onClick: () -> Unit, - onClickQr: (data: String, ticketName: String) -> Unit, ) { Card( modifier = modifier, shape = RectangleShape, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background), ) { - TicketContent(ticket = ticket, onClick = onClick, onClickQr = onClickQr) + TicketContent(ticket = ticket, onClick = onClick) } } @Composable private fun TicketContent( - ticket: Ticket, + ticket: TicketGroup, onClick: () -> Unit, - onClickQr: (data: String, ticketName: String) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -151,7 +147,7 @@ private fun TicketContent( ), ) Column { - Title(ticket.ticketName, ticket.csTicketId) + Title(ticket.ticketName, ticket.tickets.size) AsyncImage( modifier = Modifier .fillMaxWidth() @@ -174,9 +170,7 @@ private fun TicketContent( showName = ticket.showName, showDate = ticket.showDate, placeName = ticket.placeName, - entryCode = ticket.entryCode, - ticketState = ticket.ticketState, - onClickQr = { onClickQr(it, ticket.ticketName) }, + onClickQr = onClick, ) } // 티켓 좌상단 꼭지점 그라데이션 @@ -198,34 +192,31 @@ private fun TicketContent( @Composable private fun Title( ticketName: String = "", - csTicketId: String = "", + ticketCount: Int = 0, ) { Row( modifier = Modifier .background(White.copy(alpha = 0.3f)) .alpha(0.65f) - .padding(horizontal = 20.dp, vertical = 10.dp), + .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { + Text( + modifier = Modifier + .padding(start = 20.dp) + .weight(1f), + text = ticketName + " • " + stringResource(R.string.ticket_count, ticketCount), + style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), + color = Grey80, + ) Icon( modifier = Modifier - .padding(end = 4.dp) - .size(20.dp), + .padding(end = 16.dp) + .size(22.dp), painter = painterResource(R.drawable.ic_logo), tint = Grey80, contentDescription = null, ) - Text( - modifier = Modifier.weight(1f), - text = ticketName, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), - color = Grey80, - ) - Text( - text = csTicketId, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), - color = Grey80, - ) } } @@ -235,9 +226,7 @@ private fun TicketInfo( showName: String, showDate: LocalDateTime, placeName: String, - entryCode: String, - ticketState: TicketState, - onClickQr: (entryCode: String) -> Unit, + onClickQr: () -> Unit, ) { Row( modifier = Modifier @@ -280,68 +269,29 @@ private fun TicketInfo( } } Spacer(modifier = Modifier.padding(12.dp)) - TicketQr(entryCode, ticketState, onClickQr) + TicketQr(onClickQr = onClickQr) } } @Composable private fun TicketQr( - entryCode: String, - ticketState: TicketState, - onClickQr: (entryCode: String) -> Unit, + modifier: Modifier = Modifier, + onClickQr: () -> Unit, ) { Box( - modifier = Modifier, + modifier = modifier, contentAlignment = Alignment.Center, ) { Image( modifier = Modifier - .padding(vertical = 8.dp) - .clip(RoundedCornerShape(4.dp)) - .background(White) - .padding(2.dp) - .clickable { - if (ticketState == TicketState.Ready) onClickQr(entryCode) - }, - painter = rememberQrBitmapPainter( - entryCode, - size = 66.dp, - ), - contentScale = ContentScale.Inside, + .size(58.dp) + .clip(RoundedCornerShape(8.dp)) + .border(width = 1.dp, color = White, RoundedCornerShape(8.dp)) + .padding(12.dp) + .clickable(onClick = onClickQr), + imageVector = ImageVector.vectorResource(R.drawable.ic_qr), + contentScale = ContentScale.Fit, contentDescription = stringResource(R.string.description_qr), ) - if (ticketState != TicketState.Ready) { - val color = when (ticketState) { - TicketState.Used -> MaterialTheme.colorScheme.primary - TicketState.Finished -> Grey40 - else -> Grey40 - } - Box( - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .size(70.dp) - .background( - brush = SolidColor(Black), - alpha = 0.8f, - ) - ) - Text( - modifier = Modifier - .graphicsLayer(rotationZ = -16f) - .border( - width = 2.dp, - color = color, - shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 16.dp, vertical = 8.dp), - text = when (ticketState) { - TicketState.Used -> stringResource(R.string.ticket_used_state) - TicketState.Finished -> stringResource(R.string.ticket_show_finished_state) - else -> "" - }, - style = MaterialTheme.typography.titleMedium, - color = color, - ) - } } } 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 6a3018f7..f23259f4 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 @@ -30,7 +30,6 @@ import kotlin.math.absoluteValue @Composable fun TicketScreen( onClickTicket: (String) -> Unit, - onClickQr: (entryCode: String, ticketName: String) -> Unit, modifier: Modifier = Modifier, viewModel: TicketViewModel = hiltViewModel(), ) { @@ -49,7 +48,6 @@ fun TicketScreen( uiState.tickets.isNotEmpty() -> TicketNotEmptyScreen( modifier, uiState, - onClickQr, onClickTicket = onClickTicket ) @@ -62,7 +60,6 @@ fun TicketScreen( private fun TicketNotEmptyScreen( modifier: Modifier, uiState: TicketUiState, - onClickQr: (entryCode: String, ticketName: String) -> Unit, onClickTicket: (ticketId: String) -> Unit, ) { Column( @@ -83,7 +80,7 @@ private fun TicketNotEmptyScreen( .align(Alignment.CenterHorizontally) .weight(1f), state = pagerState, - key = { uiState.tickets[it].ticketId }, + key = { uiState.tickets[it].reservationId }, contentPadding = PaddingValues(horizontal = contentPadding), pageSpacing = pageSpacing, ) { page -> @@ -108,9 +105,8 @@ private fun TicketNotEmptyScreen( translationX = sign * size.width * (1 - it) / 2 } }, - onClick = { onClickTicket(ticket.ticketId) }, + onClick = { onClickTicket(ticket.reservationId) }, ticket = ticket, - onClickQr = onClickQr, ) } Card( @@ -124,6 +120,7 @@ private fun TicketNotEmptyScreen( Text( text = "${pagerState.currentPage + 1}/${pagerState.pageCount}", modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), + style = MaterialTheme.typography.bodySmall, ) } } 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 a9003ec8..fa708318 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 @@ -1,8 +1,8 @@ package com.nexters.boolti.presentation.screen.ticket -import com.nexters.boolti.domain.model.Ticket +import com.nexters.boolti.domain.model.TicketGroup data class TicketUiState( val loading: Boolean = false, - val tickets: List = emptyList(), + 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 e440d9b1..80ffc483 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 @@ -1,17 +1,17 @@ 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.launchIn import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.singleOrNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import javax.inject.Inject @HiltViewModel @@ -22,18 +22,14 @@ class TicketViewModel @Inject constructor( val uiState = _uiState.asStateFlow() fun load() { - 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) - } + ticketRepository.getTickets() + .onStart { _uiState.update { it.copy(loading = true) } } + .onCompletion { _uiState.update { it.copy(loading = false) } } + .onEach { tickets -> + _uiState.update { + it.copy(tickets = tickets) } - } + } + .launchIn(viewModelScope + recordExceptionHandler) } } 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 50def90c..5223faf8 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 @@ -6,6 +6,7 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -14,17 +15,24 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +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.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,6 +53,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -53,90 +62,88 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Black import androidx.compose.ui.graphics.Color.Companion.White import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalClipboardManager 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.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 -import androidx.compose.ui.unit.Dp 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.NavBackStackEntry import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import coil.compose.AsyncImage +import com.nexters.boolti.domain.model.TicketGroup 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.InstagramIndicator import com.nexters.boolti.presentation.component.ShowInquiry 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.screen.qr.QrCoverView import com.nexters.boolti.presentation.theme.BooltiTheme -import com.nexters.boolti.presentation.theme.Grey20 +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.Grey40 import com.nexters.boolti.presentation.theme.Grey50 +import com.nexters.boolti.presentation.theme.Grey60 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 -import com.nexters.boolti.presentation.theme.point2 import com.nexters.boolti.presentation.util.TicketShape +import com.nexters.boolti.presentation.util.UrlParser import com.nexters.boolti.presentation.util.asyncImageBlurModel import com.nexters.boolti.presentation.util.rememberQrBitmapPainter import kotlinx.coroutines.launch import java.time.LocalDate -import java.time.LocalDateTime fun NavGraphBuilder.TicketDetailScreen( navigateTo: (String) -> Unit, popBackStack: () -> Unit, + getSharedViewModel: @Composable (NavBackStackEntry) -> TicketDetailViewModel, modifier: Modifier = Modifier, ) { composable( - route = "${MainDestination.TicketDetail.route}/{$ticketId}", + route = "detail", arguments = MainDestination.TicketDetail.arguments, - ) { + ) { entry -> TicketDetailScreen( modifier = modifier, onBackClicked = popBackStack, - onClickQr = { code, ticketName -> - navigateTo( - "${MainDestination.Qr.route}/${code.filter { c -> c.isLetterOrDigit() }}?ticketName=$ticketName" - ) + onClickQr = { + navigateTo(MainDestination.Qr.route) }, - navigateToShowDetail = { navigateTo("${MainDestination.ShowDetail.route}/$it") } + navigateToShowDetail = { navigateTo("${MainDestination.ShowDetail.route}/$it") }, + viewModel = getSharedViewModel(entry), ) } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable private fun TicketDetailScreen( modifier: Modifier = Modifier, viewModel: TicketDetailViewModel = hiltViewModel(), onBackClicked: () -> Unit, - onClickQr: (entryCode: String, ticketName: String) -> Unit, + onClickQr: () -> Unit, navigateToShowDetail: (showId: String) -> Unit, ) { val scrollState = rememberScrollState() @@ -148,16 +155,17 @@ private fun TicketDetailScreen( val scope = rememberCoroutineScope() val context = LocalContext.current - val ticketInfoHeight = 125.dp 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 uiState by viewModel.uiState.collectAsStateWithLifecycle() val managerCodeState by viewModel.managerCodeState.collectAsStateWithLifecycle() - val ticket = uiState.ticket + val ticketGroup = uiState.ticketGroup + val pagerState = rememberPagerState { ticketGroup.tickets.size } + val currentTicket = remember(ticketGroup, pagerState.currentPage) { + ticketGroup.tickets.getOrElse(pagerState.currentPage) { TicketGroup.Ticket() } + } val pullToRefreshState = rememberPullToRefreshState() @@ -175,8 +183,18 @@ private fun TicketDetailScreen( } } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage } + .collect(viewModel::syncCurrentPage) + } + Scaffold( - topBar = { BtBackAppBar(onClickBack = onBackClicked) }, + topBar = { + BtBackAppBar( + title = stringResource(R.string.ticket_detail_title), + onClickBack = onBackClicked, + ) + }, snackbarHost = { ToastSnackbarHost( modifier = Modifier.padding(bottom = 54.dp), @@ -190,7 +208,11 @@ private fun TicketDetailScreen( } } - Box(modifier = Modifier.nestedScroll(pullToRefreshState.nestedScrollConnection)) { + Box( + modifier = Modifier + .nestedScroll(pullToRefreshState.nestedScrollConnection) + .padding(top = 16.dp) + ) { Column( modifier = modifier .padding(innerPadding) @@ -202,7 +224,7 @@ private fun TicketDetailScreen( height = ticketSectionHeight, circleRadius = 10.dp.toPx(), cornerRadius = 8.dp.toPx(), - bottomAreaHeight = bottomAreaHeight, + bottomAreaHeight = ticketSectionHeightUntilTicketInfo, ) Column( modifier = Modifier @@ -224,75 +246,86 @@ private fun TicketDetailScreen( ) { Box { // 배경 블러된 이미지 - AsyncImage( - model = asyncImageBlurModel(context, ticket.poster, radius = 24), - modifier = Modifier - .size( - width = contentWidth.toDp(), - height = ticketSectionHeightUntilTicketInfo.toDp(), - ) - .alpha(.8f), - contentScale = ContentScale.Crop, - contentDescription = null, - ) - // 배경 블러된 이미지 위에 올라가는 그라데이션 배경 - Box( + Box(contentAlignment = Alignment.BottomCenter) { + AsyncImage( + model = asyncImageBlurModel(context, ticketGroup.poster, radius = 24), + modifier = Modifier + .width(contentWidth.toDp()) + .aspectRatio(317 / 570f) + .alpha(.8f), + contentScale = ContentScale.Crop, + contentDescription = null, + ) + Box( + Modifier + .fillMaxWidth() + .aspectRatio(317 / 125f) + .background( + brush = Brush.verticalGradient(listOf(Black.copy(alpha = 0f), Black)), + ) + ) + } + Column( modifier = Modifier - .size( - width = contentWidth.toDp(), - height = ticketSectionHeightUntilTicketInfo.toDp(), - ) .background( brush = Brush.linearGradient( colors = listOf( - Color(0x33C5CACD), - Grey95.copy(alpha = .2f) - ), - start = Offset.Zero, - end = Offset( - x = contentWidth, - y = ticketSectionHeightUntilTicketInfo + Color(0xCCC5CACD), + Grey95.copy(alpha = .8f) ), ), + shape = ticketShape, ) - ) - Column( - modifier = Modifier - .onGloballyPositioned { coordinates -> - ticketSectionHeightUntilTicketInfo = - coordinates.size.height.toFloat() - } ) { - Title(ticketName = ticket.ticketName, csTicketId = ticket.csTicketId) + Title(showName = ticketGroup.showName) - AsyncImage( + QrCodes( modifier = Modifier - .padding(marginHorizontal) - .aspectRatio(0.75f) - .clip(RoundedCornerShape(8.dp)), - model = ticket.poster, - contentScale = ContentScale.Crop, - contentDescription = stringResource(R.string.description_poster) + .fillMaxWidth() + .padding(top = 20.dp) + .padding(horizontal = marginHorizontal), + ticketGroup = ticketGroup, + pagerState = pagerState, + onClickQr = onClickQr, ) DottedDivider( modifier = Modifier .fillMaxWidth() + .padding(top = 28.dp) .padding(horizontal = marginHorizontal), color = White.copy(alpha = 0.3f), thickness = 2.dp ) - TicketInfo( - bottomAreaHeight = ticketInfoHeight, - showName = ticket.showName, - showDate = ticket.showDate, - placeName = ticket.placeName, - entryCode = ticket.entryCode, - ticketState = ticket.ticketState, - onClickQr = { onClickQr(it, ticket.ticketName) }, - ) + Column( + modifier = Modifier + .onGloballyPositioned { coordinates -> + ticketSectionHeightUntilTicketInfo = + coordinates.size.height.toFloat() + } + ) { + Notice(notice = ticketGroup.ticketNotice) + + val copiedMessage = stringResource(id = R.string.ticketing_address_copied_message) + Inquiry( + hostName = ticketGroup.hostName, + hostPhoneNumber = ticketGroup.hostPhoneNumber, + onClickCopyPlace = { + clipboardManager.setText(AnnotatedString(ticketGroup.streetAddress)) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + scope.launch { + snackbarHostState.showSnackbar(copiedMessage) + } + } + }, + onClickNavigateToShowDetail = { + navigateToShowDetail(ticketGroup.showId) + } + ) + } } + // 티켓 좌상단 꼭지점 그라데이션 Box( modifier = Modifier @@ -307,32 +340,12 @@ private fun TicketDetailScreen( ), ) } - - Notice(notice = ticket.ticketNotice) - - val copiedMessage = - stringResource(id = R.string.ticketing_address_copied_message) - Inquiry( - hostName = ticket.hostName, - hostPhoneNumber = ticket.hostPhoneNumber, - onClickCopyPlace = { - clipboardManager.setText(AnnotatedString(ticket.streetAddress)) - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { - scope.launch { - snackbarHostState.showSnackbar(copiedMessage) - } - } - }, - onClickNavigateToShowDetail = { - navigateToShowDetail(ticket.showId) - } - ) } Spacer(modifier = Modifier.size(20.dp)) RefundPolicySection(uiState.refundPolicy) - if (uiState.ticket.ticketState == TicketState.Ready) { + if (currentTicket.ticketState == TicketState.Ready) { Text( modifier = Modifier .padding(top = 20.dp, bottom = 60.dp) @@ -353,7 +366,7 @@ private fun TicketDetailScreen( } if (showEnterCodeDialog) { - if (LocalDate.now().toEpochDay() < uiState.ticket.showDate.toLocalDate().toEpochDay()) { + if (LocalDate.now().toEpochDay() < uiState.ticketGroup.showDate.toLocalDate().toEpochDay()) { // 아직 공연일 아님 BTDialog( showCloseButton = false, @@ -366,7 +379,7 @@ private fun TicketDetailScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } else if (uiState.ticket.ticketState == TicketState.Ready) { + } else if (currentTicket.ticketState == TicketState.Ready) { ManagerCodeDialog( managerCode = managerCodeState.code, onManagerCodeChanged = viewModel::setManagerCode, @@ -396,160 +409,125 @@ private fun TicketDetailScreen( } @Composable -private fun Title( - ticketName: String = "", - csTicketId: String = "", -) { +private fun Title(showName: String = "") { Row( modifier = Modifier .background(White.copy(alpha = 0.3f)) .alpha(0.65f) - .padding(horizontal = 20.dp, vertical = 10.dp), + .padding(horizontal = 20.dp, vertical = 6.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.copy(fontWeight = FontWeight.SemiBold), + text = showName, + style = MaterialTheme.typography.titleSmall, color = Grey80, ) - Text( - text = csTicketId, - style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), - color = Grey80, + Image( + modifier = Modifier + .padding(end = 4.dp) + .size(22.dp), + painter = painterResource(R.drawable.ic_logo), + colorFilter = ColorFilter.tint(Grey80), + contentDescription = null, ) } } +@OptIn(ExperimentalFoundationApi::class) @Composable -private fun TicketInfo( - bottomAreaHeight: Dp, - showName: String, - showDate: LocalDateTime, - placeName: String, - entryCode: String, - ticketState: TicketState, - onClickQr: (entryCode: String) -> Unit, +private fun QrCodes( + modifier: Modifier = Modifier, + ticketGroup: TicketGroup, + pagerState: PagerState, + onClickQr: () -> Unit, ) { - Row( - modifier = Modifier - .height(bottomAreaHeight) - .background( - brush = Brush.verticalGradient( - listOf( - MaterialTheme.colorScheme.background.copy(alpha = .2f), - MaterialTheme.colorScheme.background - ), - ) - ) - .padding(marginHorizontal), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = showName, - style = point2, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onPrimary, + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .aspectRatio(0.75f) + .clip(RoundedCornerShape(8.dp)) + .background(White), + state = pagerState, + pageSpacing = 8.dp, + ) { i -> + val ticket = ticketGroup.tickets[i] + QrCode( + modifier = Modifier.clickable(onClick = onClickQr), + ticketName = ticketGroup.ticketName, + qrCode = ticket.entryCode, + csTicketId = ticket.csTicketId, + ticketState = ticket.ticketState, + ) + } + if (ticketGroup.tickets.size > 1) { + InstagramIndicator( + modifier = Modifier.padding(top = 16.dp), + pagerState = pagerState, ) - Row( - modifier = Modifier.padding(top = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - - Text( - text = showDate.format("yyyy.MM.dd (${showDate.dayOfWeekString})"), - style = MaterialTheme.typography.bodySmall, - color = Grey30, - ) - Box( - modifier = Modifier - .padding(horizontal = 6.dp) - .size(width = 1.dp, height = 13.dp) - .background(Grey50), - ) - Text( - text = placeName, - style = MaterialTheme.typography.bodySmall, - color = Grey30, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } } - Spacer(modifier = Modifier.padding(12.dp)) - TicketQr(entryCode, ticketState, onClickQr) } } @Composable -private fun TicketQr( - entryCode: String, +private fun QrCode( + ticketName: String, + qrCode: String, + csTicketId: String, ticketState: TicketState, - onClickQr: (entryCode: String) -> Unit, + modifier: Modifier = Modifier, ) { - if (entryCode.isBlank()) return - Box( - modifier = Modifier, + modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Image( - modifier = Modifier - .padding(vertical = 8.dp) - .clip(RoundedCornerShape(4.dp)) - .background(White) - .padding(2.dp) - .clickable { - if (ticketState == TicketState.Ready) onClickQr(entryCode) - }, - painter = rememberQrBitmapPainter( - entryCode, - size = 66.dp, - ), - contentScale = ContentScale.Inside, - contentDescription = stringResource(R.string.description_qr), - ) - if (ticketState != TicketState.Ready) { - val color = when (ticketState) { - TicketState.Used -> MaterialTheme.colorScheme.primary - TicketState.Finished -> Grey40 - else -> Grey40 - } + Column( + modifier = Modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(Grey10) + .padding(horizontal = 16.dp, vertical = 4.dp), + text = ticketName, + color = Grey70, + style = MaterialTheme.typography.titleMedium, + ) Box( modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .size(70.dp) - .background( - brush = SolidColor(Color.Black), - alpha = 0.8f, + .height(IntrinsicSize.Max) + .width(IntrinsicSize.Max), + ) { + Image( + modifier = Modifier.padding(top = 16.dp), + painter = rememberQrBitmapPainter(qrCode, size = 178.dp), + contentDescription = stringResource(R.string.description_qr), + ) + when (ticketState) { + TicketState.Used -> QrCoverView( + MaterialTheme.colorScheme.primary, + stringResource(R.string.ticket_used_state), ) - ) + + TicketState.Finished -> QrCoverView( + Grey60, + stringResource(R.string.ticket_show_finished_state), + ) + + TicketState.Ready -> Unit + } + } Text( modifier = Modifier - .graphicsLayer(rotationZ = -16f) - .border( - width = 2.dp, - color = color, - shape = RoundedCornerShape(8.dp), - ) - .padding(horizontal = 16.dp, vertical = 8.dp), - text = when (ticketState) { - TicketState.Used -> stringResource(R.string.ticket_used_state) - TicketState.Finished -> stringResource(R.string.ticket_show_finished_state) - else -> "" - }, - style = MaterialTheme.typography.titleMedium, - color = color, + .padding(top = 16.dp), + text = csTicketId, + style = MaterialTheme.typography.bodySmall, + color = Grey70, ) } } @@ -560,20 +538,29 @@ private fun Notice(notice: String) { Column( modifier = Modifier .padding(horizontal = marginHorizontal) - .padding(top = 16.dp, bottom = 20.dp) + .padding(top = 36.dp, bottom = 20.dp) .fillMaxWidth(), ) { + val uriHandler = LocalUriHandler.current + val urlParser = remember(notice) { UrlParser(notice) } + Text( text = stringResource(R.string.ticket_notice_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text( + ClickableText( modifier = Modifier.padding(top = 12.dp), - text = notice, - style = MaterialTheme.typography.bodySmall, - color = Grey50, - ) + text = urlParser.annotatedString, + style = MaterialTheme.typography.bodySmall.copy(color = Grey30), + ) { offset -> + val urlOffset = urlParser.urlOffsets.find { (start, end) -> offset in start.. }, + onClickQr = {}, navigateToShowDetail = {}) } } diff --git a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt index e8c9b457..546779f8 100644 --- a/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt +++ b/presentation/src/main/java/com/nexters/boolti/presentation/screen/ticket/detail/TicketDetailUiState.kt @@ -1,8 +1,11 @@ package com.nexters.boolti.presentation.screen.ticket.detail -import com.nexters.boolti.domain.model.Ticket +import com.nexters.boolti.domain.model.LegacyTicket +import com.nexters.boolti.domain.model.TicketGroup data class TicketDetailUiState( - val ticket: Ticket = Ticket(), + val legacyTicket: LegacyTicket = LegacyTicket(), val refundPolicy: List = emptyList(), + val ticketGroup: TicketGroup = TicketGroup(), + val currentPage: Int = 0, ) 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 5fe17bde..81e1db0a 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 @@ -54,9 +54,10 @@ class TicketDetailViewModel @Inject constructor( TicketException.TicketNotFound -> event(TicketDetailEvent.NotFound) } } - .singleOrNull()?.let { ticket -> - _uiState.update { it.copy(ticket = ticket) } + .onEach { ticketGroup -> + _uiState.update { it.copy(ticketGroup = ticketGroup) } } + .launchIn(viewModelScope + recordExceptionHandler) getRefundPolicyUsecase().onEach { refundPolicy -> _uiState.update { it.copy(refundPolicy = refundPolicy) } @@ -67,10 +68,11 @@ class TicketDetailViewModel @Inject constructor( fun refresh() = load() fun requestEntrance(managerCode: String) { - val ticket = uiState.value.ticket + val ticket = uiState.value.legacyTicket + val ticketGroup = uiState.value.ticketGroup viewModelScope.launch(recordExceptionHandler) { repository.requestEntrance( - ManagerCodeRequest(showId = ticket.showId, ticketId = ticket.ticketId, managerCode = managerCode) + ManagerCodeRequest(showId = ticketGroup.showId, ticketId = ticket.ticketId, managerCode = managerCode) ).catch { e -> when (e) { is ManagerCodeException -> { @@ -92,6 +94,10 @@ class TicketDetailViewModel @Inject constructor( _managerCodeState.update { it.copy(code = code, error = null) } } + fun syncCurrentPage(page: Int) { + _uiState.update { it.copy(currentPage = page) } + } + private fun event(event: TicketDetailEvent) { viewModelScope.launch { _event.send(event) 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 f81a5bfc..67aa1135 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 @@ -60,6 +60,13 @@ private val subhead1 = createTextStyle( lineHeight = 24.sp, ) +private val subhead0 = createTextStyle( + fontFamily = pretendardFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 14.sp, + lineHeight = 22.sp, +) + private val body4 = createTextStyle( fontFamily = pretendardFamily, fontWeight = FontWeight.Normal, @@ -154,11 +161,11 @@ val Typography = Typography( titleLarge = subhead2, titleMedium = subhead1, + titleSmall = subhead0, - titleSmall = body4, bodyLarge = body3, bodyMedium = body2, bodySmall = body1, labelMedium = caption, -) \ No newline at end of file +) diff --git a/presentation/src/main/res/drawable/ic_qr.xml b/presentation/src/main/res/drawable/ic_qr.xml new file mode 100644 index 00000000..f8e10cc0 --- /dev/null +++ b/presentation/src/main/res/drawable/ic_qr.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index e700d993..d8621d3e 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -55,7 +55,7 @@ 다음 보기 - 예매자 + 방문자 결제자 티켓 초청 티켓 @@ -103,6 +103,7 @@ 공연 관련 문의 공연장 주소 복사 공연 정보 보기 + 티켓 상세 옵션 선택 @@ -124,7 +125,7 @@ 공연 종료 1인 %d매 결제하기 - 예매자 정보 + 방문자 정보 결제자 정보 티켓 정보 초청 코드 @@ -146,7 +147,7 @@ 이미 사용된 초청 코드입니다 올바른 초청 코드를 입력해 주세요 초청 코드를 입력해 주세요 - 예매자와 결제자가 같아요 + 방문자와 결제자가 같아요 티켓 종류 티켓 매수 총 결제 금액 @@ -187,7 +188,6 @@ 예금주 입금 마감일 결제를 완료했어요 - 예매자 정보 확인 후 티켓이 발권됩니다. 결제에 실패했어요 예매 진행 중 오류가 발생하였습니다.\n다시 시도해 주세요 @@ -195,7 +195,7 @@ 불티 로그인 하러가기 원하는 공연 티켓을 예매해보세요! 로그아웃 - 예매 내역 + 결제 내역 QR 스캔 정말 로그아웃 하시겠어요? 공연 등록 @@ -206,8 +206,8 @@ 공연을 주최하고 QR 스캐너로\n관객 입장을 관리해 보세요 사용되었어요 - - 예매 내역이 없어요 + + 결제 내역이 없어요 티켓을 예매하고 공연을 즐겨보세요! 입금 확인 중 취소 진행 중 @@ -219,8 +219,8 @@ "%s / %d매 / %,d원" %d매 / %,d원" - - 예매 내역 상세 + + 결제 내역 상세 입금 계좌 정보 결제 정보 결제 수단 @@ -234,7 +234,7 @@ 환불 내역 총 환불 금액 티켓 보기 - 예매 내역보기 + 결제 내역보기 취소 요청하기